mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
fix(stalker): harden session compatibility
This commit is contained in:
1 parent
63ab2837a7
commit
bdf353a3b8
31 files changed
+1067
-82
No files matched your search
+74
-1
@@ -395,7 +395,12 @@ describe('StalkerEndpointResolver', () => {
|
||||
action === 'handshake'
|
||||
) {
|
||||
return rawSuccess(
|
||||
'<!doctype html><html><body>Portal landing</body></html>',
|
||||
[
|
||||
'<!doctype html><html><head>',
|
||||
'<title>stalker_portal</title>',
|
||||
'<script src="../server/api/load_js.php"></script>',
|
||||
'</head><body></body></html>',
|
||||
].join(''),
|
||||
{
|
||||
contentType: 'text/html; charset=utf-8',
|
||||
finalUrl: request.url,
|
||||
@@ -438,6 +443,74 @@ describe('StalkerEndpointResolver', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'an unknown HTML account denial',
|
||||
'<!doctype html><html><body>Account suspended</body></html>',
|
||||
'text/html',
|
||||
],
|
||||
[
|
||||
'an unknown XHTML device denial',
|
||||
'<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><body>Device blocked</body></html>',
|
||||
'application/xhtml+xml',
|
||||
],
|
||||
[
|
||||
'an HTML denial with only a portal title marker',
|
||||
'<!doctype html><html><head><title>stalker_portal</title></head><body>Account suspended</body></html>',
|
||||
'text/html',
|
||||
],
|
||||
])(
|
||||
'treats %s as terminal after earlier stateless evidence',
|
||||
async (_, denialBody, contentType) => {
|
||||
const harness = createHarness((request) => {
|
||||
const action = request.params?.['action'];
|
||||
if (request.mode === STALKER_HTTP_REQUEST_MODES.Anonymous) {
|
||||
return success({}, { finalUrl: request.url });
|
||||
}
|
||||
if (
|
||||
request.url.endsWith('/server/load.php') &&
|
||||
action === 'handshake'
|
||||
) {
|
||||
return success(
|
||||
{},
|
||||
{ finalUrl: request.url, status: 404 }
|
||||
);
|
||||
}
|
||||
if (
|
||||
request.url.endsWith('/server/load.php') &&
|
||||
action === 'get_genres'
|
||||
) {
|
||||
return success(
|
||||
{ js: [{ id: '*', title: 'All' }] },
|
||||
{ finalUrl: request.url }
|
||||
);
|
||||
}
|
||||
if (
|
||||
request.url.endsWith('/portal.php') &&
|
||||
action === 'handshake'
|
||||
) {
|
||||
return rawSuccess(denialBody, {
|
||||
contentType,
|
||||
finalUrl: request.url,
|
||||
});
|
||||
}
|
||||
throw new Error('Unexpected request');
|
||||
});
|
||||
|
||||
const outcome = await harness.resolver.resolve({
|
||||
descriptor: descriptor(),
|
||||
transport,
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'incompatible-response',
|
||||
retryable: false,
|
||||
stage: 'resolving',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('continues after early stateless evidence so a later full endpoint wins', async () => {
|
||||
const harness = createHarness((request) => {
|
||||
const action = request.params?.['action'];
|
||||
|
||||
+11
-7
@@ -563,15 +563,19 @@ function isBenignHtmlEndpointMiss(
|
||||
?.split(';', 1)[0]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
if (mediaType !== 'text/html' && mediaType !== 'application/xhtml+xml') {
|
||||
if (mediaType !== 'text/html' || rawBody === undefined) {
|
||||
return false;
|
||||
}
|
||||
const prefix = rawBody?.trimStart().slice(0, 64).toLowerCase() ?? '';
|
||||
return (
|
||||
prefix.startsWith('<!doctype html') ||
|
||||
prefix.startsWith('<html') ||
|
||||
prefix.startsWith('<?xml')
|
||||
);
|
||||
// The public Stalker /c/ shell combines these bootstrap markers. Requiring
|
||||
// two keeps branded denial pages from becoming a downgrade signal.
|
||||
const normalized = rawBody.toLowerCase();
|
||||
const markerCount = [
|
||||
/<title[^>]*>\s*stalker_portal\s*<\/title>/i.test(rawBody),
|
||||
normalized.includes('server/api/load_js.php'),
|
||||
normalized.includes('window.loadrequiredfiles('),
|
||||
normalized.includes('stb.init('),
|
||||
].filter(Boolean).length;
|
||||
return markerCount >= 2;
|
||||
}
|
||||
|
||||
function isApiEnvelopeRejectedOnlyByMediaType(
|
||||
|
||||
@@ -98,6 +98,38 @@ describe('StalkerHttpSession', () => {
|
||||
expect(config.headers).not.toHaveProperty('Proxy-Authorization');
|
||||
});
|
||||
|
||||
it('blocks an anonymous public-to-private redirect before target contact', async () => {
|
||||
axiosMock
|
||||
.mockResolvedValueOnce({
|
||||
data: Buffer.alloc(0),
|
||||
headers: {
|
||||
location: 'http://127.0.0.1/private-admin',
|
||||
},
|
||||
status: 302,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: Buffer.from('private response'),
|
||||
headers: {},
|
||||
status: 200,
|
||||
});
|
||||
const session = new StalkerHttpSession(
|
||||
new StalkerCookieJar(),
|
||||
DEFAULT_TRANSPORT
|
||||
);
|
||||
|
||||
const outcome = await session.request({
|
||||
mode: STALKER_HTTP_REQUEST_MODES.Anonymous,
|
||||
url: 'http://93.184.216.34/landing',
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'invalid-url',
|
||||
retryable: false,
|
||||
});
|
||||
expect(axiosMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([200, 401, 404, 429, 500, 503])(
|
||||
'returns HTTP %i as a successful transport result',
|
||||
async (status) => {
|
||||
|
||||
@@ -144,6 +144,7 @@ export class StalkerHttpSession {
|
||||
validateStatus: () => true,
|
||||
},
|
||||
{
|
||||
allowPrivateNetworkRedirects: identityBearing,
|
||||
allowPrivateNetworks: true,
|
||||
pinAllowedPrivateNetworkHosts: true,
|
||||
}
|
||||
|
||||
+212
@@ -16,6 +16,63 @@ function mapRemote<Operation extends StalkerSessionApplicationOperation>(
|
||||
return mapped;
|
||||
}
|
||||
|
||||
const listResultMappers = [
|
||||
{
|
||||
name: 'categories',
|
||||
emptyResult: { items: [] },
|
||||
mapResult: (value: unknown) =>
|
||||
mapRemote(
|
||||
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
|
||||
{ contentType: 'vod' }
|
||||
).mapResult(value),
|
||||
},
|
||||
{
|
||||
name: 'catalog',
|
||||
emptyResult: { items: [] },
|
||||
mapResult: (value: unknown) =>
|
||||
mapRemote(STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems, {
|
||||
contentType: 'vod',
|
||||
}).mapResult(value),
|
||||
},
|
||||
{
|
||||
name: 'seasons',
|
||||
emptyResult: { seasons: [] },
|
||||
mapResult: (value: unknown) =>
|
||||
mapRemote(STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons, {
|
||||
seriesId: '42',
|
||||
}).mapResult(value),
|
||||
},
|
||||
{
|
||||
name: 'episodes',
|
||||
emptyResult: { episodes: [] },
|
||||
mapResult: (value: unknown) =>
|
||||
mapRemote(STALKER_SESSION_APPLICATION_OPERATIONS.SeriesEpisodes, {
|
||||
seasonId: '42:1',
|
||||
seriesId: '42',
|
||||
}).mapResult(value),
|
||||
},
|
||||
{
|
||||
name: 'short EPG',
|
||||
emptyResult: { programs: [] },
|
||||
mapResult: (value: unknown) =>
|
||||
mapRemote(STALKER_SESSION_APPLICATION_OPERATIONS.ShortEpg, {
|
||||
channelId: '11',
|
||||
}).mapResult(value),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const malformedListEnvelopes = [
|
||||
{ name: 'object-without-data', value: { js: {} } },
|
||||
{ name: 'direct-null', value: { js: null } },
|
||||
{ name: 'direct-scalar', value: { js: 7 } },
|
||||
{ name: 'direct-string', value: { js: 'not-a-list' } },
|
||||
{ name: 'undefined', value: { js: { data: undefined } } },
|
||||
{ name: 'null', value: { js: { data: null } } },
|
||||
{ name: 'scalar', value: { js: { data: 7 } } },
|
||||
{ name: 'object', value: { js: { data: {} } } },
|
||||
{ name: 'string', value: { js: { data: 'not-a-list' } } },
|
||||
] as const;
|
||||
|
||||
describe('stalker-operation-adapter', () => {
|
||||
it.each([
|
||||
{
|
||||
@@ -302,6 +359,161 @@ describe('stalker-operation-adapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(listResultMappers)(
|
||||
'keeps real empty arrays valid for $name direct and data-envelope shapes',
|
||||
({ emptyResult, mapResult }) => {
|
||||
expect(mapResult({ js: [] })).toEqual(emptyResult);
|
||||
expect(mapResult({ js: { data: [] } })).toEqual(emptyResult);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(
|
||||
listResultMappers.flatMap((operation) =>
|
||||
malformedListEnvelopes.map((envelope) => ({
|
||||
envelope: envelope.name,
|
||||
mapResult: operation.mapResult,
|
||||
operation: operation.name,
|
||||
value: envelope.value,
|
||||
}))
|
||||
)
|
||||
)(
|
||||
'rejects $envelope data for $operation',
|
||||
({ mapResult, value }) => {
|
||||
expect(() => mapResult(value)).toThrow(
|
||||
'stalker-operation-invalid-response'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('accepts the real data-wrapped channel map and empty bulk EPG shapes', () => {
|
||||
const mapped = mapRemote(
|
||||
STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo,
|
||||
{}
|
||||
);
|
||||
const program = {
|
||||
ch_id: '11',
|
||||
name: 'News',
|
||||
start_timestamp: 100,
|
||||
stop_timestamp: 200,
|
||||
};
|
||||
|
||||
expect(
|
||||
mapped.mapResult({
|
||||
js: {
|
||||
data: {
|
||||
'11': [program],
|
||||
'12': [],
|
||||
},
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
programs: [
|
||||
{
|
||||
channelId: '11',
|
||||
endsAt: 200,
|
||||
startsAt: 100,
|
||||
title: 'News',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mapped.mapResult({ js: { data: {} } })).toEqual({
|
||||
programs: [],
|
||||
});
|
||||
expect(mapped.mapResult({ js: [] })).toEqual({ programs: [] });
|
||||
expect(mapped.mapResult({ js: { data: [] } })).toEqual({
|
||||
programs: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves direct and nested channel-keyed bulk EPG variants', () => {
|
||||
const mapped = mapRemote(
|
||||
STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo,
|
||||
{}
|
||||
);
|
||||
const program = {
|
||||
ch_id: '11',
|
||||
name: 'News',
|
||||
start_timestamp: 100,
|
||||
stop_timestamp: 200,
|
||||
};
|
||||
|
||||
expect(
|
||||
mapped.mapResult({
|
||||
js: {
|
||||
'11': [program],
|
||||
'12': { data: [] },
|
||||
'13': { epg: [] },
|
||||
'14': { items: [] },
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
programs: [
|
||||
{
|
||||
channelId: '11',
|
||||
endsAt: 200,
|
||||
startsAt: 100,
|
||||
title: 'News',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(
|
||||
mapped.mapResult({
|
||||
js: {
|
||||
data: {
|
||||
'11': { data: [program] },
|
||||
'12': { epg: [] },
|
||||
'13': { items: [] },
|
||||
},
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
programs: [
|
||||
{
|
||||
channelId: '11',
|
||||
endsAt: 200,
|
||||
startsAt: 100,
|
||||
title: 'News',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'object-without-data', value: { js: {} } },
|
||||
{ name: 'direct-null', value: { js: null } },
|
||||
{ name: 'direct-scalar', value: { js: 7 } },
|
||||
{ name: 'direct-string', value: { js: 'not-an-epg-map' } },
|
||||
{ name: 'undefined-data', value: { js: { data: undefined } } },
|
||||
{ name: 'null-data', value: { js: { data: null } } },
|
||||
{ name: 'scalar-data', value: { js: { data: 7 } } },
|
||||
{ name: 'string-data', value: { js: { data: 'not-an-epg-map' } } },
|
||||
{
|
||||
name: 'scalar-channel-entry',
|
||||
value: { js: { data: { '11': 7 } } },
|
||||
},
|
||||
{
|
||||
name: 'unknown-channel-entry',
|
||||
value: { js: { data: { '11': { unknown: [] } } } },
|
||||
},
|
||||
{
|
||||
name: 'direct-scalar-channel-entry',
|
||||
value: { js: { '11': 7 } },
|
||||
},
|
||||
{
|
||||
name: 'direct-unknown-channel-entry',
|
||||
value: { js: { '11': { unknown: [] } } },
|
||||
},
|
||||
] as const)('rejects malformed bulk EPG $name', ({ value }) => {
|
||||
const mapped = mapRemote(
|
||||
STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(() => mapped.mapResult(value)).toThrow(
|
||||
'stalker-operation-invalid-response'
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes categories, seasons, episodes, EPG, links, and account summaries', () => {
|
||||
const categories = mapRemote(
|
||||
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
|
||||
|
||||
+86
-27
@@ -212,7 +212,9 @@ function mapShortEpg<Operation extends StalkerSessionApplicationOperation>(
|
||||
type: 'itv',
|
||||
};
|
||||
copyPositiveInteger(record, 'limit', wire, 'size');
|
||||
return remote(wire, (value) => mapEpg(value, channelId));
|
||||
return remote(wire, (value) =>
|
||||
mapEpg(value, { fallbackChannelId: channelId })
|
||||
);
|
||||
}
|
||||
|
||||
function mapEpgInfo<Operation extends StalkerSessionApplicationOperation>(
|
||||
@@ -227,7 +229,9 @@ function mapEpgInfo<Operation extends StalkerSessionApplicationOperation>(
|
||||
copyNonNegativeInteger(record, 'fromTimestamp', wire, 'from_timestamp');
|
||||
copyPositiveInteger(record, 'periodHours', wire, 'period');
|
||||
copyNonNegativeInteger(record, 'toTimestamp', wire, 'to_timestamp');
|
||||
return remote(wire, (value) => mapEpg(value));
|
||||
return remote(wire, (value) =>
|
||||
mapEpg(value, { allowChannelMap: true })
|
||||
);
|
||||
}
|
||||
|
||||
function mapCreateLink<Operation extends StalkerSessionApplicationOperation>(
|
||||
@@ -257,7 +261,7 @@ function mapCreateLink<Operation extends StalkerSessionApplicationOperation>(
|
||||
}
|
||||
|
||||
function mapCategories(value: unknown): StalkerSessionCategoriesResult {
|
||||
const items = readArray(unwrapJs(value)).flatMap((value) => {
|
||||
const items = readDataArray(value).flatMap((value) => {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return [];
|
||||
@@ -292,7 +296,7 @@ function mapCatalog(
|
||||
): StalkerSessionCatalogResult {
|
||||
const js = unwrapJs(value);
|
||||
const envelope = asRecord(js);
|
||||
const rawItems = envelope ? readArray(envelope['data']) : readArray(js);
|
||||
const rawItems = readListPayload(js);
|
||||
const items = rawItems.flatMap((value) => {
|
||||
const mapped = mapCatalogItem(value, contentType);
|
||||
return mapped ? [mapped] : [];
|
||||
@@ -470,9 +474,18 @@ function mapEpisodeResult(
|
||||
|
||||
function mapEpg(
|
||||
value: unknown,
|
||||
fallbackChannelId?: StalkerSessionEntityId
|
||||
{
|
||||
allowChannelMap = false,
|
||||
fallbackChannelId,
|
||||
}: {
|
||||
readonly allowChannelMap?: boolean;
|
||||
readonly fallbackChannelId?: StalkerSessionEntityId;
|
||||
} = {}
|
||||
): StalkerSessionEpgResult {
|
||||
const programs = flattenEpgEntries(unwrapJs(value)).flatMap((value) => {
|
||||
const programs = flattenEpgEntries(
|
||||
unwrapJs(value),
|
||||
allowChannelMap
|
||||
).flatMap((value) => {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return [];
|
||||
@@ -561,43 +574,93 @@ function unwrapJs(value: unknown): unknown {
|
||||
}
|
||||
|
||||
function readDataArray(value: unknown): readonly unknown[] {
|
||||
const js = unwrapJs(value);
|
||||
const record = asRecord(js);
|
||||
return record ? readArray(record['data']) : readArray(js);
|
||||
return readListPayload(unwrapJs(value));
|
||||
}
|
||||
|
||||
function flattenEpgEntries(value: unknown): readonly unknown[] {
|
||||
function readListPayload(value: unknown): readonly unknown[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
const envelope = asRecord(value);
|
||||
if (
|
||||
!envelope ||
|
||||
!Object.prototype.hasOwnProperty.call(envelope, 'data') ||
|
||||
!Array.isArray(envelope['data'])
|
||||
) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
return envelope['data'];
|
||||
}
|
||||
|
||||
function flattenEpgEntries(
|
||||
value: unknown,
|
||||
allowChannelMap: boolean
|
||||
): readonly unknown[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return [];
|
||||
throw invalidResponse();
|
||||
}
|
||||
const direct = readArray(record['data']);
|
||||
if (direct.length > 0) {
|
||||
return direct;
|
||||
if (Object.prototype.hasOwnProperty.call(record, 'data')) {
|
||||
if (Array.isArray(record['data'])) {
|
||||
return record['data'];
|
||||
}
|
||||
if (!allowChannelMap) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
const channelMap = asRecord(record['data']);
|
||||
if (!channelMap) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
return flattenEpgChannelMap(channelMap, true);
|
||||
}
|
||||
if (!allowChannelMap) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
return flattenEpgChannelMap(record, false);
|
||||
}
|
||||
|
||||
function flattenEpgChannelMap(
|
||||
value: Readonly<Record<string, unknown>>,
|
||||
allowEmpty: boolean
|
||||
): readonly unknown[] {
|
||||
const nestedValues = Object.values(value);
|
||||
if (!allowEmpty && nestedValues.length === 0) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
|
||||
const result: unknown[] = [];
|
||||
for (const nested of Object.values(record)) {
|
||||
for (const nested of nestedValues) {
|
||||
if (Array.isArray(nested)) {
|
||||
result.push(...nested);
|
||||
continue;
|
||||
}
|
||||
const nestedRecord = asRecord(nested);
|
||||
if (nestedRecord) {
|
||||
result.push(
|
||||
...readArray(
|
||||
nestedRecord['data'] ??
|
||||
nestedRecord['epg'] ??
|
||||
nestedRecord['items']
|
||||
)
|
||||
);
|
||||
if (!nestedRecord) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
result.push(...readNestedEpgArray(nestedRecord));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function readNestedEpgArray(
|
||||
value: Readonly<Record<string, unknown>>
|
||||
): readonly unknown[] {
|
||||
for (const key of ['data', 'epg', 'items'] as const) {
|
||||
if (!Object.prototype.hasOwnProperty.call(value, key)) {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(value[key])) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
return value[key];
|
||||
}
|
||||
throw invalidResponse();
|
||||
}
|
||||
|
||||
function normalizePlaybackCommand(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const separator = trimmed.indexOf(' ');
|
||||
@@ -631,10 +694,6 @@ function asRecord(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readArray(value: unknown): readonly unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function readRequiredString(value: unknown): string {
|
||||
const result = optionalString(value);
|
||||
if (result === undefined) {
|
||||
|
||||
+81
@@ -834,6 +834,87 @@ describe('StalkerSessionManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('caps one attempt at three credential submissions across auth replacements caused by alternating principals', async () => {
|
||||
const targetAuths = [
|
||||
new FakeAuth('target-user', false, [
|
||||
{ attemptNumber: 1, kind: 'credentials-required' },
|
||||
]),
|
||||
new FakeAuth('target-user', false, [
|
||||
{ attemptNumber: 2, kind: 'credentials-required' },
|
||||
]),
|
||||
new FakeAuth('target-user', false, [
|
||||
{ attemptNumber: 2, kind: 'credentials-required' },
|
||||
]),
|
||||
new FakeAuth('target-user', false, [
|
||||
{ attemptNumber: 2, kind: 'credentials-required' },
|
||||
]),
|
||||
];
|
||||
const { manager } = harness({
|
||||
auths: [
|
||||
targetAuths[0],
|
||||
new FakeAuth('principal-a'),
|
||||
targetAuths[1],
|
||||
new FakeAuth('principal-b'),
|
||||
targetAuths[2],
|
||||
new FakeAuth('principal-a'),
|
||||
targetAuths[3],
|
||||
],
|
||||
resolverOutcomes: Array.from({ length: 7 }, () => full()),
|
||||
});
|
||||
|
||||
let challenge = await manager.open(1, {
|
||||
descriptor: descriptor('credential-budget-target'),
|
||||
});
|
||||
if (challenge.kind !== 'credentials-required') {
|
||||
throw new Error('expected-credentials-challenge');
|
||||
}
|
||||
|
||||
for (const [index, principal] of [
|
||||
[0, 'principal-a'],
|
||||
[1, 'principal-b'],
|
||||
[2, 'principal-a'],
|
||||
] as const) {
|
||||
const interferer = await manager.open(index + 2, {
|
||||
descriptor: descriptor(
|
||||
`credential-budget-${principal}-${index}`
|
||||
),
|
||||
});
|
||||
expectFullReady(interferer);
|
||||
|
||||
const outcome = await manager.continue(1, {
|
||||
challengeRef: challenge.challengeRef,
|
||||
response: {
|
||||
kind: 'credentials',
|
||||
password: `bad-password-${index}`,
|
||||
username: 'target-user',
|
||||
},
|
||||
});
|
||||
if (index === 2) {
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'failure',
|
||||
reason: STALKER_SESSION_FAILURE_REASONS
|
||||
.CredentialsAttemptLimit,
|
||||
stage: 'do-auth',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
expect(outcome).toMatchObject({
|
||||
attemptNumber: index + 2,
|
||||
kind: 'credentials-required',
|
||||
});
|
||||
if (outcome.kind !== 'credentials-required') {
|
||||
throw new Error('expected-credentials-challenge');
|
||||
}
|
||||
challenge = outcome;
|
||||
}
|
||||
|
||||
expect(
|
||||
targetAuths.flatMap((auth) => auth.startedCredentials).filter(
|
||||
(credentials) => credentials !== undefined
|
||||
)
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('resets the two-minute lifetime when a slow attempt becomes ready or issues a challenge', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(0);
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
} from './stalker-watchdog';
|
||||
|
||||
const ATTEMPT_TTL_MS = 120_000;
|
||||
const MAX_CREDENTIAL_SUBMISSIONS_PER_ATTEMPT = 3;
|
||||
const REFERENCE_BYTES = 32;
|
||||
const MAX_REFERENCE_ATTEMPTS = 4;
|
||||
const RECIPE_CLASSIFIER_VERSION = 1;
|
||||
@@ -214,6 +215,7 @@ interface AttemptRecord {
|
||||
challengeRef?: string;
|
||||
coordinator: StalkerBaseIdentityCoordinator;
|
||||
credentialCandidate?: StalkerAuthCredentials;
|
||||
credentialSubmissions: number;
|
||||
descriptor: StalkerSessionConnectionDescriptor;
|
||||
expiresAt: number;
|
||||
lastEndpoint?: string;
|
||||
@@ -916,6 +918,7 @@ export class StalkerSessionManager {
|
||||
validated.descriptor.sourceUrl,
|
||||
validated.descriptor.macAddress
|
||||
),
|
||||
credentialSubmissions: 0,
|
||||
descriptor: validated.descriptor,
|
||||
expiresAt: this.#now() + ATTEMPT_TTL_MS,
|
||||
previousSessions:
|
||||
@@ -970,7 +973,16 @@ export class StalkerSessionManager {
|
||||
attempt.descriptor
|
||||
);
|
||||
attempt.lastLandingUrl = resolved.landingUrl;
|
||||
const authOutcome = await auth.start(credentials);
|
||||
const authOutcome =
|
||||
credentials === undefined
|
||||
? this.#normalizeCredentialChallenge(
|
||||
attempt,
|
||||
await auth.start()
|
||||
)
|
||||
: await this.#runCredentialSubmission(
|
||||
attempt,
|
||||
() => auth.start(credentials)
|
||||
);
|
||||
if (authOutcome.kind !== 'ready') {
|
||||
throw new ProgressSignal(
|
||||
authProgress(authOutcome, auth, resolved, epoch)
|
||||
@@ -1044,9 +1056,9 @@ export class StalkerSessionManager {
|
||||
attempt.coordinator,
|
||||
epoch
|
||||
);
|
||||
const outcome = await auth.submitCredentials(
|
||||
credentials,
|
||||
false
|
||||
const outcome = await this.#runCredentialSubmission(
|
||||
attempt,
|
||||
() => auth.submitCredentials(credentials, false)
|
||||
);
|
||||
if (outcome.kind !== 'ready') {
|
||||
throw new ProgressSignal(
|
||||
@@ -1096,6 +1108,42 @@ export class StalkerSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
async #runCredentialSubmission(
|
||||
attempt: AttemptRecord,
|
||||
operation: () => Promise<StalkerAuthOutcome>
|
||||
): Promise<StalkerAuthOutcome> {
|
||||
if (
|
||||
attempt.credentialSubmissions >=
|
||||
MAX_CREDENTIAL_SUBMISSIONS_PER_ATTEMPT
|
||||
) {
|
||||
return credentialAttemptLimit();
|
||||
}
|
||||
attempt.credentialSubmissions += 1;
|
||||
return this.#normalizeCredentialChallenge(
|
||||
attempt,
|
||||
await operation()
|
||||
);
|
||||
}
|
||||
|
||||
#normalizeCredentialChallenge(
|
||||
attempt: AttemptRecord,
|
||||
outcome: StalkerAuthOutcome
|
||||
): StalkerAuthOutcome {
|
||||
if (outcome.kind !== 'credentials-required') {
|
||||
return outcome;
|
||||
}
|
||||
if (
|
||||
attempt.credentialSubmissions >=
|
||||
MAX_CREDENTIAL_SUBMISSIONS_PER_ATTEMPT
|
||||
) {
|
||||
return credentialAttemptLimit();
|
||||
}
|
||||
return {
|
||||
...outcome,
|
||||
attemptNumber: attempt.credentialSubmissions + 1,
|
||||
};
|
||||
}
|
||||
|
||||
async #publishProgress(
|
||||
attempt: AttemptRecord,
|
||||
progress: AuthenticationProgress,
|
||||
@@ -1702,6 +1750,7 @@ export class StalkerSessionManager {
|
||||
approvedOrigins: new Set(session.approvedOrigins),
|
||||
coordinator: session.coordinator,
|
||||
credentialCandidate: session.credentials,
|
||||
credentialSubmissions: 0,
|
||||
descriptor: session.descriptor,
|
||||
expiresAt: this.#now() + ATTEMPT_TTL_MS,
|
||||
lastIdentityRevision: session.identityRevision,
|
||||
@@ -2273,6 +2322,18 @@ export class StalkerSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
function credentialAttemptLimit(): Extract<
|
||||
StalkerAuthOutcome,
|
||||
{ kind: 'failure' }
|
||||
> {
|
||||
return {
|
||||
kind: 'failure',
|
||||
reason: STALKER_SESSION_FAILURE_REASONS.CredentialsAttemptLimit,
|
||||
retryable: false,
|
||||
stage: 'do-auth',
|
||||
};
|
||||
}
|
||||
|
||||
function endpointProgress(
|
||||
outcome: Exclude<StalkerEndpointResolverOutcome, { kind: 'full-session' }>
|
||||
): AuthenticationProgress {
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "إعادة المحاولة",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "حاول مرة أخرى",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Паўтарыць",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser-User-Agent überschreiben (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Anmeldedaten prüfen",
|
||||
"SAVE_AGAIN": "Erneut speichern",
|
||||
"RETRY": "Erneut versuchen",
|
||||
"USERNAME": "Benutzername",
|
||||
"PASSWORD": "Passwort"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Επανάληψη",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Retry",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Reintentar",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Réessayer",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Újrapróbálkozás",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Riprova",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "再試行",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "다시 시도",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Opnieuw proberen",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Spróbuj ponownie",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Tentar novamente",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Переопределение браузерного User-Agent (необязательно)",
|
||||
"VALIDATE_CREDENTIALS": "Проверить данные",
|
||||
"SAVE_AGAIN": "Сохранить снова",
|
||||
"RETRY": "Повторить",
|
||||
"USERNAME": "Имя пользователя",
|
||||
"PASSWORD": "Пароль"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Tekrar dene",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "重试",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "重試",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
+188
-6
@@ -15,7 +15,7 @@ import {
|
||||
StalkerSessionConnectionOutcome,
|
||||
StalkerSessionFullReadyOutcome,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { EMPTY, Subject, of, throwError } from 'rxjs';
|
||||
import { Subject, of, throwError } from 'rxjs';
|
||||
import { StalkerCredentialsDialogComponent } from './stalker-credentials-dialog.component';
|
||||
import { StalkerConnectionFlowService } from './stalker-connection-flow.service';
|
||||
import { StalkerOriginApprovalDialogComponent } from './stalker-origin-approval-dialog.component';
|
||||
@@ -178,6 +178,14 @@ describe('StalkerConnectionFlowService', () => {
|
||||
let dialog: {
|
||||
open: jest.Mock;
|
||||
};
|
||||
let snackAction: Subject<void>;
|
||||
let snackBar: {
|
||||
open: jest.Mock;
|
||||
};
|
||||
let snackRef: {
|
||||
dismiss: jest.Mock;
|
||||
onAction: () => Subject<void>;
|
||||
};
|
||||
let queuedDialogResults: unknown[];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -190,6 +198,14 @@ describe('StalkerConnectionFlowService', () => {
|
||||
store = {
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
snackAction = new Subject<void>();
|
||||
snackRef = {
|
||||
dismiss: jest.fn(),
|
||||
onAction: () => snackAction,
|
||||
};
|
||||
snackBar = {
|
||||
open: jest.fn(() => snackRef),
|
||||
};
|
||||
queuedDialogResults = [];
|
||||
dialog = {
|
||||
open: jest.fn(() => ({
|
||||
@@ -219,11 +235,7 @@ describe('StalkerConnectionFlowService', () => {
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: {
|
||||
open: jest.fn(() => ({
|
||||
onAction: () => EMPTY,
|
||||
})),
|
||||
},
|
||||
useValue: snackBar,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
@@ -602,6 +614,138 @@ describe('StalkerConnectionFlowService', () => {
|
||||
expect(playlists.persistStalkerConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers an actionable retry with the stable terminal reason', async () => {
|
||||
session.open.mockResolvedValueOnce({
|
||||
kind: 'failure',
|
||||
reason: 'incompatible-response',
|
||||
requestId: 'request-terminal-failure',
|
||||
retryable: false,
|
||||
stage: 'resolving',
|
||||
});
|
||||
session.forceRedetect.mockResolvedValueOnce(READY);
|
||||
|
||||
await expect(
|
||||
service.ensureConnected(LEGACY_PLAYLIST)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
expect.stringContaining('incompatible-response'),
|
||||
'HOME.STALKER_PORTAL.RETRY',
|
||||
{ duration: 120_000 }
|
||||
);
|
||||
|
||||
snackAction.next();
|
||||
snackAction.next();
|
||||
await flushPromises();
|
||||
|
||||
expect(session.forceRedetect).toHaveBeenCalledWith('lease-route-1');
|
||||
expect(session.forceRedetect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('invalidates a terminal retry action when navigation cancels the flow', async () => {
|
||||
session.open.mockResolvedValueOnce({
|
||||
kind: 'failure',
|
||||
reason: 'request-timeout',
|
||||
requestId: 'request-terminal-cancel',
|
||||
retryable: true,
|
||||
stage: 'handshaking',
|
||||
});
|
||||
|
||||
await service.ensureConnected(LEGACY_PLAYLIST);
|
||||
await service.cancel();
|
||||
snackAction.next();
|
||||
await flushPromises();
|
||||
|
||||
expect(snackRef.dismiss).toHaveBeenCalled();
|
||||
expect(session.forceRedetect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalidates an in-flight typed run when a new run resolves through the stateless path', async () => {
|
||||
const deferredOpen = createDeferred<StalkerSessionConnectionOutcome>();
|
||||
session.open.mockReturnValueOnce(deferredOpen.promise);
|
||||
const connecting = service.ensureConnected(LEGACY_PLAYLIST);
|
||||
await flushPromises();
|
||||
|
||||
const statelessPlaylist = {
|
||||
...LEGACY_PLAYLIST,
|
||||
stalkerRecipeClassifierVersion: 1,
|
||||
stalkerRequestRecipe: 'stateless-mac' as const,
|
||||
};
|
||||
await expect(
|
||||
service.ensureConnected(statelessPlaylist)
|
||||
).resolves.toBe(statelessPlaylist);
|
||||
|
||||
deferredOpen.resolve({
|
||||
kind: 'failure',
|
||||
reason: 'request-timeout',
|
||||
requestId: 'request-stale-typed-failure',
|
||||
retryable: true,
|
||||
stage: 'handshaking',
|
||||
});
|
||||
await expect(connecting).resolves.toBeUndefined();
|
||||
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('turns an asynchronous force-redetect continuation rejection into an actionable failure', async () => {
|
||||
session.forceRedetect.mockResolvedValueOnce({
|
||||
attemptNumber: 1,
|
||||
attemptRef: 'attempt-redetect-rejection',
|
||||
challengeRef: 'challenge-redetect-rejection',
|
||||
kind: 'credentials-required',
|
||||
requestId: 'request-redetect-rejection',
|
||||
});
|
||||
session.continue.mockRejectedValueOnce(
|
||||
new Error('redetect-continue-failed')
|
||||
);
|
||||
queuedDialogResults.push({
|
||||
password: 'password',
|
||||
username: 'user',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.forceRedetect(LEGACY_PLAYLIST)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(snackBar.open).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('connection-redetect-failed'),
|
||||
'HOME.STALKER_PORTAL.RETRY',
|
||||
{ duration: 120_000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('turns an asynchronous recovery continuation rejection into an actionable failure', async () => {
|
||||
session.open.mockResolvedValueOnce({
|
||||
attemptNumber: 1,
|
||||
attemptRef: 'attempt-recovery-rejection',
|
||||
challengeRef: 'challenge-recovery-rejection',
|
||||
kind: 'credentials-required',
|
||||
requestId: 'request-recovery-rejection',
|
||||
});
|
||||
session.continue.mockRejectedValueOnce(
|
||||
new Error('recovery-continue-failed')
|
||||
);
|
||||
queuedDialogResults.push({
|
||||
password: 'password',
|
||||
username: 'user',
|
||||
});
|
||||
const handler = session.recoveryHandler();
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
await expect(
|
||||
handler?.({
|
||||
playlist: LEGACY_PLAYLIST,
|
||||
trigger: 'endpoint-shape',
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(snackBar.open).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('connection-recovery-failed'),
|
||||
'HOME.STALKER_PORTAL.RETRY',
|
||||
{ duration: 120_000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('retains a ready attempt after a local write failure and retries the same draft', async () => {
|
||||
session.open.mockResolvedValue(READY);
|
||||
playlists.persistStalkerConnection.mockReturnValueOnce(
|
||||
@@ -679,6 +823,44 @@ describe('StalkerConnectionFlowService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the actionable terminal reason when promotion recovery returns a failure', async () => {
|
||||
session.open.mockResolvedValueOnce(READY).mockResolvedValueOnce({
|
||||
kind: 'failure',
|
||||
reason: 'portal-unavailable',
|
||||
requestId: 'request-promotion-reopen-failure',
|
||||
retryable: true,
|
||||
stage: 'handshaking',
|
||||
});
|
||||
session.commit.mockRejectedValueOnce(new Error('promotion-failed'));
|
||||
|
||||
await expect(
|
||||
service.ensureConnected(LEGACY_PLAYLIST)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(snackBar.open).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('portal-unavailable'),
|
||||
'HOME.STALKER_PORTAL.RETRY',
|
||||
{ duration: 120_000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('offers an actionable retry when promotion recovery cannot reopen the persisted playlist', async () => {
|
||||
session.open
|
||||
.mockResolvedValueOnce(READY)
|
||||
.mockRejectedValueOnce(new Error('reopen-failed'));
|
||||
session.commit.mockRejectedValueOnce(new Error('promotion-failed'));
|
||||
|
||||
await expect(
|
||||
service.ensureConnected(LEGACY_PLAYLIST)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(snackBar.open).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('session-promotion-failed'),
|
||||
'HOME.STALKER_PORTAL.RETRY',
|
||||
{ duration: 120_000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('discards a provisional ready outcome that arrives after cancellation of open', async () => {
|
||||
const deferredOpen = createDeferred<StalkerSessionConnectionOutcome>();
|
||||
session.open.mockReturnValueOnce(deferredOpen.promise);
|
||||
|
||||
+93
-28
@@ -47,6 +47,7 @@ export class StalkerConnectionFlowService {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly readySubject = new Subject<Playlist>();
|
||||
private activeDialog: { close: () => void } | null = null;
|
||||
private activeConnectionRetry: { dismiss: () => void } | null = null;
|
||||
private activeAttemptRef: string | null = null;
|
||||
private pendingPersistence: PendingPersistence | null = null;
|
||||
private runId = 0;
|
||||
@@ -65,10 +66,11 @@ export class StalkerConnectionFlowService {
|
||||
}
|
||||
|
||||
async ensureConnected(playlist: Playlist): Promise<Playlist | undefined> {
|
||||
const runId = ++this.runId;
|
||||
this.dismissConnectionRetry();
|
||||
if (!this.shouldUseTypedSession(playlist)) {
|
||||
return playlist;
|
||||
}
|
||||
const runId = ++this.runId;
|
||||
await this.discardPending();
|
||||
const needsMigration = !this.hasVerifiedRecipe(playlist);
|
||||
try {
|
||||
@@ -83,33 +85,49 @@ export class StalkerConnectionFlowService {
|
||||
);
|
||||
return await this.handleOutcome(playlist, outcome, runId, false);
|
||||
} catch {
|
||||
this.offerConnectionRetry(
|
||||
playlist,
|
||||
'connection-open-failed',
|
||||
runId
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async forceRedetect(playlist: Playlist): Promise<Playlist | undefined> {
|
||||
if (!this.session.supportsTypedSessions()) {
|
||||
this.dismissConnectionRetry();
|
||||
return playlist;
|
||||
}
|
||||
const leaseRef = this.session.getLeaseRef(playlist._id);
|
||||
if (leaseRef === undefined) {
|
||||
const runId = ++this.runId;
|
||||
await this.discardPending();
|
||||
try {
|
||||
const runId = ++this.runId;
|
||||
this.dismissConnectionRetry();
|
||||
try {
|
||||
if (leaseRef === undefined) {
|
||||
await this.discardPending();
|
||||
const outcome = await this.session.open(playlist, {
|
||||
connectionMode: 'provisional',
|
||||
provisionalReason: 'migration',
|
||||
});
|
||||
return this.handleOutcome(playlist, outcome, runId, true);
|
||||
} catch {
|
||||
return undefined;
|
||||
return await this.handleOutcome(
|
||||
playlist,
|
||||
outcome,
|
||||
runId,
|
||||
true
|
||||
);
|
||||
}
|
||||
const outcome = await this.session.forceRedetect(leaseRef);
|
||||
return outcome.kind === 'success'
|
||||
? playlist
|
||||
: await this.handleOutcome(playlist, outcome, runId, true);
|
||||
} catch {
|
||||
this.offerConnectionRetry(
|
||||
playlist,
|
||||
'connection-redetect-failed',
|
||||
runId
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const runId = ++this.runId;
|
||||
const outcome = await this.session.forceRedetect(leaseRef);
|
||||
return outcome.kind === 'success'
|
||||
? playlist
|
||||
: this.handleOutcome(playlist, outcome, runId, true);
|
||||
}
|
||||
|
||||
async retryPendingPersistence(): Promise<Playlist | undefined> {
|
||||
@@ -125,6 +143,7 @@ export class StalkerConnectionFlowService {
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
this.runId += 1;
|
||||
this.dismissConnectionRetry();
|
||||
this.activeDialog?.close();
|
||||
this.activeDialog = null;
|
||||
await this.discardPending();
|
||||
@@ -141,6 +160,7 @@ export class StalkerConnectionFlowService {
|
||||
return undefined;
|
||||
}
|
||||
const runId = ++this.runId;
|
||||
this.dismissConnectionRetry();
|
||||
await this.discardPending();
|
||||
try {
|
||||
const outcome =
|
||||
@@ -151,13 +171,18 @@ export class StalkerConnectionFlowService {
|
||||
provisionalReason: 'migration',
|
||||
})
|
||||
: request.outcome;
|
||||
return this.handleOutcome(
|
||||
return await this.handleOutcome(
|
||||
request.playlist,
|
||||
outcome,
|
||||
runId,
|
||||
true
|
||||
);
|
||||
} catch {
|
||||
this.offerConnectionRetry(
|
||||
request.playlist,
|
||||
'connection-recovery-failed',
|
||||
runId
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -246,6 +271,9 @@ export class StalkerConnectionFlowService {
|
||||
outcome.kind === 'ready' ? credentials : undefined;
|
||||
continue;
|
||||
}
|
||||
if (outcome.kind === 'failure') {
|
||||
this.offerConnectionRetry(playlist, outcome.reason, runId);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
@@ -350,6 +378,7 @@ export class StalkerConnectionFlowService {
|
||||
}
|
||||
this.activeAttemptRef = null;
|
||||
this.pendingPersistence = null;
|
||||
this.dismissConnectionRetry();
|
||||
if (pending.announceReady) {
|
||||
this.readySubject.next(persisted);
|
||||
}
|
||||
@@ -371,23 +400,23 @@ export class StalkerConnectionFlowService {
|
||||
try {
|
||||
outcome = await this.session.open(persisted);
|
||||
} catch {
|
||||
this.notifyPromotionFailure();
|
||||
this.offerConnectionRetry(
|
||||
persisted,
|
||||
'session-promotion-failed',
|
||||
pending.runId
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (await this.discardOutcomeWhenStale(outcome, pending.runId)) {
|
||||
return undefined;
|
||||
}
|
||||
if (outcome.kind !== 'ready') {
|
||||
const recovered = await this.handleOutcome(
|
||||
return this.handleOutcome(
|
||||
persisted,
|
||||
outcome,
|
||||
pending.runId,
|
||||
pending.announceReady
|
||||
);
|
||||
if (recovered === undefined && pending.runId === this.runId) {
|
||||
this.notifyPromotionFailure();
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
if (pending.announceReady) {
|
||||
@@ -442,6 +471,7 @@ export class StalkerConnectionFlowService {
|
||||
}
|
||||
|
||||
private offerPersistenceRetry(): void {
|
||||
this.dismissConnectionRetry();
|
||||
const ref = this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.STALKER_PORTAL.CONNECTION_FAILURE_GENERIC',
|
||||
@@ -455,15 +485,50 @@ export class StalkerConnectionFlowService {
|
||||
.subscribe(() => void this.retryPendingPersistence());
|
||||
}
|
||||
|
||||
private notifyPromotionFailure(): void {
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.STALKER_PORTAL.CONNECTION_FAILURE_GENERIC',
|
||||
{ reason: 'session-promotion-failed' }
|
||||
),
|
||||
undefined,
|
||||
{ duration: 10_000 }
|
||||
offerRetry(playlist: Playlist, reason: string): void {
|
||||
this.offerConnectionRetry(playlist, reason, this.runId);
|
||||
}
|
||||
|
||||
private offerConnectionRetry(
|
||||
playlist: Playlist,
|
||||
reason: string,
|
||||
runId: number
|
||||
): void {
|
||||
if (runId !== this.runId) {
|
||||
return;
|
||||
}
|
||||
this.dismissConnectionRetry();
|
||||
const ref = this.snackBar.open(
|
||||
this.connectionFailureMessage(reason),
|
||||
this.translate.instant('HOME.STALKER_PORTAL.RETRY'),
|
||||
{ duration: 120_000 }
|
||||
);
|
||||
this.activeConnectionRetry = ref;
|
||||
ref.onAction()
|
||||
.pipe(take(1))
|
||||
.subscribe(() => {
|
||||
if (
|
||||
runId !== this.runId ||
|
||||
this.activeConnectionRetry !== ref
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.activeConnectionRetry = null;
|
||||
void this.forceRedetect(playlist);
|
||||
});
|
||||
}
|
||||
|
||||
private connectionFailureMessage(reason: string): string {
|
||||
const message = this.translate.instant(
|
||||
'HOME.STALKER_PORTAL.CONNECTION_FAILURE_GENERIC',
|
||||
{ reason }
|
||||
);
|
||||
return message.includes(reason) ? message : `${message} ${reason}`;
|
||||
}
|
||||
|
||||
private dismissConnectionRetry(): void {
|
||||
this.activeConnectionRetry?.dismiss();
|
||||
this.activeConnectionRetry = null;
|
||||
}
|
||||
|
||||
private async discardPending(): Promise<void> {
|
||||
|
||||
+121
-1
@@ -25,6 +25,8 @@ const ACTIVE_PLAYLIST: PlaylistMeta = {
|
||||
const FULL_STALKER_PLAYLIST: PlaylistMeta = {
|
||||
...ACTIVE_PLAYLIST,
|
||||
isFullStalkerPortal: true,
|
||||
stalkerRecipeClassifierVersion: 1,
|
||||
stalkerRequestRecipe: 'full-session',
|
||||
stalkerSerialNumber: 'CUSTOMSN123',
|
||||
stalkerDeviceId1: 'DEVICE-ID-1',
|
||||
stalkerDeviceId2: 'DEVICE-ID-2',
|
||||
@@ -89,6 +91,7 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
cancel: jest.fn().mockResolvedValue(undefined),
|
||||
connectionReady$: connectionReady.asObservable(),
|
||||
ensureConnected: jest.fn(async (playlist: PlaylistMeta) => playlist),
|
||||
offerRetry: jest.fn(),
|
||||
};
|
||||
|
||||
const session = {
|
||||
@@ -142,12 +145,19 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
stalkerStore.setSelectedContentType.mockClear();
|
||||
stalkerStore.setSearchPhrase.mockClear();
|
||||
playlistsService.getPlaylistById.mockClear();
|
||||
playlistsService.getPlaylistById.mockImplementation(() =>
|
||||
of(ACTIVE_PLAYLIST)
|
||||
);
|
||||
connectionFlow.cancel.mockClear();
|
||||
connectionFlow.ensureConnected.mockClear();
|
||||
connectionFlow.offerRetry.mockClear();
|
||||
session.activate.mockClear();
|
||||
session.close.mockClear();
|
||||
session.deactivate.mockClear();
|
||||
session.getLeaseRef.mockClear();
|
||||
session.getLeaseRef.mockImplementation(
|
||||
(playlistId: string) => `lease-${playlistId}`
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -258,7 +268,7 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses active Stalker playlist metadata directly when the portal mode is explicit', async () => {
|
||||
it('uses active Stalker playlist metadata directly when its recipe is current', async () => {
|
||||
activePlaylist.set(FULL_STALKER_PLAYLIST);
|
||||
|
||||
TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
@@ -270,6 +280,30 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let a legacy boolean on temporary route metadata shadow the full stored playlist', async () => {
|
||||
activePlaylist.set({
|
||||
...ACTIVE_PLAYLIST,
|
||||
isFullStalkerPortal: false,
|
||||
title: 'Untitled playlist',
|
||||
});
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of(FULL_STALKER_PLAYLIST)
|
||||
);
|
||||
|
||||
TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
expect(playlistsService.getPlaylistById).toHaveBeenCalledWith(
|
||||
PLAYLIST_ID
|
||||
);
|
||||
expect(connectionFlow.ensureConnected).toHaveBeenCalledWith(
|
||||
FULL_STALKER_PLAYLIST
|
||||
);
|
||||
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
|
||||
FULL_STALKER_PLAYLIST
|
||||
);
|
||||
});
|
||||
|
||||
it('does not expose a playlist to catalog resources when connection flow is cancelled', async () => {
|
||||
connectionFlow.ensureConnected.mockResolvedValueOnce(undefined);
|
||||
|
||||
@@ -279,6 +313,92 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers a retry instead of exposing a playlist when lease activation fails', async () => {
|
||||
session.activate.mockResolvedValueOnce({
|
||||
kind: 'failure',
|
||||
reason: 'portal-unavailable',
|
||||
requestId: 'activate-failure',
|
||||
retryable: true,
|
||||
stage: 'ready',
|
||||
});
|
||||
|
||||
TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
expect(connectionFlow.offerRetry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ _id: PLAYLIST_ID }),
|
||||
'portal-unavailable'
|
||||
);
|
||||
expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not let a stale activation failure close or retry a newer lease for the same playlist', async () => {
|
||||
const staleActivation = createDeferred<{
|
||||
kind: 'failure';
|
||||
reason: string;
|
||||
requestId: string;
|
||||
retryable: boolean;
|
||||
stage: 'ready';
|
||||
}>();
|
||||
let currentLeaseRef = 'lease-stale';
|
||||
session.getLeaseRef.mockImplementation(() => currentLeaseRef);
|
||||
session.activate
|
||||
.mockReturnValueOnce(staleActivation.promise)
|
||||
.mockResolvedValueOnce({
|
||||
action: 'activate',
|
||||
kind: 'success',
|
||||
requestId: 'activate-current',
|
||||
});
|
||||
|
||||
TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
expect(session.activate).toHaveBeenCalledWith('lease-stale');
|
||||
|
||||
router.url = '/workspace/m3u/playlist-2';
|
||||
playlistContext.syncFromUrl.mockReturnValueOnce({
|
||||
inWorkspace: true,
|
||||
playlistId: 'playlist-2',
|
||||
provider: 'm3u',
|
||||
section: null,
|
||||
});
|
||||
routerEvents.next(
|
||||
new NavigationEnd(
|
||||
2,
|
||||
`/workspace/stalker/${PLAYLIST_ID}/vod`,
|
||||
router.url
|
||||
)
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
currentLeaseRef = 'lease-current';
|
||||
router.url = `/workspace/stalker/${PLAYLIST_ID}/vod`;
|
||||
routerEvents.next(
|
||||
new NavigationEnd(
|
||||
3,
|
||||
'/workspace/m3u/playlist-2',
|
||||
router.url
|
||||
)
|
||||
);
|
||||
await flushEffects();
|
||||
expect(session.activate).toHaveBeenLastCalledWith('lease-current');
|
||||
expect(stalkerStore.setCurrentPlaylist).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ _id: PLAYLIST_ID })
|
||||
);
|
||||
|
||||
staleActivation.resolve({
|
||||
kind: 'failure',
|
||||
reason: 'stale-portal-unavailable',
|
||||
requestId: 'activate-stale-failure',
|
||||
retryable: true,
|
||||
stage: 'ready',
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(session.deactivate).not.toHaveBeenCalledWith('lease-current');
|
||||
expect(session.close).not.toHaveBeenCalledWith('lease-current');
|
||||
expect(connectionFlow.offerRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels a provisional route attempt before leaving the Stalker workspace', async () => {
|
||||
TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
@@ -16,8 +16,13 @@ import {
|
||||
StalkerSessionService,
|
||||
StalkerStore,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { STALKER_RECIPE_CLASSIFIER_VERSION } from '@iptvnator/portal/stalker/protocol';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
type StalkerSessionLeaseRef,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { StalkerConnectionFlowService } from './stalker-connection-flow/stalker-connection-flow.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -146,7 +151,8 @@ export class StalkerWorkspaceRouteSession {
|
||||
private async activateAndSetCurrentPlaylist(
|
||||
playlist: Playlist
|
||||
): Promise<void> {
|
||||
if (playlist._id !== this.currentPlaylistId) {
|
||||
const activationGeneration = this.syncGeneration;
|
||||
if (!this.isCurrentActivation(playlist, activationGeneration)) {
|
||||
return;
|
||||
}
|
||||
const leaseRef = this.session.getLeaseRef(playlist._id);
|
||||
@@ -154,28 +160,96 @@ export class StalkerWorkspaceRouteSession {
|
||||
try {
|
||||
const activation = await this.session.activate(leaseRef);
|
||||
if (activation.kind !== 'success') {
|
||||
await this.releasePlaylistSession(playlist._id);
|
||||
const retryEligible = this.isCurrentActivation(
|
||||
playlist,
|
||||
activationGeneration,
|
||||
leaseRef
|
||||
);
|
||||
await this.releaseLeaseSession(leaseRef);
|
||||
this.offerActivationRetry(
|
||||
playlist,
|
||||
activation.kind === 'failure'
|
||||
? activation.reason
|
||||
: `session-activation-${activation.kind}`,
|
||||
activationGeneration,
|
||||
leaseRef,
|
||||
retryEligible
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
await this.releasePlaylistSession(playlist._id);
|
||||
const retryEligible = this.isCurrentActivation(
|
||||
playlist,
|
||||
activationGeneration,
|
||||
leaseRef
|
||||
);
|
||||
await this.releaseLeaseSession(leaseRef);
|
||||
this.offerActivationRetry(
|
||||
playlist,
|
||||
'session-activation-failed',
|
||||
activationGeneration,
|
||||
leaseRef,
|
||||
retryEligible
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (playlist._id === this.currentPlaylistId) {
|
||||
if (
|
||||
this.isCurrentActivation(
|
||||
playlist,
|
||||
activationGeneration,
|
||||
leaseRef
|
||||
)
|
||||
) {
|
||||
await this.stalkerStore.setCurrentPlaylist(playlist);
|
||||
}
|
||||
}
|
||||
|
||||
private offerActivationRetry(
|
||||
playlist: Playlist,
|
||||
reason: string,
|
||||
activationGeneration: number,
|
||||
leaseRef: StalkerSessionLeaseRef,
|
||||
retryEligible: boolean
|
||||
): void {
|
||||
const currentLeaseRef = this.session.getLeaseRef(playlist._id);
|
||||
if (
|
||||
retryEligible &&
|
||||
this.isCurrentActivation(playlist, activationGeneration) &&
|
||||
(currentLeaseRef === undefined || currentLeaseRef === leaseRef)
|
||||
) {
|
||||
this.connectionFlow.offerRetry(playlist, reason);
|
||||
}
|
||||
}
|
||||
|
||||
private async releasePlaylistSession(playlistId: string): Promise<void> {
|
||||
const leaseRef = this.session.getLeaseRef(playlistId);
|
||||
if (leaseRef === undefined) {
|
||||
return;
|
||||
}
|
||||
await this.releaseLeaseSession(leaseRef);
|
||||
}
|
||||
|
||||
private async releaseLeaseSession(
|
||||
leaseRef: StalkerSessionLeaseRef
|
||||
): Promise<void> {
|
||||
await this.session.deactivate(leaseRef).catch(() => undefined);
|
||||
await this.session.close(leaseRef).catch(() => undefined);
|
||||
}
|
||||
|
||||
private isCurrentActivation(
|
||||
playlist: Playlist,
|
||||
activationGeneration: number,
|
||||
leaseRef?: StalkerSessionLeaseRef
|
||||
): boolean {
|
||||
return (
|
||||
activationGeneration === this.syncGeneration &&
|
||||
playlist._id === this.currentPlaylistId &&
|
||||
(leaseRef === undefined ||
|
||||
this.session.getLeaseRef(playlist._id) === leaseRef)
|
||||
);
|
||||
}
|
||||
|
||||
private syncRouteState(section: PortalRailSection | null): void {
|
||||
if (!section) {
|
||||
return;
|
||||
@@ -213,7 +287,7 @@ export class StalkerWorkspaceRouteSession {
|
||||
): Promise<PlaylistMeta | undefined> {
|
||||
const activePlaylist = this.playlistContext.activePlaylist();
|
||||
|
||||
if (this.hasExplicitStalkerPortalMode(playlistId, activePlaylist)) {
|
||||
if (this.hasCurrentStalkerRecipe(playlistId, activePlaylist)) {
|
||||
return activePlaylist;
|
||||
}
|
||||
|
||||
@@ -225,13 +299,15 @@ export class StalkerWorkspaceRouteSession {
|
||||
return storedPlaylist ?? activePlaylist ?? undefined;
|
||||
}
|
||||
|
||||
private hasExplicitStalkerPortalMode(
|
||||
private hasCurrentStalkerRecipe(
|
||||
playlistId: string,
|
||||
playlist: PlaylistMeta | null
|
||||
): playlist is PlaylistMeta {
|
||||
return (
|
||||
playlist?._id === playlistId &&
|
||||
playlist.isFullStalkerPortal !== undefined
|
||||
playlist.stalkerRequestRecipe !== undefined &&
|
||||
playlist.stalkerRecipeClassifierVersion ===
|
||||
STALKER_RECIPE_CLASSIFIER_VERSION
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user