00.01 — Cartographie de l'écosystème

00.01 — Cartographie de l’écosystème#

L’écosystème Ocarina est constitué de six dépôts publics sur le compte GitHub mojo-molotov. Ils s’organisent en cinq rôles distincts qui se composent :

RôleDépôtForme
FrameworkocarinaBibliothèque Python publiée sur PyPI
Application sous test (SUT) publiqueigoristanSPA React + Vike déployée sur GitHub Pages
Backend de coordinationtests-workersAPI Vercel Edge + Upstash Redis
Suite d’exemplesocarina-example (canonique), ocarina-with-ai-example (IA)Projets Python e2e tournés vers l’Igoristan / CURA
Documentation publiqueocarina-holy-bookSite VitePress FR + EN, PDF, skills IA

Schéma d’ensemble (big picture)#

┌─────────────────────────────────────────────────────────────────────────────────┐
│                              OCARINA (BIG PICTURE)                              │
└─────────────────────────────────────────────────────────────────────────────────┘

   ┌──────────────────────────────┐         ┌──────────────────────────────────┐
   │  [PACKAGE]  ocarina          │ ──────► │  [PACKAGE]  ocarina-example      │
   │  Framework Python 3.14+      │   pip   │  Suite e2e contre l'Igoristan    │
   │  ROP / DSL / orchestration   │ install │  (Selenium + Firefox + Redis)    │
   └──────────────────────────────┘         └──────────────────────────────────┘
            │  │                                       │  ▲   │
            │  │                                       │  │  utilise
            │  │ pip install ocarina                   │  │  IGOR_API_KEY
            │  ▼                                       ▼  │   │
            │  ┌────────────────────────────────────┐  pilote │
            │  │ [PACKAGE]  ocarina-with-ai-example │  ┌──────┴──────────────────┐
            │  │ Suite e2e CURA Healthcare          │  │ [WEBSITE]  igoristan    │
            │  │ Claude Code, 99% machine           │  │ React 19 + Vike         │
            │  └────────────────────────────────────┘  │ GitHub Pages            │
            │              │                           └──────┬──────────────────┘
            │              │ pilote                           │ fetch OTP
            │              ▼                                  ▼
            │   ┌──────────────────────────────┐    ┌──────────────────────────────┐
            │   │ [WEBSITE]  CURA Healthcare   │    │ [BACKEND]  tests-workers     │
            │   │ Heroku, PHP open source      │    │ Vercel Edge Functions        │
            │   └──────────────────────────────┘    │ + Upstash Redis              │
            │                                       │ /api/otp, /otp-history,      │
            │                                       │ /api/corsicadex              │
            │                                       └──────────────────────────────┘
            │                                                 ▲
            │                                                 │ x-api-key
            │                                                 │ (IGOR_API_KEY ≡ API_SECRET)
            │                                                 │
            ▼
   ┌──────────────────────────────┐
   │  [DOCS]  ocarina-holy-book   │
   │  VitePress FR + EN + RU      │
   │  Skills IA, PDF, llms.txt    │
   │  GitHub Pages                │
   └──────────────────────────────┘
  1. ocarina → suites d’exemples : dépendance Python classique (pip install ocarina).
  2. Suites d’exemples → SUT (Igoristan / CURA) : pilotage navigateur via Selenium.
  3. Suite Igoristan ↔ tests-workers : appels HTTP (OTP, Corsicadex) ; le secret partagé IGOR_API_KEY (côté client) ≡ API_SECRET (côté serveur).

Responsabilités#

RôleContratBesoin
FrameworkDonner le DSL, l’orchestration, la pool de drivers, les reportersDoit être petit, auditable, sans dépendances cachées
SUT publicOffrir un terrain de jeu volontairement chaotique (random errors, OTP, formulaires capricieux)Hébergé en permanence sur GitHub Pages, sans backend lourd
Backend de coordinationCoordonner les workers sur l’OTP, fournir des données pour les tests parallèlesStateless côté code, l’état vit dans Redis (Upstash)
ExemplesDémontrer comment on utilise réellement Ocarina, à la fois sans IA (canonique) et avec IA (CURA)Doivent être publics, exécutables, complets, suivis en CI
DocumentationTransmettre le pourquoi, le comment, et l’usage IADoit exister en EN + FR + RU (tradition corso-russe), exposer des fichiers adaptés aux LLMs comme llms.txt, générer des PDF

