mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:27:41 -08:00
fix(stalker): harden provisional connection lifecycle
This commit is contained in:
1 parent
79dcbba241
commit
8569612dbd
6 files changed
+446
-24
No files matched your search
+58
-1
@@ -115,6 +115,7 @@ class FakeAuth implements StalkerSessionAuthLike {
|
||||
readonly profileOutcomes: StalkerAuthenticatedRequestOutcome[] = [];
|
||||
profileChecks = 0;
|
||||
readonly requests: Readonly<Record<string, string | number>>[] = [];
|
||||
readonly startedCredentials: Array<StalkerAuthCredentials | undefined> = [];
|
||||
readonly submittedCredentials: StalkerAuthCredentials[] = [];
|
||||
startCalls = 0;
|
||||
|
||||
@@ -151,8 +152,11 @@ class FakeAuth implements StalkerSessionAuthLike {
|
||||
);
|
||||
}
|
||||
|
||||
async start(): Promise<StalkerAuthOutcome> {
|
||||
async start(
|
||||
credentials?: StalkerAuthCredentials
|
||||
): Promise<StalkerAuthOutcome> {
|
||||
this.startCalls += 1;
|
||||
this.startedCredentials.push(credentials);
|
||||
return (
|
||||
this.startOutcomes.shift() ?? {
|
||||
kind: 'failure',
|
||||
@@ -323,6 +327,59 @@ function deferred<T>() {
|
||||
}
|
||||
|
||||
describe('StalkerSessionManager', () => {
|
||||
it('does not replay a credential candidate after an origin challenge intervenes', async () => {
|
||||
const sourceAuth = new FakeAuth('mac-only', false, [
|
||||
{ attemptNumber: 1, kind: 'credentials-required' },
|
||||
{
|
||||
finalOrigin: 'https://approved.test',
|
||||
kind: 'origin-approval-required',
|
||||
sourceOrigin: 'https://portal.test',
|
||||
targetUrl: 'https://approved.test/c/',
|
||||
},
|
||||
]);
|
||||
const targetAuth = new FakeAuth();
|
||||
const { manager } = harness({
|
||||
auths: [sourceAuth, targetAuth],
|
||||
resolverOutcomes: [
|
||||
full('https://portal.test/server/load.php'),
|
||||
{
|
||||
...full('https://approved.test/server/load.php'),
|
||||
landingUrl: 'https://approved.test/c/',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const credentialChallenge = await manager.open(1, {
|
||||
descriptor: descriptor('credential-redirect', 'provisional'),
|
||||
});
|
||||
if (credentialChallenge.kind !== 'credentials-required') {
|
||||
throw new Error('expected-credentials-challenge');
|
||||
}
|
||||
|
||||
const originChallenge = await manager.continue(1, {
|
||||
challengeRef: credentialChallenge.challengeRef,
|
||||
response: {
|
||||
kind: 'credentials',
|
||||
password: 'candidate-password',
|
||||
username: 'candidate-user',
|
||||
},
|
||||
});
|
||||
if (originChallenge.kind !== 'origin-approval-required') {
|
||||
throw new Error('expected-origin-challenge');
|
||||
}
|
||||
|
||||
const ready = await manager.continue(1, {
|
||||
challengeRef: originChallenge.challengeRef,
|
||||
response: {
|
||||
approved: true,
|
||||
kind: 'origin-approval',
|
||||
},
|
||||
});
|
||||
|
||||
expectFullReady(ready);
|
||||
expect(targetAuth.startedCredentials).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it('restarts approved cross-origin discovery at the target without changing the persisted source', async () => {
|
||||
const originalSource = 'https://portal.test/customer/c/';
|
||||
const approvedLanding =
|
||||
|
||||
@@ -208,6 +208,7 @@ type AuthenticationProgress =
|
||||
| Extract<StalkerSessionConnectionOutcome, { kind: 'failure' }>;
|
||||
|
||||
interface AttemptRecord {
|
||||
acceptedCredentials?: StalkerAuthCredentials;
|
||||
approvedOrigins: Set<string>;
|
||||
auth?: StalkerSessionAuthLike;
|
||||
challengeRef?: string;
|
||||
@@ -1105,7 +1106,8 @@ export class StalkerSessionManager {
|
||||
}
|
||||
if (progress.kind === 'full-ready') {
|
||||
attempt.auth = progress.auth;
|
||||
attempt.credentialCandidate = progress.credentials;
|
||||
attempt.acceptedCredentials = progress.credentials;
|
||||
attempt.credentialCandidate = undefined;
|
||||
attempt.ready = this.#createFullAttemptReady(attempt, progress);
|
||||
attempt.state = 'ready';
|
||||
this.#refreshAttemptExpiry(attempt);
|
||||
@@ -1150,6 +1152,8 @@ export class StalkerSessionManager {
|
||||
);
|
||||
}
|
||||
if (progress.kind === 'stateless-mac') {
|
||||
attempt.acceptedCredentials = undefined;
|
||||
attempt.credentialCandidate = undefined;
|
||||
attempt.ready = {
|
||||
endpoint: progress.endpoint,
|
||||
kind: progress.kind,
|
||||
@@ -1172,6 +1176,8 @@ export class StalkerSessionManager {
|
||||
return outcome;
|
||||
}
|
||||
if (progress.kind === 'origin-approval-required') {
|
||||
attempt.acceptedCredentials = undefined;
|
||||
attempt.credentialCandidate = undefined;
|
||||
return this.#issueChallenge(
|
||||
attempt,
|
||||
{
|
||||
@@ -1188,9 +1194,8 @@ export class StalkerSessionManager {
|
||||
attempt.lastEndpoint = progress.endpoint;
|
||||
attempt.lastLandingUrl = progress.landingUrl;
|
||||
attempt.mutationEpoch = progress.epoch;
|
||||
if (progress.outcome.savedCredentialsRejected) {
|
||||
attempt.credentialCandidate = undefined;
|
||||
}
|
||||
attempt.acceptedCredentials = undefined;
|
||||
attempt.credentialCandidate = undefined;
|
||||
return this.#issueChallenge(
|
||||
attempt,
|
||||
{
|
||||
@@ -1258,9 +1263,9 @@ export class StalkerSessionManager {
|
||||
? {}
|
||||
: { accountSummary: progress.accountSummary }),
|
||||
auth: progress.auth,
|
||||
...(progress.credentials === undefined
|
||||
...(attempt.acceptedCredentials === undefined
|
||||
? {}
|
||||
: { credentials: progress.credentials }),
|
||||
: { credentials: attempt.acceptedCredentials }),
|
||||
endpoint: progress.endpoint,
|
||||
epoch: progress.epoch,
|
||||
identityRevision: progress.identityRevision,
|
||||
@@ -1323,6 +1328,8 @@ export class StalkerSessionManager {
|
||||
'stalker-session-promotion-revalidation-failed'
|
||||
);
|
||||
}
|
||||
attempt.acceptedCredentials = progress.credentials;
|
||||
attempt.credentialCandidate = undefined;
|
||||
ready = this.#createFullAttemptReady(
|
||||
attempt,
|
||||
progress,
|
||||
@@ -1522,6 +1529,7 @@ export class StalkerSessionManager {
|
||||
);
|
||||
this.#attempts.delete(attempt.ref);
|
||||
this.#rememberAttempt(attempt, action);
|
||||
attempt.acceptedCredentials = undefined;
|
||||
attempt.auth = undefined;
|
||||
attempt.credentialCandidate = undefined;
|
||||
attempt.ready = undefined;
|
||||
@@ -1552,6 +1560,7 @@ export class StalkerSessionManager {
|
||||
if (attempt.ready?.kind === 'full-session') {
|
||||
this.#playbackContexts?.invalidateLease(attempt.ready.leaseRef);
|
||||
}
|
||||
attempt.acceptedCredentials = undefined;
|
||||
attempt.auth = undefined;
|
||||
attempt.credentialCandidate = undefined;
|
||||
attempt.ready = undefined;
|
||||
|
||||
+113
@@ -62,6 +62,12 @@ function deferred<T>(): {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe('StalkerPortalImportComponent', () => {
|
||||
let component: StalkerPortalImportComponent;
|
||||
let session: {
|
||||
@@ -69,6 +75,7 @@ describe('StalkerPortalImportComponent', () => {
|
||||
continue: jest.Mock;
|
||||
commit: jest.Mock;
|
||||
discard: jest.Mock;
|
||||
close: jest.Mock;
|
||||
};
|
||||
let playlists: {
|
||||
persistStalkerConnection: jest.Mock;
|
||||
@@ -85,6 +92,11 @@ describe('StalkerPortalImportComponent', () => {
|
||||
kind: 'success',
|
||||
requestId: 'request-discard-1',
|
||||
}),
|
||||
close: jest.fn().mockResolvedValue({
|
||||
action: 'close',
|
||||
kind: 'success',
|
||||
requestId: 'request-close-1',
|
||||
}),
|
||||
};
|
||||
playlists = {
|
||||
persistStalkerConnection: jest.fn((playlist: Playlist) =>
|
||||
@@ -281,6 +293,89 @@ describe('StalkerPortalImportComponent', () => {
|
||||
expect(component.connectionStage()).toBe('connected');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['status-0 full session', READY_OUTCOME],
|
||||
[
|
||||
'stateless portal',
|
||||
{
|
||||
attemptRef: 'attempt-import-stateless',
|
||||
connectionMode: 'provisional',
|
||||
endpoint: 'https://portal.example/portal.php',
|
||||
kind: 'ready',
|
||||
landingUrl: 'https://portal.example/c/',
|
||||
persistenceDraft: {
|
||||
isFullStalkerPortal: false,
|
||||
portalUrl: 'https://portal.example/portal.php',
|
||||
stalkerLandingUrl: 'https://portal.example/c/',
|
||||
stalkerLastVerifiedAt: '2026-07-27T12:00:00.000Z',
|
||||
stalkerRecipeClassifierVersion: 1,
|
||||
stalkerRequestRecipe: 'stateless-mac',
|
||||
stalkerSourceUrl: 'https://portal.example/c/',
|
||||
},
|
||||
provisionalReason: 'import',
|
||||
recipe: 'stateless-mac',
|
||||
requestId: 'request-ready-stateless',
|
||||
} satisfies StalkerSessionConnectionOutcome,
|
||||
],
|
||||
])(
|
||||
'does not persist prefilled credential candidates for a ready %s',
|
||||
async (_label, outcome) => {
|
||||
component.form.patchValue({
|
||||
password: 'candidate-password',
|
||||
username: 'candidate-user',
|
||||
});
|
||||
session.open.mockResolvedValueOnce(outcome);
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
const persisted = playlists.persistStalkerConnection.mock
|
||||
.calls[0][0] as Playlist;
|
||||
expect(persisted.username).toBeUndefined();
|
||||
expect(persisted.password).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it('does not accept credentials when their continuation resolves as stateless', async () => {
|
||||
session.open.mockResolvedValueOnce({
|
||||
attemptNumber: 1,
|
||||
attemptRef: 'attempt-credentials-stateless',
|
||||
challengeRef: 'challenge-credentials-stateless',
|
||||
kind: 'credentials-required',
|
||||
requestId: 'request-credentials-stateless',
|
||||
});
|
||||
session.continue.mockResolvedValueOnce({
|
||||
attemptRef: 'attempt-credentials-stateless',
|
||||
connectionMode: 'provisional',
|
||||
endpoint: 'https://portal.example/portal.php',
|
||||
kind: 'ready',
|
||||
landingUrl: 'https://portal.example/c/',
|
||||
persistenceDraft: {
|
||||
isFullStalkerPortal: false,
|
||||
portalUrl: 'https://portal.example/portal.php',
|
||||
stalkerLandingUrl: 'https://portal.example/c/',
|
||||
stalkerLastVerifiedAt: '2026-07-27T12:00:00.000Z',
|
||||
stalkerRecipeClassifierVersion: 1,
|
||||
stalkerRequestRecipe: 'stateless-mac',
|
||||
stalkerSourceUrl: 'https://portal.example/c/',
|
||||
},
|
||||
provisionalReason: 'import',
|
||||
recipe: 'stateless-mac',
|
||||
requestId: 'request-ready-stateless',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
component.form.patchValue({
|
||||
password: 'candidate-password',
|
||||
username: 'candidate-user',
|
||||
});
|
||||
await component.submitCredentials();
|
||||
|
||||
const persisted = playlists.persistStalkerConnection.mock
|
||||
.calls[0][0] as Playlist;
|
||||
expect(persisted.username).toBeUndefined();
|
||||
expect(persisted.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retains form values after rejected credentials without overwriting storage', async () => {
|
||||
session.open.mockResolvedValueOnce({
|
||||
attemptNumber: 1,
|
||||
@@ -485,6 +580,24 @@ describe('StalkerPortalImportComponent', () => {
|
||||
expect(playlists.persistStalkerConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes a committed full-session lease when the form is cleared during commit', async () => {
|
||||
const commit = deferred<typeof CONTROL_SUCCESS>();
|
||||
session.commit.mockReturnValueOnce(commit.promise);
|
||||
|
||||
const add = component.addPlaylist();
|
||||
await flushPromises();
|
||||
expect(session.commit).toHaveBeenCalledWith('attempt-import-1');
|
||||
|
||||
component.clearForm();
|
||||
await Promise.resolve();
|
||||
commit.resolve(CONTROL_SUCCESS);
|
||||
await add;
|
||||
|
||||
expect(session.close).toHaveBeenCalledWith('lease-import-1');
|
||||
expect(component.connectionStage()).toBe('idle');
|
||||
expect(component.addClicked.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function setValidForm(): void {
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-1',
|
||||
|
||||
+31
-7
@@ -104,6 +104,7 @@ export class StalkerPortalImportComponent implements OnDestroy {
|
||||
private pendingAttemptRef: StalkerSessionAttemptRef | null = null;
|
||||
private pendingChallengeRef: StalkerSessionChallengeRef | null = null;
|
||||
private pendingReady: PendingReady | null = null;
|
||||
private acceptedCredentials: StalkerAcceptedCredentials | undefined;
|
||||
private credentialCandidate: StalkerAcceptedCredentials | undefined;
|
||||
private automaticCredentialsTried = false;
|
||||
private committed = false;
|
||||
@@ -233,6 +234,7 @@ export class StalkerPortalImportComponent implements OnDestroy {
|
||||
|
||||
const runId = this.runId;
|
||||
this.credentialCandidate = credentials;
|
||||
this.acceptedCredentials = undefined;
|
||||
this.automaticCredentialsTried = true;
|
||||
this.pendingChallengeRef = null;
|
||||
this.connectionStage.set('validating-credentials');
|
||||
@@ -245,6 +247,11 @@ export class StalkerPortalImportComponent implements OnDestroy {
|
||||
},
|
||||
});
|
||||
if (runId === this.runId) {
|
||||
this.acceptedCredentials =
|
||||
isStalkerReadyOutcome(outcome) &&
|
||||
outcome.recipe === 'full-session'
|
||||
? credentials
|
||||
: undefined;
|
||||
await this.handleOutcome(outcome, runId);
|
||||
} else {
|
||||
await this.discardStaleOutcome(outcome);
|
||||
@@ -269,7 +276,7 @@ export class StalkerPortalImportComponent implements OnDestroy {
|
||||
const playlist = applyStalkerReadyOutcome(
|
||||
provisional,
|
||||
outcome,
|
||||
this.credentialCandidate
|
||||
this.acceptedCredentials
|
||||
);
|
||||
const pending = { outcome, playlist };
|
||||
this.pendingAttemptRef = outcome.attemptRef ?? null;
|
||||
@@ -279,6 +286,8 @@ export class StalkerPortalImportComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
if (outcome.kind === 'origin-approval-required') {
|
||||
this.acceptedCredentials = undefined;
|
||||
this.credentialCandidate = undefined;
|
||||
this.pendingAttemptRef = outcome.attemptRef;
|
||||
this.pendingChallengeRef = outcome.challengeRef;
|
||||
this.pendingOriginApproval.set({
|
||||
@@ -290,18 +299,18 @@ export class StalkerPortalImportComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
if (outcome.kind === 'credentials-required') {
|
||||
this.acceptedCredentials = undefined;
|
||||
this.pendingAttemptRef = outcome.attemptRef;
|
||||
this.pendingChallengeRef = outcome.challengeRef;
|
||||
this.credentialsRequested.set(true);
|
||||
this.savedCredentialsRejected.set(
|
||||
outcome.savedCredentialsRejected === true
|
||||
);
|
||||
if (
|
||||
this.credentialCandidate !== undefined &&
|
||||
!this.automaticCredentialsTried
|
||||
) {
|
||||
const candidate = this.credentialCandidate;
|
||||
this.credentialCandidate = undefined;
|
||||
if (candidate !== undefined && !this.automaticCredentialsTried) {
|
||||
this.automaticCredentialsTried = true;
|
||||
await this.continueWithCredentials(this.credentialCandidate);
|
||||
await this.continueWithCredentials(candidate);
|
||||
return;
|
||||
}
|
||||
this.connectionStage.set('credentials-required');
|
||||
@@ -347,7 +356,7 @@ export class StalkerPortalImportComponent implements OnDestroy {
|
||||
}
|
||||
try {
|
||||
const promotion = await this.session.commit(attemptRef);
|
||||
if (runId !== this.runId || promotion.kind !== 'success') {
|
||||
if (promotion.kind !== 'success') {
|
||||
await this.failPromotion(runId);
|
||||
return;
|
||||
}
|
||||
@@ -355,6 +364,10 @@ export class StalkerPortalImportComponent implements OnDestroy {
|
||||
await this.failPromotion(runId);
|
||||
return;
|
||||
}
|
||||
if (runId !== this.runId) {
|
||||
await this.closeCommittedLease(pending.outcome);
|
||||
return;
|
||||
}
|
||||
|
||||
this.committed = true;
|
||||
this.pendingAttemptRef = null;
|
||||
@@ -370,6 +383,14 @@ export class StalkerPortalImportComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async closeCommittedLease(
|
||||
outcome: StalkerImportReadyOutcome
|
||||
): Promise<void> {
|
||||
if (outcome.recipe === 'full-session') {
|
||||
await this.session.close(outcome.leaseRef).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async discardPendingAttempt(): Promise<void> {
|
||||
const attemptRef =
|
||||
this.pendingReady?.outcome.attemptRef ??
|
||||
@@ -393,6 +414,8 @@ export class StalkerPortalImportComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private fail(reason: StalkerSessionFailureReason): void {
|
||||
this.acceptedCredentials = undefined;
|
||||
this.credentialCandidate = undefined;
|
||||
this.failureReason.set(reason);
|
||||
this.connectionStage.set('failure');
|
||||
this.notifyFailure(reason);
|
||||
@@ -414,6 +437,7 @@ export class StalkerPortalImportComponent implements OnDestroy {
|
||||
this.pendingAttemptRef = null;
|
||||
this.pendingChallengeRef = null;
|
||||
this.pendingReady = null;
|
||||
this.acceptedCredentials = undefined;
|
||||
this.credentialCandidate = undefined;
|
||||
this.automaticCredentialsTried = false;
|
||||
this.committed = false;
|
||||
|
||||
+180
-1
@@ -118,6 +118,11 @@ function createSessionMock() {
|
||||
let recoveryHandler: StalkerSessionRecoveryHandler | undefined;
|
||||
return {
|
||||
activate: jest.fn(),
|
||||
close: jest.fn().mockResolvedValue({
|
||||
action: 'close',
|
||||
kind: 'success',
|
||||
requestId: 'request-close-route-1',
|
||||
}),
|
||||
commit: jest.fn().mockResolvedValue(CONTROL_SUCCESS),
|
||||
continue: jest.fn(),
|
||||
discard: jest.fn().mockResolvedValue({
|
||||
@@ -243,10 +248,12 @@ describe('StalkerConnectionFlowService', () => {
|
||||
expect(connected).toEqual(persistedDraft);
|
||||
});
|
||||
|
||||
it('keeps an explicitly simple record and PWA behavior unchanged', async () => {
|
||||
it('keeps an explicitly stateless record and PWA behavior unchanged', async () => {
|
||||
const simple = {
|
||||
...LEGACY_PLAYLIST,
|
||||
isFullStalkerPortal: false,
|
||||
stalkerRecipeClassifierVersion: 1,
|
||||
stalkerRequestRecipe: 'stateless-mac' as const,
|
||||
};
|
||||
|
||||
await expect(service.ensureConnected(simple)).resolves.toBe(simple);
|
||||
@@ -258,6 +265,73 @@ describe('StalkerConnectionFlowService', () => {
|
||||
expect(session.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a full-session recipe override a stale false legacy boolean', async () => {
|
||||
session.open.mockResolvedValueOnce(READY);
|
||||
const playlist = {
|
||||
...LEGACY_PLAYLIST,
|
||||
isFullStalkerPortal: false,
|
||||
stalkerRecipeClassifierVersion: 1,
|
||||
stalkerRequestRecipe: 'full-session' as const,
|
||||
};
|
||||
|
||||
await expect(service.ensureConnected(playlist)).resolves.toMatchObject({
|
||||
stalkerRequestRecipe: 'full-session',
|
||||
});
|
||||
|
||||
expect(session.open).toHaveBeenCalledWith(playlist, {});
|
||||
});
|
||||
|
||||
it('provisionally reclassifies a full-session recipe from a stale classifier', async () => {
|
||||
session.open.mockResolvedValueOnce(READY);
|
||||
const playlist = {
|
||||
...LEGACY_PLAYLIST,
|
||||
isFullStalkerPortal: true,
|
||||
stalkerRecipeClassifierVersion: 0,
|
||||
stalkerRequestRecipe: 'full-session' as const,
|
||||
};
|
||||
|
||||
await service.ensureConnected(playlist);
|
||||
|
||||
expect(session.open).toHaveBeenCalledWith(playlist, {
|
||||
connectionMode: 'provisional',
|
||||
provisionalReason: 'migration',
|
||||
});
|
||||
});
|
||||
|
||||
it('provisionally reclassifies a stateless recipe from a stale classifier', async () => {
|
||||
session.open.mockResolvedValueOnce(READY);
|
||||
const playlist = {
|
||||
...LEGACY_PLAYLIST,
|
||||
isFullStalkerPortal: false,
|
||||
stalkerRecipeClassifierVersion: 0,
|
||||
stalkerRequestRecipe: 'stateless-mac' as const,
|
||||
};
|
||||
|
||||
await service.ensureConnected(playlist);
|
||||
|
||||
expect(session.open).toHaveBeenCalledWith(playlist, {
|
||||
connectionMode: 'provisional',
|
||||
provisionalReason: 'migration',
|
||||
});
|
||||
});
|
||||
|
||||
it('lazily classifies a recipe-less record regardless of its legacy boolean', async () => {
|
||||
session.open.mockResolvedValueOnce(READY);
|
||||
const playlist = {
|
||||
...LEGACY_PLAYLIST,
|
||||
isFullStalkerPortal: false,
|
||||
};
|
||||
|
||||
await expect(service.ensureConnected(playlist)).resolves.toMatchObject({
|
||||
stalkerRequestRecipe: 'full-session',
|
||||
});
|
||||
|
||||
expect(session.open).toHaveBeenCalledWith(playlist, {
|
||||
connectionMode: 'provisional',
|
||||
provisionalReason: 'migration',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows exact origin and credential challenges and persists only accepted credentials', async () => {
|
||||
const origin: StalkerSessionConnectionOutcome = {
|
||||
attemptRef: 'attempt-route-1',
|
||||
@@ -341,6 +415,92 @@ describe('StalkerConnectionFlowService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not persist credentials when an origin challenge intervenes before ready', async () => {
|
||||
const credentials: StalkerSessionConnectionOutcome = {
|
||||
attemptNumber: 1,
|
||||
attemptRef: 'attempt-credential-origin',
|
||||
challengeRef: 'challenge-credential-origin',
|
||||
kind: 'credentials-required',
|
||||
requestId: 'request-credential-origin',
|
||||
};
|
||||
const origin: StalkerSessionConnectionOutcome = {
|
||||
attemptRef: 'attempt-credential-origin',
|
||||
challengeRef: 'challenge-origin-after-credentials',
|
||||
finalOrigin: 'https://redirected.example',
|
||||
kind: 'origin-approval-required',
|
||||
requestId: 'request-origin-after-credentials',
|
||||
sourceOrigin: 'https://source.example',
|
||||
};
|
||||
session.open.mockResolvedValueOnce(credentials);
|
||||
session.continue
|
||||
.mockResolvedValueOnce(origin)
|
||||
.mockResolvedValueOnce(READY);
|
||||
queuedDialogResults.push(
|
||||
{
|
||||
password: 'candidate-password',
|
||||
username: 'candidate-user',
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
const connected = await service.ensureConnected(LEGACY_PLAYLIST);
|
||||
|
||||
expect(connected?.username).toBeUndefined();
|
||||
expect(connected?.password).toBeUndefined();
|
||||
const persisted = playlists.persistStalkerConnection.mock
|
||||
.calls[0][0] as Playlist;
|
||||
expect(persisted.username).toBeUndefined();
|
||||
expect(persisted.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it('invalidates saved credentials when open redirects before the target becomes ready', async () => {
|
||||
const origin: StalkerSessionConnectionOutcome = {
|
||||
attemptRef: 'attempt-saved-credential-origin',
|
||||
challengeRef: 'challenge-saved-credential-origin',
|
||||
finalOrigin: 'https://redirected.example',
|
||||
kind: 'origin-approval-required',
|
||||
requestId: 'request-saved-credential-origin',
|
||||
sourceOrigin: 'https://source.example',
|
||||
};
|
||||
session.open.mockResolvedValueOnce(origin);
|
||||
session.continue.mockResolvedValueOnce(READY);
|
||||
queuedDialogResults.push(true);
|
||||
|
||||
const connected = await service.ensureConnected({
|
||||
...LEGACY_PLAYLIST,
|
||||
password: 'saved-password',
|
||||
username: 'saved-user',
|
||||
});
|
||||
|
||||
expect(connected?.username).toBeUndefined();
|
||||
expect(connected?.password).toBeUndefined();
|
||||
const persisted = playlists.persistStalkerConnection.mock
|
||||
.calls[0][0] as Playlist;
|
||||
expect(persisted.username).toBeUndefined();
|
||||
expect(persisted.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it('discards the active credential challenge when the dialog is explicitly cancelled', async () => {
|
||||
const credentials: StalkerSessionConnectionOutcome = {
|
||||
attemptNumber: 1,
|
||||
attemptRef: 'attempt-credentials-cancelled',
|
||||
challengeRef: 'challenge-credentials-cancelled',
|
||||
kind: 'credentials-required',
|
||||
requestId: 'request-credentials-cancelled',
|
||||
};
|
||||
session.open.mockResolvedValueOnce(credentials);
|
||||
queuedDialogResults.push(null);
|
||||
|
||||
await expect(
|
||||
service.ensureConnected(LEGACY_PLAYLIST)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(session.discard).toHaveBeenCalledWith(
|
||||
'attempt-credentials-cancelled'
|
||||
);
|
||||
expect(playlists.persistStalkerConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('consumes a rejected origin challenge without persisting anything', async () => {
|
||||
const origin: StalkerSessionConnectionOutcome = {
|
||||
attemptRef: 'attempt-origin-rejected',
|
||||
@@ -409,6 +569,25 @@ describe('StalkerConnectionFlowService', () => {
|
||||
expect(session.discard).toHaveBeenCalledWith('attempt-route-1');
|
||||
});
|
||||
|
||||
it('closes a committed lease when cancellation wins during commit', async () => {
|
||||
const commit = createDeferred<typeof CONTROL_SUCCESS>();
|
||||
session.forceRedetect.mockResolvedValueOnce(READY);
|
||||
session.commit.mockReturnValueOnce(commit.promise);
|
||||
const emitted = jest.fn();
|
||||
service.connectionReady$.subscribe(emitted);
|
||||
|
||||
const connecting = service.forceRedetect(LEGACY_PLAYLIST);
|
||||
await flushPromises();
|
||||
expect(session.commit).toHaveBeenCalledWith('attempt-route-1');
|
||||
|
||||
await service.cancel();
|
||||
commit.resolve(CONTROL_SUCCESS);
|
||||
|
||||
await expect(connecting).resolves.toBeUndefined();
|
||||
expect(session.close).toHaveBeenCalledWith('lease-route-1');
|
||||
expect(emitted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reopens the persisted connection after discarding a rejected promotion', async () => {
|
||||
session.open
|
||||
.mockResolvedValueOnce(READY)
|
||||
|
||||
+49
-9
@@ -9,6 +9,7 @@ import {
|
||||
StalkerSessionRecoveryRequest,
|
||||
StalkerSessionService,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { STALKER_RECIPE_CLASSIFIER_VERSION } from '@iptvnator/portal/stalker/protocol';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import {
|
||||
Playlist,
|
||||
@@ -164,7 +165,11 @@ export class StalkerConnectionFlowService {
|
||||
announceReady: boolean
|
||||
): Promise<Playlist | undefined> {
|
||||
let outcome = initialOutcome;
|
||||
let credentials: StalkerCredentialsDialogResult | undefined;
|
||||
let acceptedCredentials:
|
||||
| StalkerCredentialsDialogResult
|
||||
| undefined;
|
||||
let credentialsInvalidated = false;
|
||||
let credentialsSubmitted = false;
|
||||
while (true) {
|
||||
if (await this.discardOutcomeWhenStale(outcome, runId)) {
|
||||
return undefined;
|
||||
@@ -175,7 +180,8 @@ export class StalkerConnectionFlowService {
|
||||
const draft = this.applyReadyOutcome(
|
||||
playlist,
|
||||
outcome,
|
||||
credentials
|
||||
acceptedCredentials,
|
||||
credentialsInvalidated || credentialsSubmitted
|
||||
);
|
||||
return this.persistAndCommit({
|
||||
announceReady,
|
||||
@@ -185,6 +191,8 @@ export class StalkerConnectionFlowService {
|
||||
});
|
||||
}
|
||||
if (outcome.kind === 'origin-approval-required') {
|
||||
acceptedCredentials = undefined;
|
||||
credentialsInvalidated = true;
|
||||
const approved = await this.requestOriginApproval(outcome);
|
||||
if (runId !== this.runId) {
|
||||
return undefined;
|
||||
@@ -205,13 +213,19 @@ export class StalkerConnectionFlowService {
|
||||
continue;
|
||||
}
|
||||
if (outcome.kind === 'credentials-required') {
|
||||
credentials = await this.requestCredentials(
|
||||
acceptedCredentials = undefined;
|
||||
const credentials = await this.requestCredentials(
|
||||
playlist,
|
||||
outcome.savedCredentialsRejected === true
|
||||
);
|
||||
if (runId !== this.runId || credentials === undefined) {
|
||||
if (runId !== this.runId) {
|
||||
return undefined;
|
||||
}
|
||||
if (credentials === undefined) {
|
||||
await this.discardPending();
|
||||
return undefined;
|
||||
}
|
||||
credentialsSubmitted = true;
|
||||
outcome = await this.session.continue(playlist._id, {
|
||||
challengeRef: outcome.challengeRef,
|
||||
response: {
|
||||
@@ -219,6 +233,8 @@ export class StalkerConnectionFlowService {
|
||||
...credentials,
|
||||
},
|
||||
});
|
||||
acceptedCredentials =
|
||||
outcome.kind === 'ready' ? credentials : undefined;
|
||||
continue;
|
||||
}
|
||||
return undefined;
|
||||
@@ -314,6 +330,10 @@ export class StalkerConnectionFlowService {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (pending.runId !== this.runId) {
|
||||
await this.closeCommittedLease(pending.outcome);
|
||||
return undefined;
|
||||
}
|
||||
this.activeAttemptRef = null;
|
||||
this.pendingPersistence = null;
|
||||
if (pending.announceReady) {
|
||||
@@ -382,18 +402,34 @@ export class StalkerConnectionFlowService {
|
||||
private applyReadyOutcome(
|
||||
playlist: Playlist,
|
||||
outcome: ReadyOutcome,
|
||||
credentials: StalkerCredentialsDialogResult | undefined
|
||||
acceptedCredentials: StalkerCredentialsDialogResult | undefined,
|
||||
clearUnacceptedCredentials: boolean
|
||||
): Playlist {
|
||||
const draft: Playlist = {
|
||||
...playlist,
|
||||
...outcome.persistenceDraft,
|
||||
portalUrl: outcome.persistenceDraft.portalUrl,
|
||||
...(credentials === undefined ? {} : credentials),
|
||||
...(acceptedCredentials === undefined
|
||||
? {}
|
||||
: acceptedCredentials),
|
||||
};
|
||||
if (
|
||||
outcome.recipe === 'stateless-mac' ||
|
||||
(clearUnacceptedCredentials && acceptedCredentials === undefined)
|
||||
) {
|
||||
delete draft.username;
|
||||
delete draft.password;
|
||||
}
|
||||
delete draft.stalkerToken;
|
||||
return draft;
|
||||
}
|
||||
|
||||
private async closeCommittedLease(outcome: ReadyOutcome): Promise<void> {
|
||||
if (outcome.recipe === 'full-session') {
|
||||
await this.session.close(outcome.leaseRef).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private offerPersistenceRetry(): void {
|
||||
const ref = this.snackBar.open(
|
||||
this.translate.instant(
|
||||
@@ -440,15 +476,19 @@ export class StalkerConnectionFlowService {
|
||||
private shouldUseTypedSession(playlist: Playlist): boolean {
|
||||
return (
|
||||
this.session.supportsTypedSessions() &&
|
||||
playlist.isFullStalkerPortal !== false &&
|
||||
playlist.stalkerRequestRecipe !== 'stateless-mac'
|
||||
!(
|
||||
playlist.stalkerRequestRecipe === 'stateless-mac' &&
|
||||
playlist.stalkerRecipeClassifierVersion ===
|
||||
STALKER_RECIPE_CLASSIFIER_VERSION
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private hasVerifiedRecipe(playlist: Playlist): boolean {
|
||||
return (
|
||||
playlist.stalkerRequestRecipe === 'full-session' &&
|
||||
typeof playlist.stalkerRecipeClassifierVersion === 'number'
|
||||
playlist.stalkerRecipeClassifierVersion ===
|
||||
STALKER_RECIPE_CLASSIFIER_VERSION
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user