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 ee2e776b7..bb4540b2a 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 @@ -259,6 +259,28 @@ describe('StalkerSessionService identity-tagged token cache', () => { } ); + it('does not treat a proxy page mentioning the phrase as an auth failure', async () => { + // End to end through makeAuthenticatedRequest, not just the + // primitive: this is the path that used to stringify the whole + // response and match an unanchored `authorization failed`. A short + // page from something in FRONT of the portal would retire the token, + // retry, and throw a message that itself matches the repair trigger — + // re-probing a portal that never refused anything. + service.setCachedToken('portal-1', 'LIVE', playlistA); + const body = 'The request Authorization failed. Try again later.'; + sendIpcEvent.mockResolvedValue(body); + + await expect( + service.makeAuthenticatedRequest(playlistA, { + action: 'get_genres', + }) + ).resolves.toBe(body); + + // No retry, no retirement, nothing for the repair layer to act on. + expect(sendIpcEvent).toHaveBeenCalledTimes(1); + expect(service.getCachedToken('portal-1')).toBe('LIVE'); + }); + it('retires a failed token even on the no-retry path (watchdog pings)', async () => { service.setCachedToken('portal-1', 'DEAD', playlistA); sendIpcEvent.mockResolvedValue('Authorization failed.'); 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 910036dec..746e95a6c 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 @@ -801,30 +801,17 @@ export class StalkerSessionService { return true; } - // Convert response to string for pattern matching - const responseStr = JSON.stringify(responseOrError).toLowerCase(); - - // Check for "Authorization failed. XX" pattern (like "Authorization failed. 75") - if (/authorization\s*failed\.?\s*\d*/i.test(responseStr)) { - return true; - } - - // Check for common auth failure indicators - const jsData = response?.['js'] as Record; - const errorMessage = - (response?.['message'] as string)?.toLowerCase?.() || - jsData?.['error']?.toString?.().toLowerCase?.() || - jsData?.['msg']?.toString?.().toLowerCase?.() || - ''; - - return ( - errorMessage.includes('authorization') || - errorMessage.includes('unauthorized') || - errorMessage.includes('auth failed') || - errorMessage.includes('invalid token') || - response?.['status'] === 401 || - jsData?.['error'] === 'Authorization failed' - ); + // Everything above is structural. What used to follow was a + // `JSON.stringify(responseOrError)` sweep with an UNANCHORED + // `authorization failed` pattern — the same false positive the body + // detector was just anchored against, reachable through a different + // door: a short proxy/WAF page containing the phrase retired the + // token, retried, and threw `Authorization failed after retry`, + // whose own message then matched the repair trigger and re-probed a + // portal that never refused anything. The shared detector already + // covers every real shape, including the `js.error`/`js.msg` + // envelopes, so the sweep only added the false positive. + return response?.['status'] === 401 || response?.['status'] === 403; } /**