Trois licences#

LicenceDépôts concernésPourquoi
MITocarina, ocarina-example, ocarina-with-ai-example, ocarina-holy-bookTout ce qui est code Python ou doc, transmissible « tel quel », souverain — voir ../11-independence/.
ISCtests-workersChoix par défaut du scaffold Vercel Edge, conservé.
(aucune licence)igoristanApplication démo publique, hébergée par l’auteur (à considérer comme WTFPL).

Points de friction volontaires entre dépôts#

Le design est volontairement asymétrique sur certains axes ; ces frictions sont des outils de test :

06.01 — Stack Vercel Edge

06.01 — Stack Vercel Edge#

package.json#

{
  "name": "tests-workers",
  "version": "1.0.0",
  "scripts": {
    "vercel-build": "echo 'Build skipped'"
  },
  "packageManager": "pnpm@11.2.2",
  "dependencies": {
    "@upstash/redis": "^1.36.2",
    "otplib": "^13.2.1"
  },
  "devDependencies": {
    "@types/node": "^25.2.0",
    "next": "^16.1.6",
    "@vercel/node": "^5.5.28",
    "typescript": "^5.9.3"
  },
  "license": "ISC"
}
LibRôle
@upstash/redis (runtime)Client Redis REST (pas TCP). Compatible Edge.
otplib (runtime)Génération TOTP RFC 6238.
next (dev seulement)Types uniquement (NextRequest). Pas d’app Next.js.
@vercel/node (dev seulement)Types Vercel.
@types/node (dev seulement)Types Node 22+.

Note : aucune dépendance runtime au-delà de @upstash/redis + otplib.
Le bundle est microscopique → cold start < 100ms.

06.02 — GET /api/otp

06.02 — GET /api/otp#

Fichier source : api/otp.ts

Code#

import { generate } from "otplib";
import type { NextRequest } from "next/server";
import redis from "../lib/redis";
import isAuthorized from "../lib/isAuthorized";

export const config = { runtime: "edge" };

const SECRET_QUERY_PARAM_KEY = "secret";
const OTP_SECRET_MIN_LENGTH = 32;
const OTP_SECRET_MAX_LENGTH = 64;
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const OTP_TTL = 60 * 5; // 5 minutes
const OTP_HISTORY_TTL = OTP_TTL + 60; // 6 minutes
const MAX_FREE_PAYLOAD_SIZE = 4096;

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, OPTIONS",
  "Access-Control-Allow-Headers": "x-api-key, Content-Type",
} as const;

function generateRandomBase32Secret(length: number = 32): string {
  let secret = "";
  const randomValues = new Uint8Array(length);
  crypto.getRandomValues(randomValues);
  for (let i = 0; i < length; i++) {
    secret += BASE32_ALPHABET[randomValues[i] % 32];
  }
  return secret;
}

function ensureSecretLength(secret: string): string {
  const cleaned = secret.toUpperCase().replace(/\s/g, "");
  if (cleaned.length > OTP_SECRET_MAX_LENGTH) {
    const validLength = Math.floor(OTP_SECRET_MAX_LENGTH / 8) * 8;
    return cleaned.substring(0, validLength);
  }
  if (cleaned.length >= OTP_SECRET_MIN_LENGTH) {
    const validLength = Math.floor(cleaned.length / 8) * 8;
    return cleaned.substring(0, validLength);
  }
  const repetitions = Math.ceil(OTP_SECRET_MIN_LENGTH / cleaned.length);
  const repeated = cleaned.repeat(repetitions);
  const validLength = Math.floor(repeated.length / 8) * 8;
  return repeated.substring(0, Math.max(validLength, OTP_SECRET_MIN_LENGTH));
}

