mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
fix(stalker): surface watchdog session failures
This commit is contained in:
1 parent
4317380137
commit
317f4ffdad
5 files changed
+375
-25
No files matched your search
@@ -117,6 +117,28 @@ describe('StalkerAuthSession', () => {
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('prefers profile timeslot over watchdog_timeout for the watchdog interval', async () => {
|
||||
const { auth } = harness(
|
||||
{
|
||||
kind: 'ready',
|
||||
profile: {
|
||||
status: 0,
|
||||
timeslot: '17.5',
|
||||
watchdog_timeout: '45',
|
||||
},
|
||||
status: 0,
|
||||
},
|
||||
() => {
|
||||
throw new Error('No request expected');
|
||||
}
|
||||
);
|
||||
|
||||
await expect(auth.start()).resolves.toMatchObject({
|
||||
kind: 'ready',
|
||||
watchdogIntervalSeconds: 17.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('runs do_auth only after status 2, then sends the second profile with step 1', async () => {
|
||||
const exactUsername = ' confirmed-user ';
|
||||
const { auth, calls } = harness(
|
||||
@@ -216,6 +238,114 @@ describe('StalkerAuthSession', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses the coherent authenticated profile payload for watchdog checks', async () => {
|
||||
const { auth, calls } = harness(
|
||||
{
|
||||
kind: 'credentials-required',
|
||||
profile: { status: 2 },
|
||||
status: 2,
|
||||
},
|
||||
(request) => {
|
||||
if (request.params?.['action'] === 'do_auth') {
|
||||
return success({ js: true });
|
||||
}
|
||||
return success({
|
||||
js: {
|
||||
login: 'configured-user',
|
||||
status: 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
apiSignature: 'configured-api-signature',
|
||||
deviceId1: 'configured-device-one',
|
||||
deviceId2: 'configured-device-two',
|
||||
firmwareVersion: 'configured-firmware',
|
||||
hardwareVersion: 'configured-hardware',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
prehash: 'configured-prehash',
|
||||
serialNumber: 'configured-serial',
|
||||
signature1: 'configured-signature',
|
||||
signature2: 'configured-signature-two',
|
||||
}
|
||||
);
|
||||
|
||||
await auth.start();
|
||||
await auth.submitCredentials({
|
||||
password: 'configured-password',
|
||||
username: 'configured-user',
|
||||
});
|
||||
await expect(auth.checkProfile()).resolves.toMatchObject({
|
||||
kind: 'success',
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[2].params).toEqual(calls[1].params);
|
||||
expect(calls[2].params).toMatchObject({
|
||||
action: 'get_profile',
|
||||
api_signature: 'configured-api-signature',
|
||||
auth_second_step: 1,
|
||||
device_id: 'configured-device-one',
|
||||
device_id2: 'configured-device-two',
|
||||
hw_version: 'configured-hardware',
|
||||
not_valid_token: 0,
|
||||
prehash: 'configured-prehash',
|
||||
signature: 'configured-signature',
|
||||
signature2: 'configured-signature-two',
|
||||
sn: 'configured-serial',
|
||||
type: 'stb',
|
||||
ver: 'configured-firmware',
|
||||
});
|
||||
expect(JSON.parse(String(calls[2].params?.['metrics']))).toMatchObject({
|
||||
mac: '00:1A:79:AA:BB:CC',
|
||||
sn: 'configured-serial',
|
||||
uid: 'configured-device-two',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a MAC-only watchdog profile on the first authentication step', async () => {
|
||||
const { auth, calls } = harness(
|
||||
{
|
||||
kind: 'ready',
|
||||
profile: { status: 0 },
|
||||
status: 0,
|
||||
},
|
||||
() => success({ js: { status: 0 } })
|
||||
);
|
||||
await auth.start();
|
||||
|
||||
await expect(auth.checkProfile()).resolves.toMatchObject({
|
||||
kind: 'success',
|
||||
});
|
||||
|
||||
expect(calls[0].params).toMatchObject({
|
||||
action: 'get_profile',
|
||||
auth_second_step: 0,
|
||||
not_valid_token: 0,
|
||||
type: 'stb',
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies a blocked watchdog profile without changing identity', async () => {
|
||||
const { auth } = harness(
|
||||
{
|
||||
kind: 'ready',
|
||||
profile: { login: 'stable-user', status: 0 },
|
||||
status: 0,
|
||||
},
|
||||
() => success({ js: { status: 1 } })
|
||||
);
|
||||
await auth.start();
|
||||
|
||||
await expect(auth.checkProfile()).resolves.toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'account-or-device-blocked',
|
||||
retryable: false,
|
||||
stage: 'ready',
|
||||
});
|
||||
expect(auth.getPrincipalKey()).toBe('stable-user');
|
||||
});
|
||||
|
||||
it('marks rejected saved credentials and accepts a fresh bounded submission', async () => {
|
||||
let doAuthCount = 0;
|
||||
const { auth } = harness(
|
||||
|
||||
@@ -232,15 +232,7 @@ export class StalkerAuthSession {
|
||||
const secondProfile = await this.#http.request({
|
||||
headers: this.authenticatedHeaders(),
|
||||
mode: STALKER_HTTP_REQUEST_MODES.IdentityBearing,
|
||||
params: {
|
||||
...this.#identity.profileParameters,
|
||||
action: 'get_profile',
|
||||
auth_second_step: 1,
|
||||
JsHttpRequest: '1-xml',
|
||||
metrics: JSON.stringify(this.#identity.metrics),
|
||||
not_valid_token: 0,
|
||||
type: 'stb',
|
||||
},
|
||||
params: profileRequestParameters(this.#identity, true),
|
||||
url: this.#endpoint,
|
||||
});
|
||||
const normalizedSecondProfile = normalizeResponse(
|
||||
@@ -288,6 +280,50 @@ export class StalkerAuthSession {
|
||||
return this.#acceptedCredentials !== undefined;
|
||||
}
|
||||
|
||||
async checkProfile(): Promise<StalkerAuthenticatedRequestOutcome> {
|
||||
if (this.#state !== 'ready') {
|
||||
return authFailure(
|
||||
STALKER_FAILURE_REASONS.IncompatibleResponse,
|
||||
'ready',
|
||||
false
|
||||
);
|
||||
}
|
||||
const outcome = await this.#http.request({
|
||||
headers: this.authenticatedHeaders(),
|
||||
mode: STALKER_HTTP_REQUEST_MODES.IdentityBearing,
|
||||
params: profileRequestParameters(
|
||||
this.#identity,
|
||||
this.#acceptedCredentials !== undefined
|
||||
),
|
||||
url: this.#endpoint,
|
||||
});
|
||||
const normalized = normalizeResponse(outcome, 'ready', true);
|
||||
if (normalized.kind !== 'success') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const profile = classifyStalkerProfile(normalized.value);
|
||||
if (profile.kind === 'ready') {
|
||||
this.#profile = profile.profile;
|
||||
return normalized;
|
||||
}
|
||||
if (profile.kind === 'blocked') {
|
||||
return authFailure(
|
||||
STALKER_FAILURE_REASONS.AccountOrDeviceBlocked,
|
||||
'ready',
|
||||
false
|
||||
);
|
||||
}
|
||||
if (profile.kind === 'credentials-required') {
|
||||
return authFailure(
|
||||
STALKER_FAILURE_REASONS.PrincipalTransitionRequired,
|
||||
'ready',
|
||||
false
|
||||
);
|
||||
}
|
||||
return authFailure(profile.reason, 'ready', false);
|
||||
}
|
||||
|
||||
async preparePlayback(streamUrl: string): Promise<StalkerPreparedPlayback> {
|
||||
if (this.#state !== 'ready') {
|
||||
throw new Error('stalker-playback-not-ready');
|
||||
@@ -540,6 +576,21 @@ function doAuthIdentityParameters(
|
||||
return parameters;
|
||||
}
|
||||
|
||||
function profileRequestParameters(
|
||||
identity: StalkerIdentityProfile,
|
||||
authSecondStep: boolean
|
||||
): Readonly<Record<string, string | number>> {
|
||||
return {
|
||||
...identity.profileParameters,
|
||||
action: 'get_profile',
|
||||
auth_second_step: authSecondStep ? 1 : 0,
|
||||
JsHttpRequest: '1-xml',
|
||||
metrics: JSON.stringify(identity.metrics),
|
||||
not_valid_token: 0,
|
||||
type: 'stb',
|
||||
};
|
||||
}
|
||||
|
||||
function buildReadyOutcome(
|
||||
profile: Readonly<Record<string, unknown>>
|
||||
): StalkerAuthReadyOutcome {
|
||||
@@ -562,9 +613,9 @@ function buildReadyOutcome(
|
||||
readNonEmptyString(accountInfo?.['tariff_plan_name']) ??
|
||||
readNonEmptyString(profile['tariff_plan_name']),
|
||||
});
|
||||
const watchdogIntervalSeconds = readPositiveFiniteNumber(
|
||||
profile['watchdog_timeout']
|
||||
);
|
||||
const watchdogIntervalSeconds =
|
||||
readPositiveFiniteNumber(profile['timeslot']) ??
|
||||
readPositiveFiniteNumber(profile['watchdog_timeout']);
|
||||
return {
|
||||
...(accountSummary === undefined ? {} : { accountSummary }),
|
||||
kind: 'ready',
|
||||
|
||||
+4
@@ -48,6 +48,10 @@ class BoundaryAuth implements StalkerSessionAuthLike {
|
||||
return true;
|
||||
}
|
||||
|
||||
async checkProfile(): Promise<StalkerAuthenticatedRequestOutcome> {
|
||||
return { kind: 'success', value: { js: { status: 0 } } };
|
||||
}
|
||||
|
||||
async preparePlayback(): Promise<{
|
||||
headers: Readonly<Record<string, string>>;
|
||||
streamUrl: string;
|
||||
|
||||
+106
@@ -112,6 +112,8 @@ function stateless(): StalkerEndpointResolverOutcome {
|
||||
}
|
||||
|
||||
class FakeAuth implements StalkerSessionAuthLike {
|
||||
readonly profileOutcomes: StalkerAuthenticatedRequestOutcome[] = [];
|
||||
profileChecks = 0;
|
||||
readonly requests: Readonly<Record<string, string | number>>[] = [];
|
||||
readonly submittedCredentials: StalkerAuthCredentials[] = [];
|
||||
startCalls = 0;
|
||||
@@ -139,6 +141,16 @@ class FakeAuth implements StalkerSessionAuthLike {
|
||||
return this.acceptedCredentials;
|
||||
}
|
||||
|
||||
async checkProfile(): Promise<StalkerAuthenticatedRequestOutcome> {
|
||||
this.profileChecks += 1;
|
||||
return (
|
||||
this.profileOutcomes.shift() ?? {
|
||||
kind: 'success',
|
||||
value: { js: { status: 0 } },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async start(): Promise<StalkerAuthOutcome> {
|
||||
this.startCalls += 1;
|
||||
return (
|
||||
@@ -1262,6 +1274,100 @@ describe('StalkerSessionManager', () => {
|
||||
expect(playbackContexts.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('surfaces classified watchdog failures until a later profile check recovers', async () => {
|
||||
jest.useFakeTimers();
|
||||
const random = jest.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
try {
|
||||
const auth = new FakeAuth();
|
||||
auth.profileOutcomes.push(
|
||||
{
|
||||
kind: 'failure',
|
||||
reason: STALKER_SESSION_FAILURE_REASONS.AccountAccessDenied,
|
||||
retryable: false,
|
||||
stage: 'ready',
|
||||
},
|
||||
{ kind: 'success', value: { js: { status: 0 } } }
|
||||
);
|
||||
const { manager, resolve } = harness({
|
||||
auths: [auth],
|
||||
resolverOutcomes: [full()],
|
||||
});
|
||||
const ready = await manager.open(1, {
|
||||
descriptor: descriptor('watchdog-recovery'),
|
||||
});
|
||||
expectFullReady(ready);
|
||||
await manager.control(1, {
|
||||
action: 'activate',
|
||||
leaseRef: ready.leaseRef,
|
||||
});
|
||||
|
||||
await jest.advanceTimersByTimeAsync(25_000);
|
||||
|
||||
expect(auth.profileChecks).toBe(1);
|
||||
await expect(
|
||||
manager.request(1, catalogRequest(ready.leaseRef))
|
||||
).resolves.toMatchObject({
|
||||
kind: 'failure',
|
||||
reason: STALKER_SESSION_FAILURE_REASONS.AccountAccessDenied,
|
||||
stage: 'ready',
|
||||
});
|
||||
expect(auth.requests).toHaveLength(0);
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(25_000);
|
||||
|
||||
await expect(
|
||||
manager.request(1, catalogRequest(ready.leaseRef))
|
||||
).resolves.toMatchObject({ kind: 'success' });
|
||||
expect(auth.profileChecks).toBe(2);
|
||||
expect(auth.requests).toHaveLength(1);
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
|
||||
await manager.destroyAll();
|
||||
} finally {
|
||||
random.mockRestore();
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('routes watchdog token rejection through one manager-owned refresh', async () => {
|
||||
jest.useFakeTimers();
|
||||
const random = jest.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
try {
|
||||
const expired = new FakeAuth();
|
||||
expired.profileOutcomes.push({ kind: 'token-rejected' });
|
||||
const refreshed = new FakeAuth();
|
||||
const { manager, resolve } = harness({
|
||||
auths: [expired, refreshed],
|
||||
resolverOutcomes: [full(), full()],
|
||||
});
|
||||
const ready = await manager.open(1, {
|
||||
descriptor: descriptor('watchdog-refresh'),
|
||||
});
|
||||
expectFullReady(ready);
|
||||
await manager.control(1, {
|
||||
action: 'activate',
|
||||
leaseRef: ready.leaseRef,
|
||||
});
|
||||
|
||||
await jest.advanceTimersByTimeAsync(25_000);
|
||||
|
||||
expect(expired.profileChecks).toBe(1);
|
||||
expect(resolve).toHaveBeenCalledTimes(2);
|
||||
expect(refreshed.startCalls).toBe(1);
|
||||
await expect(
|
||||
manager.request(1, catalogRequest(ready.leaseRef))
|
||||
).resolves.toMatchObject({ kind: 'success' });
|
||||
expect(expired.requests).toHaveLength(0);
|
||||
expect(refreshed.requests).toHaveLength(1);
|
||||
|
||||
await manager.destroyAll();
|
||||
} finally {
|
||||
random.mockRestore();
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('cannot resurrect an attempt after its renderer is cleaned up mid-authentication', async () => {
|
||||
const resolution = deferred<StalkerEndpointResolverOutcome>();
|
||||
const manager = new StalkerSessionManager({
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface StalkerPreparedPlayback {
|
||||
}
|
||||
|
||||
export interface StalkerSessionAuthLike {
|
||||
checkProfile(): Promise<StalkerAuthenticatedRequestOutcome>;
|
||||
getEndpoint(): string;
|
||||
getPrincipalKey(): string;
|
||||
hasAcceptedCredentials(): boolean;
|
||||
@@ -265,6 +266,11 @@ interface LeaseRecord {
|
||||
session: SessionRecord;
|
||||
}
|
||||
|
||||
type WatchdogRecoveryOutcome = Exclude<
|
||||
StalkerAuthenticatedRequestOutcome,
|
||||
{ kind: 'success' } | { kind: 'token-rejected' }
|
||||
>;
|
||||
|
||||
interface SessionRecord {
|
||||
accountSummary?: StalkerSessionAccountSummary;
|
||||
readonly activeLeases: Set<string>;
|
||||
@@ -284,6 +290,7 @@ interface SessionRecord {
|
||||
resolutionSourceUrl?: string;
|
||||
readonly coordinator: StalkerBaseIdentityCoordinator;
|
||||
transport: StalkerTransportConfig;
|
||||
watchdogRecovery?: WatchdogRecoveryOutcome;
|
||||
watchdogIntervalSeconds?: number;
|
||||
}
|
||||
|
||||
@@ -401,22 +408,43 @@ export class StalkerSessionManager {
|
||||
if (!session || session.principal !== target.principal) {
|
||||
return;
|
||||
}
|
||||
const binding = this.#captureBinding(session);
|
||||
const read = await session.coordinator.runRead(
|
||||
session.principal,
|
||||
session.epoch,
|
||||
() =>
|
||||
session.auth.request({
|
||||
action: 'get_profile',
|
||||
auth_second_step: 0,
|
||||
JsHttpRequest: '1-xml',
|
||||
type: 'stb',
|
||||
})
|
||||
binding.principal,
|
||||
binding.epoch,
|
||||
() => binding.auth.checkProfile()
|
||||
);
|
||||
if (
|
||||
read.kind === 'completed' &&
|
||||
read.value.kind === 'token-rejected'
|
||||
read.kind !== 'completed' ||
|
||||
!this.#isCurrentSessionBinding(binding)
|
||||
) {
|
||||
await this.#refreshSession(session);
|
||||
return;
|
||||
}
|
||||
if (read.value.kind === 'success') {
|
||||
session.watchdogRecovery = undefined;
|
||||
return;
|
||||
}
|
||||
if (read.value.kind !== 'token-rejected') {
|
||||
session.watchdogRecovery = read.value;
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshed = await this.#refreshSession(session);
|
||||
if (refreshed.kind === 'ready') {
|
||||
refreshed.session.watchdogRecovery = undefined;
|
||||
} else if (this.#isLiveSession(session)) {
|
||||
session.watchdogRecovery =
|
||||
refreshed.kind === 'failure'
|
||||
? refreshed.outcome
|
||||
: {
|
||||
finalOrigin:
|
||||
refreshed.progress.finalOrigin,
|
||||
kind: 'origin-approval-required',
|
||||
sourceOrigin:
|
||||
refreshed.progress.sourceOrigin,
|
||||
targetUrl:
|
||||
refreshed.progress.landingUrl,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -626,6 +654,23 @@ export class StalkerSessionManager {
|
||||
} as StalkerSessionRequestOutcome<Operation>;
|
||||
}
|
||||
|
||||
const watchdogRecovery = lease.session.watchdogRecovery;
|
||||
if (watchdogRecovery !== undefined) {
|
||||
if (watchdogRecovery.kind === 'origin-approval-required') {
|
||||
lease.session.watchdogRecovery = undefined;
|
||||
return this.#issueRecoveryOriginChallenge(
|
||||
senderId,
|
||||
lease.session,
|
||||
watchdogRecovery,
|
||||
requestId
|
||||
) as StalkerSessionRequestOutcome<Operation>;
|
||||
}
|
||||
return projectAuthFailure(
|
||||
requestId,
|
||||
watchdogRecovery
|
||||
) as StalkerSessionRequestOutcome<Operation>;
|
||||
}
|
||||
|
||||
let execution = await this.#runSessionRequest(
|
||||
lease.session,
|
||||
mapped.parameters
|
||||
@@ -1782,6 +1827,7 @@ export class StalkerSessionManager {
|
||||
progress.credentials
|
||||
);
|
||||
session.credentials = progress.credentials;
|
||||
session.watchdogRecovery = undefined;
|
||||
session.watchdogIntervalSeconds = progress.watchdogIntervalSeconds;
|
||||
this.#sessions.set(nextKey, session);
|
||||
this.#reactivateTransferredLeases(session);
|
||||
@@ -2038,9 +2084,22 @@ export class StalkerSessionManager {
|
||||
lease: LeaseRecord,
|
||||
binding: SessionGenerationBinding
|
||||
): boolean {
|
||||
const snapshot = binding.session.coordinator.snapshot;
|
||||
return (
|
||||
this.#isCurrentGenerationBinding(lease, binding) &&
|
||||
this.#isCurrentSessionBinding(binding)
|
||||
);
|
||||
}
|
||||
|
||||
#isCurrentSessionBinding(binding: SessionGenerationBinding): boolean {
|
||||
const session = binding.session;
|
||||
const snapshot = session.coordinator.snapshot;
|
||||
return (
|
||||
this.#sessions.get(binding.key) === session &&
|
||||
session.auth === binding.auth &&
|
||||
session.epoch === binding.epoch &&
|
||||
session.generation === binding.generation &&
|
||||
session.key === binding.key &&
|
||||
session.principal === binding.principal &&
|
||||
snapshot.epoch === binding.epoch &&
|
||||
snapshot.activePrincipal === binding.principal
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user