diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.spec.ts index 77b798d71..248a8e548 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.spec.ts @@ -51,6 +51,25 @@ function success( }; } +function rawSuccess( + body: string, + options: { + contentType?: string; + finalUrl?: string; + status?: number; + } = {} +): StalkerHttpRequestOutcome { + return { + kind: STALKER_HTTP_OUTCOME_KINDS.Success, + result: { + body: new TextEncoder().encode(body), + contentType: options.contentType ?? 'application/json', + finalUrl: options.finalUrl ?? 'https://portal.test/c/', + status: options.status ?? 200, + }, + }; +} + function createHarness( responder: ( request: StalkerHttpRequest, @@ -176,6 +195,146 @@ describe('StalkerEndpointResolver', () => { ).toBe('Bearer token-one'); }); + it('stops at the first profile with an unknown status', async () => { + const harness = createHarness((request) => { + const action = request.params?.['action']; + if (request.mode === STALKER_HTTP_REQUEST_MODES.Anonymous) { + return success({}, { finalUrl: request.url }); + } + if (action === 'handshake') { + return success( + { js: { token: 'candidate-token' } }, + { finalUrl: request.url } + ); + } + if (action === 'get_profile') { + return success( + { js: { id: 'profile-one', status: 3 } }, + { finalUrl: request.url } + ); + } + throw new Error(`Unexpected action: ${String(action)}`); + }); + + const outcome = await harness.resolver.resolve({ + descriptor: descriptor(), + transport, + }); + + expect(outcome).toEqual({ + kind: 'failure', + reason: 'incompatible-response', + retryable: false, + stage: 'resolving', + }); + expect( + harness.calls.filter( + (request) => request.params?.['action'] === 'handshake' + ) + ).toHaveLength(1); + expect( + harness.calls.filter( + (request) => request.params?.['action'] === 'get_profile' + ) + ).toHaveLength(1); + expect( + harness.calls.some( + (request) => request.params?.['action'] === 'get_genres' + ) + ).toBe(false); + }); + + it.each([ + ['a handshake token near miss', () => success({ js: { token: 1 } })], + [ + 'malformed JSONP', + () => + rawSuccess('callback({"js":{"token":"candidate-token"}}', { + contentType: 'application/javascript', + }), + ], + [ + 'JSON with the wrong content type', + () => + rawSuccess('{"js":{"token":"candidate-token"}}', { + contentType: 'text/html', + }), + ], + ])( + 'treats %s as terminal and never advances candidates', + async (_, reply) => { + const harness = createHarness((request) => { + if (request.mode === STALKER_HTTP_REQUEST_MODES.Anonymous) { + return success({}, { finalUrl: request.url }); + } + return reply(); + }); + + const outcome = await harness.resolver.resolve({ + descriptor: descriptor(), + transport, + }); + + expect(outcome).toEqual({ + kind: 'failure', + reason: 'incompatible-response', + retryable: false, + stage: 'resolving', + }); + expect( + harness.calls.filter( + (request) => request.params?.['action'] === 'handshake' + ) + ).toHaveLength(1); + expect( + harness.calls.some( + (request) => request.params?.['action'] === 'get_genres' + ) + ).toBe(false); + } + ); + + it('rejects a stateless catalog near miss without advancing candidates', async () => { + const harness = createHarness((request) => { + const action = request.params?.['action']; + if (request.mode === STALKER_HTTP_REQUEST_MODES.Anonymous) { + return success({}, { finalUrl: request.url }); + } + if (action === 'handshake') { + return success({}, { finalUrl: request.url, status: 404 }); + } + if (action === 'get_genres') { + return success( + { js: { id: 'not-a-catalog' } }, + { finalUrl: request.url } + ); + } + throw new Error(`Unexpected action: ${String(action)}`); + }); + + const outcome = await harness.resolver.resolve({ + descriptor: descriptor(), + transport, + }); + + expect(outcome).toEqual({ + kind: 'failure', + reason: 'incompatible-response', + retryable: false, + stage: 'resolving', + }); + expect( + harness.calls.filter( + (request) => request.params?.['action'] === 'handshake' + ) + ).toHaveLength(1); + expect( + harness.calls.filter( + (request) => request.params?.['action'] === 'get_genres' + ) + ).toHaveLength(1); + }); + it('continues after early stateless evidence so a later full endpoint wins', async () => { const harness = createHarness((request) => { const action = request.params?.['action']; @@ -279,7 +438,7 @@ describe('StalkerEndpointResolver', () => { await jar.collectResponseCookies(request.url, [ 'poison=first; Path=/', ]); - return success({}, { finalUrl: request.url }); + return success({}, { finalUrl: request.url, status: 404 }); } if (request.url.endsWith('/portal.php') && action === 'handshake') { secondCandidateCookie = @@ -307,48 +466,54 @@ describe('StalkerEndpointResolver', () => { expect(secondCandidateCookie).not.toContain('poison=first'); }); - it('tries a learned endpoint once, then rediscovers from the landing shape', async () => { - const handshakeUrls: string[] = []; - const harness = createHarness((request) => { - const action = request.params?.['action']; - if (request.mode === STALKER_HTTP_REQUEST_MODES.Anonymous) { - return success({}, { finalUrl: request.url }); - } - if (action === 'handshake') { - handshakeUrls.push(request.url); - } - if (request.url.includes('/learned/') && action === 'handshake') { + it.each([404, 405, 501])( + 'keeps explicit unsupported HTTP %i eligible for candidate rediscovery', + async (unsupportedStatus) => { + const handshakeUrls: string[] = []; + const harness = createHarness((request) => { + const action = request.params?.['action']; + if (request.mode === STALKER_HTTP_REQUEST_MODES.Anonymous) { + return success({}, { finalUrl: request.url }); + } + if (action === 'handshake') { + handshakeUrls.push(request.url); + } + if (request.url.includes('/learned/')) { + return success( + {}, + { + finalUrl: request.url, + status: unsupportedStatus, + } + ); + } + if (action === 'handshake') { + return success( + { js: { token: 'rediscovered-token' } }, + { finalUrl: request.url } + ); + } return success( - { unexpected: true }, - { - contentType: 'text/html', - finalUrl: request.url, - } - ); - } - if (action === 'handshake') { - return success( - { js: { token: 'rediscovered-token' } }, + { js: { status: 0 } }, { finalUrl: request.url } ); - } - return success({ js: { status: 0 } }, { finalUrl: request.url }); - }); + }); - const outcome = await harness.resolver.resolve({ - descriptor: descriptor({ - learnedEndpointHint: - 'https://portal.test/learned/server/load.php', - }), - transport, - }); + const outcome = await harness.resolver.resolve({ + descriptor: descriptor({ + learnedEndpointHint: + 'https://portal.test/learned/server/load.php', + }), + transport, + }); - expect(outcome.kind).toBe('full-session'); - expect(handshakeUrls).toEqual([ - 'https://portal.test/learned/server/load.php', - 'https://portal.test/server/load.php', - ]); - }); + expect(outcome.kind).toBe('full-session'); + expect(handshakeUrls).toEqual([ + 'https://portal.test/learned/server/load.php', + 'https://portal.test/server/load.php', + ]); + } + ); it('ignores an unapproved cross-origin learned endpoint before sending identity', async () => { const harness = createHarness((request) => { diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.ts index 16fa84f97..0b43ab8c1 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.ts @@ -90,9 +90,15 @@ export type StalkerEndpointResolverOutcome = | StalkerEndpointFailureOutcome; interface ParsedTransportEnvelope { + kind: 'parsed'; rawBody: string; - result: StalkerTransportResult; - value?: unknown; + value: unknown; +} + +interface UnparsedTransportEnvelope { + kind: 'unparsed'; + rawBody?: string; + reason: StalkerSessionFailureReason; } interface CandidateProbeUnsupported { @@ -259,7 +265,7 @@ export class StalkerEndpointResolver { const handshakeValue = asRecord(handshakeEnvelope?.['js']); const token = boundedNonEmptyString(handshakeValue?.['token']); if (token === undefined) { - return { kind: 'miss' }; + return failure(STALKER_FAILURE_REASONS.IncompatibleResponse, false); } const handshakeRandom = boundedOptionalString( handshakeValue?.['random'] @@ -294,7 +300,7 @@ export class StalkerEndpointResolver { normalizedProfile.value ); if (classifiedProfile.kind === 'failure') { - return { kind: 'miss' }; + return failure(classifiedProfile.reason, false); } return { @@ -343,7 +349,7 @@ export class StalkerEndpointResolver { kind: 'stateless-mac', landingUrl: endpoint, } - : { kind: 'miss' }; + : failure(STALKER_FAILURE_REASONS.IncompatibleResponse, false); } } @@ -439,12 +445,15 @@ function normalizeProtocolOutcome( sourceOrigin: outcome.sourceOrigin, }; } + if (EXPLICITLY_UNSUPPORTED_STATUSES.has(outcome.result.status)) { + return { kind: 'unsupported' }; + } const parsed = parseTransportEnvelope(outcome.result); const classifiedFailure = classifyStalkerResponseFailure({ httpStatus: outcome.result.status, - rawBody: parsed?.rawBody, - value: parsed?.value, + rawBody: parsed.rawBody, + value: parsed.kind === 'parsed' ? parsed.value : undefined, }); if (classifiedFailure.kind === 'failure') { return failure( @@ -457,11 +466,14 @@ function normalizeProtocolOutcome( if (classifiedFailure.kind === 'token-rejected') { return failure(STALKER_FAILURE_REASONS.IncompatibleResponse, false); } - if (EXPLICITLY_UNSUPPORTED_STATUSES.has(outcome.result.status)) { - return { kind: 'unsupported' }; + if (outcome.result.status !== 200) { + return failure(STALKER_FAILURE_REASONS.IncompatibleResponse, false); } - if (outcome.result.status !== 200 || parsed === null) { - return { kind: 'miss' }; + if (parsed.kind === 'unparsed') { + return failure( + parsed.reason, + parsed.reason !== STALKER_FAILURE_REASONS.IncompatibleResponse + ); } return { kind: 'parsed', value: parsed.value }; } @@ -472,8 +484,8 @@ function inspectResponseFailure( const parsed = parseTransportEnvelope(result); const classified = classifyStalkerResponseFailure({ httpStatus: result.status, - rawBody: parsed?.rawBody, - value: parsed?.value, + rawBody: parsed.rawBody, + value: parsed.kind === 'parsed' ? parsed.value : undefined, }); if (classified.kind === 'failure') { return failure( @@ -490,12 +502,15 @@ function inspectResponseFailure( function parseTransportEnvelope( result: StalkerTransportResult -): ParsedTransportEnvelope | null { +): ParsedTransportEnvelope | UnparsedTransportEnvelope { let rawBody: string; try { rawBody = new TextDecoder('utf-8', { fatal: true }).decode(result.body); } catch { - return null; + return { + kind: 'unparsed', + reason: STALKER_FAILURE_REASONS.IncompatibleResponse, + }; } const parsed = parseStalkerResponseEnvelope({ body: rawBody, @@ -503,8 +518,8 @@ function parseTransportEnvelope( maxBodyBytes: result.body.byteLength, }); return parsed.kind === 'parsed' - ? { rawBody, result, value: parsed.value } - : { rawBody, result }; + ? { kind: 'parsed', rawBody, value: parsed.value } + : { kind: 'unparsed', rawBody, reason: parsed.reason }; } function boundedNonEmptyString(value: unknown): string | undefined { diff --git a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts index 95c3ebd0d..5f4268c17 100644 --- a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts +++ b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts @@ -14,8 +14,47 @@ describe('Stalker response classifier', () => { [2, 'credentials-required'], ['2', 'credentials-required'], ])('normalizes profile status %p', (status, expectedKind) => { - expect(classifyStalkerProfile({ js: { id: 7, status } })).toMatchObject({ - kind: expectedKind, + expect(classifyStalkerProfile({ js: { id: 7, status } })).toMatchObject( + { + kind: expectedKind, + } + ); + }); + + it.each([ + ['profile block flag', { js: { blocked: true, status: 1 } }, 'blocked'], + [ + 'envelope block flag', + { account_blocked: '1', js: { status: '1' } }, + 'blocked', + ], + [ + 'profile credential flag', + { js: { credentials_required: true, status: 2 } }, + 'credentials-required', + ], + [ + 'envelope credential flag', + { auth_required: '1', js: { status: '2' } }, + 'credentials-required', + ], + ])( + 'accepts a status confirmed by a matching %s', + (_, value, expectedKind) => { + expect(classifyStalkerProfile(value)).toMatchObject({ + kind: expectedKind, + }); + } + ); + + it.each([ + { js: { credentials_required: true, status: 1 } }, + { js: { blocked: true, status: 2 } }, + { js: { blocked: true, status: 0 } }, + ])('rejects a profile status with a conflicting indicator', (value) => { + expect(classifyStalkerProfile(value)).toEqual({ + kind: 'failure', + reason: 'incompatible-response', }); }); @@ -32,15 +71,12 @@ describe('Stalker response classifier', () => { { js: { blocked: true, id: 7 } }, { js: { credentials_required: true, id: 7 } }, { error: 'Authorization failed', js: { id: 7 } }, - ])( - 'rejects status-less profiles carrying failure indicators', - (value) => { - expect(classifyStalkerProfile(value)).toEqual({ - kind: 'failure', - reason: 'incompatible-response', - }); - } - ); + ])('rejects status-less profiles carrying failure indicators', (value) => { + expect(classifyStalkerProfile(value)).toEqual({ + kind: 'failure', + reason: 'incompatible-response', + }); + }); it.each([ { error: { message: 'Access denied' }, js: { id: 7 } }, @@ -186,10 +222,7 @@ describe('Stalker response classifier', () => { it.each([ ['Authorization failed', { kind: 'token-rejected' }], - [ - 'Access denied', - { kind: 'failure', reason: 'account-access-denied' }, - ], + ['Access denied', { kind: 'failure', reason: 'account-access-denied' }], ])('classifies the canonical error %s on HTTP 403', (error, expected) => { expect( classifyStalkerResponseFailure({ @@ -211,10 +244,7 @@ describe('Stalker response classifier', () => { it.each([ ['Authorization failed', { kind: 'token-rejected' }], - [ - 'Access denied', - { kind: 'failure', reason: 'account-access-denied' }, - ], + ['Access denied', { kind: 'failure', reason: 'account-access-denied' }], ])('recognizes the exact plain error body %s', (rawBody, expected) => { expect( classifyStalkerResponseFailure({ diff --git a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts index 3ff466895..569141b99 100644 --- a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts +++ b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts @@ -42,6 +42,22 @@ const STALKER_ERROR_FIELDS = [ 'error_message', 'error_msg', ] as const; +const STALKER_BLOCK_FIELDS = [ + 'blocked', + 'is_blocked', + 'account_blocked', + 'disabled', + 'banned', +] as const; +const STALKER_CREDENTIAL_FIELDS = [ + 'credentials_required', + 'credential_required', + 'login_required', + 'auth_required', + 'authentication_required', + 'need_auth', + 'requires_auth', +] as const; export function parseStalkerResponseEnvelope( input: StalkerResponseEnvelopeInput @@ -87,12 +103,25 @@ export function classifyStalkerProfile( return incompatibleResponse(); } const profile = asRecord(envelope['js']); - if (!profile || hasProfileFailureIndicator(envelope, profile)) { + if (!profile || hasAnyIndicator(envelope, profile, STALKER_ERROR_FIELDS)) { return incompatibleResponse(); } const status = normalizeProfileStatus(profile['status']); + const hasBlockIndicator = hasAnyIndicator( + envelope, + profile, + STALKER_BLOCK_FIELDS + ); + const hasCredentialIndicator = hasAnyIndicator( + envelope, + profile, + STALKER_CREDENTIAL_FIELDS + ); if (status === 0) { + if (hasBlockIndicator || hasCredentialIndicator) { + return incompatibleResponse(); + } return { kind: STALKER_PROFILE_RESULT_KINDS.Ready, profile, @@ -100,6 +129,9 @@ export function classifyStalkerProfile( }; } if (status === 1) { + if (hasCredentialIndicator) { + return incompatibleResponse(); + } return { kind: STALKER_PROFILE_RESULT_KINDS.Blocked, profile, @@ -107,6 +139,9 @@ export function classifyStalkerProfile( }; } if (status === 2) { + if (hasBlockIndicator) { + return incompatibleResponse(); + } return { kind: STALKER_PROFILE_RESULT_KINDS.CredentialsRequired, profile, @@ -115,7 +150,9 @@ export function classifyStalkerProfile( } if ( profile['status'] === undefined && - hasRecognizedStatuslessProfileField(profile) + hasRecognizedStatuslessProfileField(profile) && + !hasBlockIndicator && + !hasCredentialIndicator ) { return { kind: STALKER_PROFILE_RESULT_KINDS.Ready, @@ -145,10 +182,7 @@ export function classifyStalkerDoAuth( export function classifyStalkerResponseFailure( input: StalkerResponseFailureInput -): - | { kind: 'none' } - | { kind: 'token-rejected' } - | StalkerClassifierFailure { +): { kind: 'none' } | { kind: 'token-rejected' } | StalkerClassifierFailure { if (input.httpStatus === 403 && looksLikePortalProtection(input.rawBody)) { return { kind: 'failure', @@ -217,27 +251,12 @@ function hasRecognizedStatuslessProfileField( ].some((field) => profile[field] !== undefined); } -function hasProfileFailureIndicator( +function hasAnyIndicator( envelope: Readonly>, - profile: Readonly> + profile: Readonly>, + fields: readonly string[] ): boolean { - const blockFields = [ - 'blocked', - 'is_blocked', - 'account_blocked', - 'disabled', - 'banned', - ]; - const credentialFields = [ - 'credentials_required', - 'credential_required', - 'login_required', - 'auth_required', - 'authentication_required', - 'need_auth', - 'requires_auth', - ]; - return [...STALKER_ERROR_FIELDS, ...blockFields, ...credentialFields].some( + return fields.some( (field) => isFailureIndicator(envelope[field]) || isFailureIndicator(profile[field]) @@ -262,7 +281,8 @@ function isFailureIndicator(value: unknown): boolean { function normalizeMediaType( contentType: string | undefined ): string | undefined | 'invalid' { - if (contentType === undefined || contentType.trim() === '') return undefined; + if (contentType === undefined || contentType.trim() === '') + return undefined; const [rawType, ...parameters] = contentType.split(';'); if ( !rawType || @@ -310,7 +330,9 @@ function parseAllowlistedJsonp( return match?.[1] === undefined ? { parsed: false } : parseJson(match[1]); } -function asRecord(value: unknown): Readonly> | undefined { +function asRecord( + value: unknown +): Readonly> | undefined { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Readonly>) : undefined;