export default async function handler(request: NextRequest) {
  if (request.method === "OPTIONS") {
    return new Response(null, { status: 200, headers: corsHeaders });
  }

  if (!isAuthorized(request)) {
    return new Response(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
      headers: { "Content-Type": "application/json", ...corsHeaders },
    });
  }

  const { searchParams } = new URL(request.url);
  const freePayload: Record<string, string> = {};
  let payloadSize = 0;

  searchParams.forEach((value, key) => {
    if (key === SECRET_QUERY_PARAM_KEY) return;
    const entrySize = new TextEncoder().encode(key + value).length;
    if (payloadSize + entrySize > MAX_FREE_PAYLOAD_SIZE) {
      payloadSize += entrySize;
      return;
    }
    freePayload[key] = value;
    payloadSize += entrySize;
  });

  if (payloadSize > MAX_FREE_PAYLOAD_SIZE) {
    return new Response(
      JSON.stringify({
        error: "Bad Request",
        message: `Free payload exceeds maximum size of ${MAX_FREE_PAYLOAD_SIZE} bytes (got ${payloadSize} bytes)`,
      }),
      {
        status: 400,
        headers: { "Content-Type": "application/json", ...corsHeaders },
      },
    );
  }

  const rawSecret =
    searchParams.get(SECRET_QUERY_PARAM_KEY) ?? generateRandomBase32Secret();
  const secret = ensureSecretLength(rawSecret);
  const otpCode = await generate({ secret });
  const now = Date.now();
  const expiresAtMs = now + OTP_TTL * 1000;
  const createdAtTimestampLackingMsPrecision = new Date(
    now - (now % 1000),
  ).toISOString();

  const event = {
    ...freePayload,
    otpCode,
    secret,
    createdAtTimestampLackingMsPrecision,
    expiresAt: new Date(expiresAtMs).toISOString(),
  };

  const key = `otp:${now}:${crypto.randomUUID()}`;
  await redis.set(key, JSON.stringify(event), { ex: OTP_HISTORY_TTL });

  return new Response(JSON.stringify(event), {
    headers: { "Content-Type": "application/json", ...corsHeaders },
  });
}

Flot#

1. Request arrive
   ↓
2. Si OPTIONS → 200 + CORS headers (preflight)
   ↓
3. isAuthorized(request) ? Non → 401
   ↓
4. Parse searchParams ; tout sauf "secret" est mis dans freePayload
   ↓
5. payloadSize > 4 KB ? → 400
   ↓
6. secret = ?secret=<X> (si fourni) sinon random Base32 32-chars
   ↓
7. ensureSecretLength : assure 32..64 chars, multiple de 8
   ↓
8. otpCode = generate({ secret })   ← otplib TOTP RFC 6238
   ↓
9. createdAt = floor(now / 1000) * 1000  ← amputation des ms
   ↓
10. event = { ...freePayload, otpCode, secret, createdAt, expiresAt }
    ↓
11. key = `otp:<now>:<uuid>`
    ↓
12. redis.set(key, JSON.stringify(event), { ex: 360 })  ← TTL 6 min
    ↓
13. Response = JSON.stringify(event), 200

