From 9988e506de08bba1633b04933d83c3c1f42edc48 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 12:05:43 +0200 Subject: [PATCH] fix(stalker): redetect session recipes atomically --- .../stalker-session-manager.spec.ts | 63 +++++++++++++++- .../stalker-session-manager.ts | 71 ++++++++++++++++--- .../lib/stalker-session-descriptor.spec.ts | 28 +++++++- .../src/lib/stalker-session-descriptor.ts | 9 ++- .../src/lib/stalker-session.service.spec.ts | 50 +++++++++++-- .../src/lib/stalker-session.service.ts | 55 ++++++++------ 6 files changed, 234 insertions(+), 42 deletions(-) diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.spec.ts index f49cd1e8b..aea3b02f3 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.spec.ts @@ -563,10 +563,15 @@ describe('StalkerSessionManager', () => { }) ).resolves.toMatchObject({ action: 'commit', kind: 'success' }); - await manager.request(1, catalogRequest(existing.leaseRef)); + await expect( + manager.request(1, catalogRequest(existing.leaseRef)) + ).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.InvalidIdentityInput, + }); await manager.request(1, catalogRequest(provisional.leaseRef)); expect(oldAuth.requests).toHaveLength(0); - expect(promotedAuth.requests).toHaveLength(2); + expect(promotedAuth.requests).toHaveLength(1); }); it('revalidates a stale provisional principal before promotion', async () => { @@ -1414,4 +1419,58 @@ describe('StalkerSessionManager', () => { }) ).resolves.toMatchObject({ action: 'commit', kind: 'success' }); }); + + it('force-redetects from the original source without a learned hint and can commit a stateless reclassification', async () => { + const { manager, resolve } = harness({ + auths: [new FakeAuth()], + resolverOutcomes: [full(), stateless()], + }); + const originalSource = 'https://portal.test/original/c/'; + const opened = await manager.open(1, { + descriptor: descriptor('redetect', 'persisted-open', { + learnedEndpointHint: + 'https://portal.test/stale/server/load.php', + sourceUrl: originalSource, + }), + }); + expectFullReady(opened); + + const redetected = await manager.control(1, { + action: 'force-redetect', + leaseRef: opened.leaseRef, + }); + + expect(redetected).toMatchObject({ + attemptRef: expect.any(String), + connectionMode: 'provisional', + kind: 'ready', + recipe: 'stateless-mac', + }); + if ( + redetected.kind !== 'ready' || + redetected.connectionMode !== 'provisional' + ) { + throw new Error('expected-provisional-redetection'); + } + expect(resolve.mock.calls[1][0].descriptor).toMatchObject({ + connectionMode: 'provisional', + sourceUrl: originalSource, + }); + expect(resolve.mock.calls[1][0].descriptor.learnedEndpointHint).toBe( + undefined + ); + + await expect( + manager.control(1, { + action: 'commit', + attemptRef: redetected.attemptRef, + }) + ).resolves.toMatchObject({ action: 'commit', kind: 'success' }); + await expect( + manager.request(1, catalogRequest(opened.leaseRef)) + ).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.InvalidIdentityInput, + }); + }); }); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.ts index 911b5f158..9f231690d 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.ts @@ -789,16 +789,7 @@ export class StalkerSessionManager { return controlSuccess(requestId, request.action); } if (request.action === 'force-redetect') { - const refreshed = await this.#refreshSession(lease.session); - if (refreshed.kind === 'ready') { - return controlSuccess(requestId, request.action); - } - return this.#projectRefreshFailure( - senderId, - lease.session, - refreshed, - requestId - ); + return this.#forceRedetect(senderId, lease, requestId); } return invalidIdentityFailure(requestId, 'ready'); } @@ -1303,6 +1294,7 @@ export class StalkerSessionManager { throw new Error('stalker-session-promotion-became-stale'); } + const replacedLeaseRefs = this.#previousPlaylistLeaseRefs(attempt); const destination = this.#sessions.get(ready.key); const promoted: SessionRecord = { ...(ready.accountSummary === undefined @@ -1370,6 +1362,12 @@ export class StalkerSessionManager { this.#leases.set(lease.ref, lease); this.#sessions.set(promoted.key, promoted); this.#reactivateTransferredLeases(promoted); + for (const replacedLeaseRef of replacedLeaseRefs) { + const replacedLease = this.#leases.get(replacedLeaseRef); + if (replacedLease !== undefined && replacedLease !== lease) { + this.#closeLease(replacedLease); + } + } return { ref: lease.ref, session: promoted }; } @@ -1399,6 +1397,7 @@ export class StalkerSessionManager { return invalidIdentityFailure(requestId, 'promoting'); } if (attempt.ready.kind === 'stateless-mac') { + this.#closePreviousPlaylistLeases(attempt); this.#finishAttempt(attempt, action); return controlSuccess(requestId, action); } @@ -1419,6 +1418,58 @@ export class StalkerSessionManager { return controlSuccess(requestId, action); } + async #forceRedetect( + senderId: number, + lease: LeaseRecord, + requestId: string + ): Promise { + const { learnedEndpointHint: _learnedEndpointHint, ...persisted } = + lease.session.descriptor; + const descriptor: StalkerSessionConnectionDescriptor = { + ...persisted, + connectionMode: 'provisional', + provisionalReason: 'migration', + }; + const attempt = this.#createAttempt(senderId, { + descriptor, + transport: lease.session.transport, + }); + attempt.previousSessions.add(lease.session); + attempt.credentialCandidate = lease.session.credentials; + this.#attempts.set(attempt.ref, attempt); + this.#scheduleExpiryCleanup(); + const progress = await this.#authenticateFresh( + attempt, + attempt.credentialCandidate + ); + return this.#publishProgress(attempt, progress, requestId); + } + + #closePreviousPlaylistLeases(attempt: AttemptRecord): void { + for (const ref of this.#previousPlaylistLeaseRefs(attempt)) { + const lease = this.#leases.get(ref); + if (lease !== undefined) { + this.#closeLease(lease); + } + } + } + + #previousPlaylistLeaseRefs(attempt: AttemptRecord): Set { + const refs = new Set(); + for (const previous of attempt.previousSessions) { + for (const ref of previous.leases) { + const lease = this.#leases.get(ref); + if ( + lease?.senderId === attempt.senderId && + lease.playlistRef === attempt.descriptor.playlistRef + ) { + refs.add(ref); + } + } + } + return refs; + } + #finishAttempt(attempt: AttemptRecord, action: 'commit' | 'discard'): void { this.#challengeRegistry.invalidateAttempt( attempt.senderId, diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.spec.ts index 47ccf2040..d6ab7ad35 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.spec.ts @@ -62,8 +62,7 @@ describe('mapPlaylistToStalkerSessionConnection', () => { expect(result).toEqual({ descriptor: { connectionMode: 'persisted-open', - learnedEndpointHint: - 'https://portal.example/server/load.php', + learnedEndpointHint: 'https://portal.example/server/load.php', macAddress: '00:1A:79:AA:BB:CC', playlistRef: 'playlist-1', profilePreset: { @@ -194,6 +193,25 @@ describe('mapPlaylistToStalkerSessionConnection', () => { expect(result.descriptor).not.toHaveProperty('credentials'); }); + it('does not auto-submit a legacy username when its password is missing or empty', () => { + expect( + mapPlaylistToStalkerSessionConnection( + playlist({ + password: undefined, + username: ' exact user ', + }) + ).savedCredentials + ).toBeUndefined(); + expect( + mapPlaylistToStalkerSessionConnection( + playlist({ + password: '', + username: ' exact user ', + }) + ).savedCredentials + ).toBeUndefined(); + }); + it('never reads the legacy stalkerToken into the new mapping', () => { const result = mapPlaylistToStalkerSessionConnection( playlist({ stalkerToken: 'legacy-token-secret' }) @@ -203,7 +221,11 @@ describe('mapPlaylistToStalkerSessionConnection', () => { }); it.each([ - playlist({ stalkerSourceUrl: undefined, portalUrl: undefined, url: '' }), + playlist({ + stalkerSourceUrl: undefined, + portalUrl: undefined, + url: '', + }), playlist({ macAddress: undefined }), playlist({ _id: '' }), ])('rejects an incomplete playlist mapping', (invalidPlaylist) => { diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.ts b/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.ts index 50134f425..29569f800 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session-descriptor.ts @@ -136,11 +136,16 @@ function copyPresentStringValues(source: T): T | undefined { function mapSavedCredentials( playlist: Playlist ): StalkerSavedCredentials | undefined { - if (playlist.username === undefined || playlist.username.length === 0) { + if ( + playlist.username === undefined || + playlist.username.length === 0 || + playlist.password === undefined || + playlist.password.length === 0 + ) { return undefined; } return { - password: playlist.password ?? '', + password: playlist.password, username: playlist.username, }; } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index 29bf677a2..3cfb93c62 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -59,6 +59,24 @@ const PROVISIONAL_READY: StalkerSessionConnectionOutcome = { requestId: 'request-provisional-1', }; +const PROVISIONAL_STATELESS_READY: StalkerSessionConnectionOutcome = { + attemptRef: 'opaque-attempt-stateless-1', + capabilities: { + authenticatedSession: false, + playbackContext: false, + }, + connectionMode: 'provisional', + kind: 'ready', + persistenceDraft: { + isFullStalkerPortal: false, + stalkerRecipeClassifierVersion: 1, + stalkerRequestRecipe: 'stateless-mac', + }, + provisionalReason: 'migration', + recipe: 'stateless-mac', + requestId: 'request-provisional-stateless-1', +}; + const RECONNECTED_READY: StalkerSessionConnectionOutcome = { ...READY, leaseRef: 'opaque-lease-2', @@ -321,6 +339,30 @@ describe('StalkerSessionService', () => { } ); + it('forgets the previous full-session lease after committing a stateless redetection', async () => { + bridge.stalkerSessionOpen.mockResolvedValue(READY); + bridge.stalkerSessionControl + .mockResolvedValueOnce(PROVISIONAL_STATELESS_READY) + .mockResolvedValueOnce({ + action: 'commit', + kind: 'success', + requestId: 'request-commit-stateless-1', + }); + + await service.open(PLAYLIST); + await expect(service.forceRedetect(READY.leaseRef)).resolves.toBe( + PROVISIONAL_STATELESS_READY + ); + await expect( + service.commit(PROVISIONAL_STATELESS_READY.attemptRef) + ).resolves.toMatchObject({ + action: 'commit', + kind: 'success', + }); + + expect(service.getLeaseRef(PLAYLIST._id)).toBeUndefined(); + }); + it('adapts a current raw application call to a typed lease request and maps the result back to the legacy response shape', async () => { bridge.stalkerSessionOpen.mockResolvedValue(READY); bridge.stalkerSessionRequest.mockResolvedValue({ @@ -429,8 +471,7 @@ describe('StalkerSessionService', () => { expect(bridge.stalkerSessionRequest).toHaveBeenCalledTimes(2); expect(bridge.stalkerSessionRequest.mock.calls[1][0]).toMatchObject({ leaseRef: 'opaque-lease-2', - operation: - STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories, + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories, }); }); @@ -510,6 +551,7 @@ describe('StalkerSessionService', () => { it('lets the route continue a first-open credential challenge before the application request', async () => { const challenge: StalkerSessionConnectionOutcome = { attemptNumber: 1, + attemptRef: 'opaque-attempt-route-1', challengeRef: 'opaque-challenge-route-1', kind: 'credentials-required', requestId: 'request-open-challenge', @@ -519,8 +561,7 @@ describe('StalkerSessionService', () => { bridge.stalkerSessionContinue.mockResolvedValue(READY); bridge.stalkerSessionRequest.mockResolvedValue({ kind: 'success', - operation: - STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories, + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories, payload: { items: [] }, requestId: 'request-after-route-recovery', }); @@ -562,6 +603,7 @@ describe('StalkerSessionService', () => { const failure: StalkerSessionConnectionOutcome = { attemptNumber: 1, + attemptRef: 'opaque-attempt-active-handler', challengeRef: 'opaque-challenge-active-handler', kind: 'credentials-required', requestId: 'request-active-handler', diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index 15e8f01eb..fb76c7bb5 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -96,7 +96,7 @@ export class StalkerSessionService { private readonly provisionalByAttemptRef = new Map< StalkerSessionAttemptRef, { - readonly leaseRef: StalkerSessionLeaseRef; + readonly leaseRef?: StalkerSessionLeaseRef; readonly playlistRef: string; } >(); @@ -249,7 +249,19 @@ export class StalkerSessionService { request.action === 'commit' && provisional !== undefined ) { - this.rememberLease(provisional.playlistRef, provisional.leaseRef); + if (provisional.leaseRef === undefined) { + const previousLease = this.leaseByPlaylistRef.get( + provisional.playlistRef + ); + if (previousLease !== undefined) { + this.forgetLease(previousLease); + } + } else { + this.rememberLease( + provisional.playlistRef, + provisional.leaseRef + ); + } this.forgetProvisionalAttempt(request.attemptRef); } else if (outcome.kind === 'success' && request.action === 'discard') { this.forgetProvisionalAttempt(request.attemptRef); @@ -414,9 +426,7 @@ export class StalkerSessionService { return recovery; } - private isRecoverable( - outcome: StalkerSessionNonSuccessOutcome - ): boolean { + private isRecoverable(outcome: StalkerSessionNonSuccessOutcome): boolean { return ( outcome.kind === 'origin-approval-required' || outcome.kind === 'credentials-required' || @@ -432,25 +442,28 @@ export class StalkerSessionService { if (outcome.kind !== 'ready') { return; } - if (outcome.recipe !== 'full-session') { - if (outcome.connectionMode === 'persisted-open') { - const previousLease = this.leaseByPlaylistRef.get(playlistRef); - if (previousLease !== undefined) { - this.forgetLease(previousLease); - } + + if (outcome.connectionMode === 'provisional') { + this.provisionalByAttemptRef.set(outcome.attemptRef, { + ...(outcome.recipe === 'full-session' + ? { leaseRef: outcome.leaseRef } + : {}), + playlistRef, + }); + if (outcome.recipe === 'full-session') { + this.attemptRefByProvisionalLease.set( + outcome.leaseRef, + outcome.attemptRef + ); } return; } - if (outcome.connectionMode === 'provisional') { - this.provisionalByAttemptRef.set(outcome.attemptRef, { - leaseRef: outcome.leaseRef, - playlistRef, - }); - this.attemptRefByProvisionalLease.set( - outcome.leaseRef, - outcome.attemptRef - ); + if (outcome.recipe !== 'full-session') { + const previousLease = this.leaseByPlaylistRef.get(playlistRef); + if (previousLease !== undefined) { + this.forgetLease(previousLease); + } return; } @@ -488,7 +501,7 @@ export class StalkerSessionService { attemptRef: StalkerSessionAttemptRef ): void { const provisional = this.provisionalByAttemptRef.get(attemptRef); - if (provisional !== undefined) { + if (provisional?.leaseRef !== undefined) { this.attemptRefByProvisionalLease.delete(provisional.leaseRef); } this.provisionalByAttemptRef.delete(attemptRef);