Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/services/EnvSyncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -95,6 +104,11 @@ export function reconnectOptionsFromLast(last: LastConnection): ConnectOptions {
};
}

/** Sommeil simple (non annulable). */
function delay(ms: number): Promise<void> {
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) => {
Expand Down Expand Up @@ -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,
Expand All @@ -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<void> {
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
Expand Down
36 changes: 34 additions & 2 deletions tests/services/EnvSyncService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthCookies> {
this.calls.push({ envUrl, credentials });
if (this.result instanceof Error) throw this.result;
return this.result;
}

async isSessionAlive(): Promise<boolean> {
return true;
async isSessionAlive(envUrl: string, sessionId: string): Promise<boolean> {
this.aliveCalls.push({ envUrl, sessionId });
return this.aliveQueue.shift() ?? true;
}
}

Expand Down Expand Up @@ -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);
Expand Down
Loading