CORS headers#

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, OPTIONS",
  "Access-Control-Allow-Headers": "x-api-key, Content-Type",
} as const;
  • * origin : permet à l’Igoristan (depuis https://mojo-molotov.github.io) d’appeler le service.
  • GET, OPTIONS : seuls verbes supportés.
  • Custom header x-api-key autorisé (sinon le browser bloquerait).

as const rend l’objet readonly côté TypeScript.

05.03 — Le faux useAuth + MFA OTP

05.03 — Le faux useAuth + MFA OTP#

Le hook useAuth de l’Igoristan est volontairement faux. Il introduit deux mécaniques pour exercer le rejeu et la coordination des workers d’Ocarina : un échec aléatoire de 10% et un MFA OTP optionnel.

Code#

// src/hooks/useAuth.ts
const VALID_PASSWORD = "figatellu";
const NOT_AUTHENTICATED = -1;
const AUTHENTICATED_WITHOUT_MFA = 1;
const AUTHENTICATED_WITH_MFA = 2;

// * ... This is FAKED
export const useAuth = () => {
  const [_isAuthenticated, setIsAuthenticated] = useLocalStorage(
    "isAuthenticated",
    NOT_AUTHENTICATED,
  );
  const [isLoading, setIsLoading] = useState(true);

  const isAuthenticated = useCallback(
    () => _isAuthenticated !== NOT_AUTHENTICATED,
    [_isAuthenticated],
  );
  const isAuthenticatedWithMFA = useCallback(
    () => _isAuthenticated === AUTHENTICATED_WITH_MFA,
    [_isAuthenticated],
  );
  const logout = useCallback(
    () => setIsAuthenticated(NOT_AUTHENTICATED),
    [setIsAuthenticated],
  );

  useEffect(() => {
    const delay = 500 * randint(1, 5); // ◄── délai aléatoire 0.5-2.5s
    const timer = setTimeout(() => setIsLoading(false), delay);
    return () => clearTimeout(timer);
  }, []);

  const login: LoginFn = ({ pre = false, password, withMFA }) => {
    if (password === VALID_PASSWORD && Math.random() < 0.9) {
      // ◄── 10% d'échec malgré bon mdp
      if (!pre) {
        setIsAuthenticated(
          withMFA ? AUTHENTICATED_WITH_MFA : AUTHENTICATED_WITHOUT_MFA,
        );
      }
      return true;
    }
    return false;
  };

  return { isAuthenticatedWithMFA, isAuthenticated, isLoading, logout, login };
};

Mécaniques#

1. LocalStorage#

const [_isAuthenticated, setIsAuthenticated] = useLocalStorage(
  "isAuthenticated",
  NOT_AUTHENTICATED,
);

Trois valeurs : -1 (pas authentifié), 1 (sans MFA), 2 (avec MFA).
Persistées dans localStorage, faciles d’accès pour du test/debug manuel.

06.03 — GET /api/otp-history

06.03 — GET /api/otp-history#

Fichier source : api/otp-history.ts

Code#

import type { NextRequest } from "next/server";
import redis from "../lib/redis";
import isAuthorized from "../lib/isAuthorized";

export const config = { runtime: "edge" };

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, OPTIONS",
  "Access-Control-Allow-Headers": "x-api-key, Content-Type",
} as const;

export default async function handler(request: NextRequest) {
  if (request.method === "OPTIONS") {
    return new Response(null, { status: 200, headers: corsHeaders });
  }

  if (!isAuthorized(request)) {
    return new Response(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
      headers: { "Content-Type": "application/json", ...corsHeaders },
    });
  }

  let cursor = "0";
  const allEvents = [];
  const BATCH_SIZE = 100;

  do {
    const [newCursor, keys] = await redis.scan(cursor, {
      match: "otp:*",
      count: BATCH_SIZE,
    });
    cursor = newCursor;

    if (keys.length > 0) {
      const values = await redis.mget(...keys);
      allEvents.push(...values.filter(Boolean));
    }
  } while (cursor !== "0");

  return new Response(JSON.stringify(allEvents), {
    headers: { "Content-Type": "application/json", ...corsHeaders },
  });
}

Mécanique de SCAN Redis#

let cursor = "0";
const allEvents = [];
const BATCH_SIZE = 100;

do {
  const [newCursor, keys] = await redis.scan(cursor, {
    match: "otp:*",
    count: BATCH_SIZE,
  });
  cursor = newCursor;

  if (keys.length > 0) {
    const values = await redis.mget(...keys);
    allEvents.push(...values.filter(Boolean));
  }
} while (cursor !== "0");
  1. cursor = "0" (initialisation).
  2. Boucle :
    • SCAN <cursor> MATCH otp:* COUNT 100 retourne [newCursor, keys].
    • Si keys non vide : MGET k1 k2 ... récupère les valeurs.
    • cursor = newCursor.
  3. On s’arrête quand cursor === "0" (cycle terminé).

