From c1f7306ed5107285ba00fcd6212436fabcfc9c94 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 12:08:04 +0200 Subject: [PATCH] fix(stalker): discard stale import attempts --- .../stalker-portal-import.component.spec.ts | 78 +++++++++++++++++++ .../stalker-portal-import.component.ts | 16 ++++ 2 files changed, 94 insertions(+) diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts index 0529d65e6..8d9737509 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- the import lifecycle matrix shares one state-machine harness */ import { TestBed } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; @@ -407,6 +408,83 @@ describe('StalkerPortalImportComponent', () => { expect(component.connectionStage()).toBe('idle'); }); + it('discards a ready attempt returned after the form was cleared during open', async () => { + const open = deferred(); + session.open.mockReturnValueOnce(open.promise); + + const add = component.addPlaylist(); + await Promise.resolve(); + component.clearForm(); + open.resolve(READY_OUTCOME); + await add; + + expect(session.discard).toHaveBeenCalledWith('attempt-import-1'); + expect(playlists.persistStalkerConnection).not.toHaveBeenCalled(); + expect(component.connectionStage()).toBe('idle'); + }); + + it('discards a challenge returned after the form was cleared during continuation', async () => { + session.open.mockResolvedValueOnce({ + attemptRef: 'attempt-origin-stale', + challengeRef: 'challenge-origin-stale', + finalOrigin: 'https://redirected.example', + kind: 'origin-approval-required', + requestId: 'request-origin-stale', + sourceOrigin: 'https://portal.example', + }); + const continuation = deferred(); + session.continue.mockReturnValueOnce(continuation.promise); + await component.addPlaylist(); + + const approval = component.respondToOriginApproval(true); + await Promise.resolve(); + component.clearForm(); + session.discard.mockClear(); + continuation.resolve({ + attemptNumber: 1, + attemptRef: 'attempt-origin-stale', + challengeRef: 'challenge-credentials-stale', + kind: 'credentials-required', + requestId: 'request-credentials-stale', + }); + await approval; + + expect(session.discard).toHaveBeenCalledWith('attempt-origin-stale'); + expect(component.connectionStage()).toBe('idle'); + }); + + it('discards a ready attempt returned after destruction during credential validation', async () => { + session.open.mockResolvedValueOnce({ + attemptNumber: 1, + attemptRef: 'attempt-credentials-stale', + challengeRef: 'challenge-credentials-stale', + kind: 'credentials-required', + requestId: 'request-credentials-stale', + }); + const continuation = deferred(); + session.continue.mockReturnValueOnce(continuation.promise); + await component.addPlaylist(); + component.form.patchValue({ + password: 'entered-password', + username: 'entered-user', + }); + + const submit = component.submitCredentials(); + await Promise.resolve(); + component.ngOnDestroy(); + session.discard.mockClear(); + continuation.resolve({ + ...READY_OUTCOME, + attemptRef: 'attempt-credentials-stale', + }); + await submit; + + expect(session.discard).toHaveBeenCalledWith( + 'attempt-credentials-stale' + ); + expect(playlists.persistStalkerConnection).not.toHaveBeenCalled(); + }); + function setValidForm(): void { component.form.patchValue({ _id: 'playlist-1', diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 73079e072..25ecf1875 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- the challenge-driven import state machine is kept in one lifecycle owner */ import { Component, OnDestroy, @@ -155,6 +156,7 @@ export class StalkerPortalImportComponent implements OnDestroy { provisionalReason: 'import', }); if (runId !== this.runId) { + await this.discardStaleOutcome(outcome); return; } await this.handleOutcome(outcome, runId); @@ -186,6 +188,8 @@ export class StalkerPortalImportComponent implements OnDestroy { }); if (runId === this.runId) { await this.handleOutcome(outcome, runId); + } else { + await this.discardStaleOutcome(outcome); } } catch { if (runId === this.runId) { @@ -242,6 +246,8 @@ export class StalkerPortalImportComponent implements OnDestroy { }); if (runId === this.runId) { await this.handleOutcome(outcome, runId); + } else { + await this.discardStaleOutcome(outcome); } } catch { if (runId === this.runId) { @@ -376,6 +382,16 @@ export class StalkerPortalImportComponent implements OnDestroy { } } + private async discardStaleOutcome( + outcome: StalkerSessionConnectionOutcome + ): Promise { + const attemptRef = + 'attemptRef' in outcome ? outcome.attemptRef : undefined; + if (attemptRef !== undefined) { + await this.session.discard(attemptRef).catch(() => undefined); + } + } + private fail(reason: StalkerSessionFailureReason): void { this.failureReason.set(reason); this.connectionStage.set('failure');