diff --git a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts index aebbea760..19598ba76 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts @@ -318,6 +318,25 @@ describe('StalkerAuthApi', () => { expect(sendIpcEvent).not.toHaveBeenCalled(); }); + it('rechecks the abort after the async prehash, not just before the call', async () => { + // getProfile awaits generatePrehash(); an abort landing during that + // await would otherwise still let the adopting request go out. + const abandon = new AbortController(); + const digest = globalThis.crypto.subtle.digest as jest.Mock; + digest.mockImplementation(async () => { + abandon.abort(); + return new Uint8Array(20).fill(1).buffer; + }); + + await expect( + api.getProfile(portalUrl, macAddress, 'TOKEN-1', {}, 'r1', { + signal: abandon.signal, + }) + ).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' }); + + expect(sendIpcEvent).not.toHaveBeenCalled(); + }); + it('stops the status-2 login flow when abandoned mid-way', async () => { const abandon = new AbortController(); sendIpcEvent diff --git a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts index e263bcb72..15590d0e8 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts @@ -190,7 +190,8 @@ export class StalkerAuthApi { portalUrl: string, macAddress: string, identity: StalkerPortalIdentity = {}, - storedToken?: string + storedToken?: string, + signal?: AbortSignal ): Promise { const normalizedIdentity = normalizeStalkerPortalIdentity(identity); const prehash = await generatePrehash(macAddress); @@ -206,6 +207,10 @@ export class StalkerAuthApi { }; try { + // Re-checked here rather than only at the call site: the prehash + // above is async, so an abort can land while it is being + // computed and the request would still go out. + assertNotAborted(signal); const response = await this.dataService.sendIpcEvent( STALKER_REQUEST, @@ -248,7 +253,11 @@ export class StalkerAuthApi { token: string, identity: StalkerPortalIdentity, handshakeRandom: string, - options: { authSecondStep?: boolean; notValidToken?: boolean } = {} + options: { + authSecondStep?: boolean; + notValidToken?: boolean; + signal?: AbortSignal; + } = {} ): Promise { const normalizedIdentity = normalizeStalkerPortalIdentity(identity); @@ -296,6 +305,10 @@ export class StalkerAuthApi { }; try { + // The last possible moment before the call that adopts the MAC's + // token portal-side — the prehash above is async, so the caller's + // pre-check can be stale by now. + assertNotAborted(options.signal); const response = await this.dataService.sendIpcEvent( STALKER_REQUEST, @@ -327,7 +340,8 @@ export class StalkerAuthApi { macAddress: string, token: string, credentials: StalkerPortalCredentials, - identity: StalkerPortalIdentity = {} + identity: StalkerPortalIdentity = {}, + signal?: AbortSignal ): Promise { const normalizedIdentity = normalizeStalkerPortalIdentity(identity); const params: Record = { @@ -345,6 +359,7 @@ export class StalkerAuthApi { }; try { + assertNotAborted(signal); const response = await this.dataService.sendIpcEvent( STALKER_REQUEST, @@ -387,7 +402,8 @@ export class StalkerAuthApi { portalUrl, macAddress, normalizedIdentity, - options.storedToken + options.storedToken, + options.signal ); // An unchanged stored token is already an adopted session — the @@ -414,7 +430,11 @@ export class StalkerAuthApi { handshake.token, normalizedIdentity, handshake.random, - { authSecondStep: false, notValidToken: handshake.notValid } + { + authSecondStep: false, + notValidToken: handshake.notValid, + signal: options.signal, + } ); // The envelope the login flow actually settled on: after a status-2 @@ -472,7 +492,8 @@ export class StalkerAuthApi { macAddress, handshake.token, { username, password }, - identity + identity, + options.signal ); if (!accepted) { throw new StalkerPortalError('login-rejected'); @@ -485,7 +506,11 @@ export class StalkerAuthApi { handshake.token, identity, handshake.random, - { authSecondStep: true, notValidToken: handshake.notValid } + { + authSecondStep: true, + notValidToken: handshake.notValid, + signal: options.signal, + } ); settled = retried; js = retried?.js; diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts index fd99719f3..8d49b9555 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts @@ -282,6 +282,26 @@ describe('StalkerPortalRepairService', () => { expect(writtenRow).toBeNull(); }); + it("forwards the playlist's stored credentials to discovery", async () => { + // A login/password portal answers status 2 during confirmation; + // without the credentials the probe reports `login-required` and + // the source could never be repaired. + discover.mockResolvedValue({ status: 'unreachable' }); + + await service.repairPortal({ + ...MISCLASSIFIED, + username: 'user', + password: 'secret', + }); + + expect(discover).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(Object), + { credentials: { username: 'user', password: 'secret' } } + ); + }); + it('probes at most once per playlist per session', async () => { discover.mockResolvedValue({ status: 'unreachable' }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts index f72bff60c..8634c90f6 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts @@ -336,7 +336,18 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { const outcome = await this.discovery.discover( playlist.portalUrl ?? '', playlist.macAddress ?? '', - getStalkerPortalIdentityFromPlaylist(playlist) + getStalkerPortalIdentityFromPlaylist(playlist), + { + // A login/password portal answers `get_profile` with status 2 + // during confirmation. Without the stored credentials the + // probe reports `login-required` and such a source could + // never be repaired, even though the playlist holds a + // working login. + credentials: { + username: playlist.username, + password: playlist.password, + }, + } ); if (outcome.status !== 'resolved') {