Pourquoi SCAN plutôt que KEYS ?

07.03 — Scénarios Dashboard login

07.03 — Scénarios Dashboard login#

Trois familles : happy paths, unhappy paths, data-driven multi-login. Tous exercent le useAuth à 10% de raté et la coordination OTP.

dashboard_login#

# src/tests/campaigns/dashboard_login.py
def create_igoristan_login_campaign(*, drivers_pool: SeleniumWebDriversPool) -> TestCampaign:
    return TestCampaign(
        name="Dashboard login",
        suites=[
            create_igoristan_login_happy_paths_test_suite(drivers_pool=drivers_pool),
            create_igoristan_login_unhappy_paths_test_suite(drivers_pool=drivers_pool),
            create_igoristan_login_data_driven_test_suite(drivers_pool=drivers_pool),
        ],
    )

Happy paths#

# src/tests/suites/dashboard/access/happy_paths.py
def create_igoristan_login_happy_paths_test_suite(*, drivers_pool) -> TestSuite:
    return TestSuite(
        name="Login happy paths",
        tests=[
            test_dashboard_login_without_otp_happy_path,
            test_dashboard_login_with_otp_happy_path,
            test_dashboard_login_page_back_to_igoristan_button,
        ],
        drivers_pool=drivers_pool,
    )

Test 1 : login sans OTP#

def dashboard_login_without_otp_happy_path(driver, logger):
    dashboard_creds = create_env_getters().get_credentials("dashboard")
    on_dashboard_login_page = DashboardLoginPage(driver=driver)
    on_dashboard_welcome_page = DashboardWelcomePage(driver=driver)

    just_log_error = create_just_log_error(logger=logger)
    log_error_with_current_url = create_log_error_with_current_url(logger=logger, driver=driver)
    just_log_success = create_just_log_success(logger=logger)
    log_success_with_current_url_and_take_screenshot = create_log_success_with_current_url_and_take_screenshot(...)

    retries_amount = max(get_max_workers(), 10)

    return [
        drive_page(
            act(on_dashboard_login_page, open_dashboard_login_page)
                .failure(just_log_error("Failed to open the dashboard login page..."))
                .success(just_log_success("Opened the dashboard login page!")),
            act(on_dashboard_login_page, verify_dashboard_login_page)
                .failure(log_error_with_current_url("Failed to verify..."))
                .success(log_success_with_current_url_and_take_screenshot("Verified!")),
            act(on_dashboard_login_page,
                login_without_otp_and_with_retries(dashboard_creds, retries_amount, logger=logger))
                .failure(just_log_error("Failed to connect to the dashboard without OTP..."))
                .success(just_log_success("Connected to the dashboard!")),
        ),
        drive_page(
            act(on_dashboard_welcome_page, verify_dashboard_welcome_page)
                .failure(log_error_with_current_url("Failed to verify..."))
                .success(log_success_with_current_url_and_take_screenshot("Verified!")),
        ),
    ]
  1. retries_amount = max(get_max_workers(), 10) : on rejoue le login interne (au POM) au moins 10 fois, à cause des 10% d’échec aléatoire.
  2. login_without_otp_and_with_retries(creds, retries_amount, logger=logger) : closure qui capture creds + nb retries + logger. Le connector retourne Callable[[DashboardLoginPage], DashboardLoginPage].
  3. 2 drive_page : login + welcome page. Chacun ouvre, vérifie, screenshot.
  4. Log factories partout : aucune lambda inline.
  5. SeleniumTitleMixin côté POM : get_current_title() est utilisé par le hook on_failure de act.

Test 2 : login avec OTP#

