diff --git a/src/services/EnvSyncService.ts b/src/services/EnvSyncService.ts index 42c62e7..e0be1bd 100644 --- a/src/services/EnvSyncService.ts +++ b/src/services/EnvSyncService.ts @@ -35,6 +35,15 @@ const STALE_AFTER_MS = 8 * 60 * 60 * 1000; const KEEPALIVE_INTERVAL_MS = 2 * 60 * 1000; /** Intervalle minimum accepté (anti-boucle serrée). */ const KEEPALIVE_MIN_INTERVAL_MS = 30 * 1000; +/** + * Nombre de sondes pour « réveiller » une session fraîchement créée avant d'écrire les .env. + * Une session OAuth2 ENT n'est pleinement active qu'après une première requête authentifiée : + * sans ça, les cookies écrits pointent vers une session pas encore initialisée et sont rejetés + * au 1er essai (puis acceptés au 2e, l'utilisateur étant devenu « chaud »). Cf. bug reconnect-last. + */ +const SESSION_WARMUP_ATTEMPTS = 5; +/** Délai entre deux sondes de réveil (ms). */ +const SESSION_WARMUP_DELAY_MS = 400; export interface ConnectOptions { env?: string; @@ -95,6 +104,11 @@ export function reconnectOptionsFromLast(last: LastConnection): ConnectOptions { }; } +/** Sommeil simple (non annulable). */ +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + /** Sommeil annulable via un AbortSignal. Retourne 'aborted' si interrompu. */ function sleep(ms: number, signal: AbortSignal): Promise<'done' | 'aborted'> { return new Promise((resolve) => { @@ -200,6 +214,9 @@ export class EnvSyncService { password: creds.password, }); + // Réveille la session côté serveur avant d'écrire les .env (cf. SESSION_WARMUP_ATTEMPTS). + await this.warmSession(env, cookies.sessionId); + await recordConnection( env.id, creds.login, @@ -224,6 +241,24 @@ export class EnvSyncService { return cookies; } + /** + * « Réveille » la session fraîchement créée : sonde `/auth/oauth2/userinfo` jusqu'à une + * réponse vivante (la sonde réalise la première requête authentifiée qui finalise la session + * côté serveur). Best-effort : si la sonde échoue (réseau) ou n'aboutit pas dans le budget de + * tentatives, on poursuit quand même l'écriture des .env plutôt que d'échouer la connexion. + */ + private async warmSession(env: EnvironmentConfig, sessionId: string): Promise { + for (let attempt = 1; attempt <= SESSION_WARMUP_ATTEMPTS; attempt++) { + try { + if (await this.authClient.isSessionAlive(env.url, sessionId)) return; + } catch { + // Réseau indisponible : inutile d'insister, on tentera l'écriture des .env. + return; + } + if (attempt < SESSION_WARMUP_ATTEMPTS) await delay(SESSION_WARMUP_DELAY_MS); + } + } + /** * Boucle keep-alive (premier plan) : ping régulier de la session pour la maintenir active * (timeout d'inactivité glissant). Tant que la session répond, on ne touche à rien — donc diff --git a/tests/services/EnvSyncService.test.ts b/tests/services/EnvSyncService.test.ts index e74c43d..d2a4a6a 100644 --- a/tests/services/EnvSyncService.test.ts +++ b/tests/services/EnvSyncService.test.ts @@ -20,17 +20,31 @@ import { AuthError } from '../../src/utils/errors.js'; /** Faux client d'auth : enregistre les arguments reçus et renvoie des cookies fixes. */ class FakeAuthClient implements IAuthClient { calls: Array<{ envUrl: string; credentials: AuthCredentials }> = []; + /** Sessions sondées par le réveil (warmSession), dans l'ordre. */ + aliveCalls: Array<{ envUrl: string; sessionId: string }> = []; + /** + * File de réponses pour isSessionAlive : chaque entrée est consommée par appel. + * Une fois la file vide, renvoie true (session réveillée). + */ + private aliveQueue: boolean[] = []; constructor(private readonly result: AuthCookies | Error) {} + /** Programme les réponses successives de isSessionAlive (ex. [false, false, true]). */ + withAliveSequence(seq: boolean[]): this { + this.aliveQueue = [...seq]; + return this; + } + async loginAndGetCookies(envUrl: string, credentials: AuthCredentials): Promise { this.calls.push({ envUrl, credentials }); if (this.result instanceof Error) throw this.result; return this.result; } - async isSessionAlive(): Promise { - return true; + async isSessionAlive(envUrl: string, sessionId: string): Promise { + this.aliveCalls.push({ envUrl, sessionId }); + return this.aliveQueue.shift() ?? true; } } @@ -108,6 +122,24 @@ describe.sequential('EnvSyncService.runInteractive', () => { expect(typeof recents[0].connectedAt).toBe('number'); }); + it('réveille la session (sonde) avant d’écrire le .env, et sonde la bonne session', async () => { + const client = new FakeAuthClient({ xsrfToken: 'x', sessionId: 'sess-456' }).withAliveSequence([ + false, + true, + ]); + const service = new EnvSyncService(client); + + await service.runInteractive({ env: ENV_ID, login: LOGIN, apps: ['myapp'], skipConfirm: true }); + + // la session a été sondée jusqu'à réponse vivante, avec le sessionId fraîchement obtenu + expect(client.aliveCalls).toHaveLength(2); + expect(client.aliveCalls.every((c) => c.sessionId === 'sess-456')).toBe(true); + + // et les cookies ont bien été écrits une fois la session active + const env = await readEnvFile(appEnvPath); + expect(env.VITE_ONE_SESSION_ID).toBe('sess-456'); + }); + it("ne touche pas au .env si l'authentification échoue", async () => { const client = new FakeAuthClient(new AuthError('bad credentials')); const service = new EnvSyncService(client);