def dashboard_login_with_otp_happy_path(driver, logger):
    # ... setup pareil ...

    cache = in_memory_cache_with_30m_ttl
    fresh_cache_key_for_username = reserve_free_cache_key(cache)
    fresh_cache_key_for_otp_send_button_click_date = reserve_free_cache_key(cache)

    return [
        drive_page(
            act(on_dashboard_login_page, open_dashboard_login_page)...,
            act(on_dashboard_login_page, verify_dashboard_login_page)...,
            act(on_dashboard_login_page,
                start_to_login_with_otp_and_with_retries(
                    dashboard_creds, retries_amount,
                    cache=cache, logger=logger,
                    username_cache_key=fresh_cache_key_for_username,
                    otp_send_button_click_date_cache_key=fresh_cache_key_for_otp_send_button_click_date,
                ))...,
            act(on_dashboard_login_page, verify_otp_screen)...,
            act(on_dashboard_login_page,
                type_otp_with_retries(
                    retries_amount,
                    cache=cache, logger=logger,
                    username_cache_key=fresh_cache_key_for_username,
                    otp_send_button_click_date_cache_key=fresh_cache_key_for_otp_send_button_click_date,
                ))...,
        ),
        drive_page(
            act(on_dashboard_welcome_page, verify_dashboard_welcome_page)...,
            act(on_dashboard_welcome_page, click_on_go_to_nested_page_btn)...,
        ),
        drive_page(
            act(on_dashboard_protected_page, verify_dashboard_protected_page)...,
        ),
    ]

Trois drive_page. Cinq act dans le premier. Cache L1 + clés réservées pour partager des valeurs entre acts (cf. 08-caches-locks.md).

00.04 — Relations entre les six dépôts

00.04 — Relations entre les six dépôts#

Chaque arête du graphe est un contrat : un secret, une URL, un type de payload, ou une version. On les détaille ici, par paire.

ocarinaocarina-example#

DirectionMécanisme
ocarina → ocarina-examplepip install ocarina depuis PyPI
ocarina-example → ocarinaAucune (l’exemple ne pousse rien)

L’exemple écrit ses propres adapters au-dessus du framework :

  • lib/ext/ocarina/adapters/agnostic/act.py → ajoute le hook on_failure qui transforme une page d’erreur HTTP (titre matché par ERROR_PAGE_REGEX) en HttpErrorPageReachedError.
  • lib/ext/ocarina/adapters/agnostic/match_page.py → create_match_page(raised_exceptions=transient_errors).
  • lib/ext/ocarina/adapters/agnostic/env_getters.py → typed EnvGetters[_CredsKeys, _ValuesKeys].
  • lib/ext/ocarina/adapters/selenium/test_suite.py → fige max_retries_per_test=8, transient_errors=..., autoscreen_on_fail=True, propage --only/--exclude.
  • lib/ext/ocarina/adapters/selenium/test_campaign.py → fige max_workers=get_max_workers().

ocarinaocarina-with-ai-example#

  • pip install . ruff mypy mypy-extensions typing-extensions pre-commit car l’exemple IA a la dépendance fixée dans son pyproject.toml : ocarina>=1.0.3.
  • L’adapter act est minimaliste ici : pas de on_failure (CURA n’a pas de page d’erreur volontairement aléatoire à intercepter). Voir ../08-ai-example/
  • L’adapter create_drivers_pool est surchargé pour bâtir un Chrome clean (password manager off, leak detection off). Voir ../08-ai-example/06-data-gaps.md

ocarina-exampleigoristan#

DirectionContratDétail
ocarina-example → igoristanURLs publiquesTout passe par https://mojo-molotov.github.io/igoristan/<route>
igoristan → ocarina-exampleaucun (le SUT ne sait pas qu’il est testé) — 

src/constants/pages/ :

06.07 — Flux de coordination OTP + l'anti-précision volontaire

06.07 — Flux de coordination OTP + l’imprécision volontaire#

La raison d’être du backend : permettre à N workers parallèles d’Ocarina de récupérer le bon OTP pour leur user, même quand plusieurs OTP sont générés.

Flux#

   ┌───────────────────────────────────────────────────────────────────┐
   │               Worker (parmi --workers 3 d'Ocarina)                │
   └───────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Selenium ouvre la page de connexion au Dashboard                  │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Acquisition du lock distribué Redis (OTP_SEND_LOCK_KEY)           │
   │   → seul ce worker peut cliquer "Send OTP" pendant ACQ            │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ min_utc_date = datetime.now(UTC)                                  │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Cache L1 : enregistre min_utc_date + username dans le cache       │
   │   (clés réservées par reserve_free_cache_key)                     │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Selenium tape username + password + cocher OTP + click "REQ OTP"  │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ IGORISTAN UI : fetch /api/otp?_user=<username>                    │
   │   (x-api-key tapé par Selenium dans l'UI)                         │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ TESTS-WORKERS /api/otp :                                          │
   │   generate(secret) → otpCode                                      │
   │   createdAt = floor(now/1000)*1000  ← amputation ms               │
   │   event = { _user, otpCode, createdAt, expiresAt, ... }           │
   │   redis.set("otp:<now>:<uuid>", JSON, EX 360)                     │
   │   return event                                                    │
   └──────────────────────────────────┬────────────────────────────────┘
                                      ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ IGORISTAN UI : affiche écran OTP                                  │
   └──────────────────────────────────┬────────────────────────────────┘
                                      ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Release du lock Redis OTP_SEND_LOCK_KEY                           │
   └──────────────────────────────────┬────────────────────────────────┘
                                      ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Selenium : retrieve_dashboard_otp_code(min_utc_date, _user)       │
   │   ↓                                                               │
   │   GET /api/otp-history  (x-api-key = IGOR_API_KEY)                │
   │   ↓                                                               │
   │   TESTS-WORKERS : SCAN otp:* + MGET → all events                  │
   │   ↓                                                               │
   │   filtre côté client par _user                                    │
   │   filtre createdAt >= min_utc_date - 1s                           │
   │   tri ASC sur createdAt                                           │
   │   return first.otpCode                                            │
   └──────────────────────────────────┬────────────────────────────────┘
                                      ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Selenium tape l'OTP dans l'UI Igoristan                           │
   │   → AUTHENTICATED_WITH_MFA                                        │
   └───────────────────────────────────────────────────────────────────┘

Races conditions#

  1. Worker A : min_utc_date_A = 13:27:53.123, génère OTP_A à 13:27:53.250.
  2. Worker B : min_utc_date_B = 13:27:53.130, génère OTP_B à 13:27:53.470.

Sans coordination, A pourrait récupérer OTPB (au lieu de OTP_A) parce que les timestamps sont _très proches (et tronqués à la seconde près).

Chapitre 06 — tests-workers, backend Vercel Edge

Chapitre 06 — tests-workers, backend Vercel Edge#

Trois endpoints HTTP, deux libs (@upstash/redis, otplib), zéro Next.js applicatif. Le strict minimum pour coordonner des tests parallèles sur l’Igoristan.

Plan#

#FichierSujet
0101-stack-edge.mdStack Vercel Edge Functions, runtime: "edge", Upstash Redis, otplib.
0202-otp-endpoint.mdGET /api/otp — génération + Redis SET + free payload.
0303-otp-history.mdGET /api/otp-history — SCAN Redis + retour de tous les events.
0404-corsicadex.mdGET /api/corsicadex?id=N — lookup statique.
0505-is-authorized.mdisAuthorized.ts — x-api-key vs apiKey query param.
0606-redis-upstash.mdlib/redis.ts — config Upstash.
0707-otp-coordination-flow.mdLe flux complet OTP : Igoristan ↔ workers ↔ Ocarina ↔ Redis.

Objectifs#

  • 3 endpoints .ts.
  • 2 fichiers lib/.
  • 1 fichier consts/.
  • Total : ~7 fichiers TypeScript de code, plus package.json et vercel.json.

L’objectif : être déployable en 30 secondes par un humain.