fix(stalker): redetect session recipes atomically

This commit is contained in:
4gray committed 2026-07-27 12:05:43 +02:00
1 parent 91d116a572
commit 9988e506de
6 files changed
+234 -42

No files matched your search

@@ -563,10 +563,15 @@ describe('StalkerSessionManager', () => {
})
).resolves.toMatchObject({ action: 'commit', kind: 'success' });
await manager.request(1, catalogRequest(existing.leaseRef));
await expect(
manager.request(1, catalogRequest(existing.leaseRef))
).resolves.toMatchObject({
kind: 'failure',
reason: STALKER_SESSION_FAILURE_REASONS.InvalidIdentityInput,
});
await manager.request(1, catalogRequest(provisional.leaseRef));
expect(oldAuth.requests).toHaveLength(0);
expect(promotedAuth.requests).toHaveLength(2);
expect(promotedAuth.requests).toHaveLength(1);
});
it('revalidates a stale provisional principal before promotion', async () => {
@@ -1414,4 +1419,58 @@ describe('StalkerSessionManager', () => {
})
).resolves.toMatchObject({ action: 'commit', kind: 'success' });
});
it('force-redetects from the original source without a learned hint and can commit a stateless reclassification', async () => {
const { manager, resolve } = harness({
auths: [new FakeAuth()],
resolverOutcomes: [full(), stateless()],
});
const originalSource = 'https://portal.test/original/c/';
const opened = await manager.open(1, {
descriptor: descriptor('redetect', 'persisted-open', {
learnedEndpointHint:
'https://portal.test/stale/server/load.php',
sourceUrl: originalSource,
}),
});
expectFullReady(opened);
const redetected = await manager.control(1, {
action: 'force-redetect',
leaseRef: opened.leaseRef,
});
expect(redetected).toMatchObject({
attemptRef: expect.any(String),
connectionMode: 'provisional',
kind: 'ready',
recipe: 'stateless-mac',
});
if (
redetected.kind !== 'ready' ||
redetected.connectionMode !== 'provisional'
) {
throw new Error('expected-provisional-redetection');
}
expect(resolve.mock.calls[1][0].descriptor).toMatchObject({
connectionMode: 'provisional',
sourceUrl: originalSource,
});
expect(resolve.mock.calls[1][0].descriptor.learnedEndpointHint).toBe(
undefined
);
await expect(
manager.control(1, {
action: 'commit',
attemptRef: redetected.attemptRef,
})
).resolves.toMatchObject({ action: 'commit', kind: 'success' });
await expect(
manager.request(1, catalogRequest(opened.leaseRef))
).resolves.toMatchObject({
kind: 'failure',
reason: STALKER_SESSION_FAILURE_REASONS.InvalidIdentityInput,
});
});
});
@@ -789,16 +789,7 @@ export class StalkerSessionManager {
return controlSuccess(requestId, request.action);
}
if (request.action === 'force-redetect') {
const refreshed = await this.#refreshSession(lease.session);
if (refreshed.kind === 'ready') {
return controlSuccess(requestId, request.action);
}
return this.#projectRefreshFailure(
senderId,
lease.session,
refreshed,
requestId
);
return this.#forceRedetect(senderId, lease, requestId);
}
return invalidIdentityFailure(requestId, 'ready');
}
@@ -1303,6 +1294,7 @@ export class StalkerSessionManager {
throw new Error('stalker-session-promotion-became-stale');
}
const replacedLeaseRefs = this.#previousPlaylistLeaseRefs(attempt);
const destination = this.#sessions.get(ready.key);
const promoted: SessionRecord = {
...(ready.accountSummary === undefined
@@ -1370,6 +1362,12 @@ export class StalkerSessionManager {
this.#leases.set(lease.ref, lease);
this.#sessions.set(promoted.key, promoted);
this.#reactivateTransferredLeases(promoted);
for (const replacedLeaseRef of replacedLeaseRefs) {
const replacedLease = this.#leases.get(replacedLeaseRef);
if (replacedLease !== undefined && replacedLease !== lease) {
this.#closeLease(replacedLease);
}
}
return { ref: lease.ref, session: promoted };
}
@@ -1399,6 +1397,7 @@ export class StalkerSessionManager {
return invalidIdentityFailure(requestId, 'promoting');
}
if (attempt.ready.kind === 'stateless-mac') {
this.#closePreviousPlaylistLeases(attempt);
this.#finishAttempt(attempt, action);
return controlSuccess(requestId, action);
}
@@ -1419,6 +1418,58 @@ export class StalkerSessionManager {
return controlSuccess(requestId, action);
}
async #forceRedetect(
senderId: number,
lease: LeaseRecord,
requestId: string
): Promise<StalkerSessionConnectionOutcome> {
const { learnedEndpointHint: _learnedEndpointHint, ...persisted } =
lease.session.descriptor;
const descriptor: StalkerSessionConnectionDescriptor = {
...persisted,
connectionMode: 'provisional',
provisionalReason: 'migration',
};
const attempt = this.#createAttempt(senderId, {
descriptor,
transport: lease.session.transport,
});
attempt.previousSessions.add(lease.session);
attempt.credentialCandidate = lease.session.credentials;
this.#attempts.set(attempt.ref, attempt);
this.#scheduleExpiryCleanup();
const progress = await this.#authenticateFresh(
attempt,
attempt.credentialCandidate
);
return this.#publishProgress(attempt, progress, requestId);
}
#closePreviousPlaylistLeases(attempt: AttemptRecord): void {
for (const ref of this.#previousPlaylistLeaseRefs(attempt)) {
const lease = this.#leases.get(ref);
if (lease !== undefined) {
this.#closeLease(lease);
}
}
}
#previousPlaylistLeaseRefs(attempt: AttemptRecord): Set<string> {
const refs = new Set<string>();
for (const previous of attempt.previousSessions) {
for (const ref of previous.leases) {
const lease = this.#leases.get(ref);
if (
lease?.senderId === attempt.senderId &&
lease.playlistRef === attempt.descriptor.playlistRef
) {
refs.add(ref);
}
}
}
return refs;
}
#finishAttempt(attempt: AttemptRecord, action: 'commit' | 'discard'): void {
this.#challengeRegistry.invalidateAttempt(
attempt.senderId,
@@ -62,8 +62,7 @@ describe('mapPlaylistToStalkerSessionConnection', () => {
expect(result).toEqual({
descriptor: {
connectionMode: 'persisted-open',
learnedEndpointHint:
'https://portal.example/server/load.php',
learnedEndpointHint: 'https://portal.example/server/load.php',
macAddress: '00:1A:79:AA:BB:CC',
playlistRef: 'playlist-1',
profilePreset: {
@@ -194,6 +193,25 @@ describe('mapPlaylistToStalkerSessionConnection', () => {
expect(result.descriptor).not.toHaveProperty('credentials');
});
it('does not auto-submit a legacy username when its password is missing or empty', () => {
expect(
mapPlaylistToStalkerSessionConnection(
playlist({
password: undefined,
username: ' exact user ',
})
).savedCredentials
).toBeUndefined();
expect(
mapPlaylistToStalkerSessionConnection(
playlist({
password: '',
username: ' exact user ',
})
).savedCredentials
).toBeUndefined();
});
it('never reads the legacy stalkerToken into the new mapping', () => {
const result = mapPlaylistToStalkerSessionConnection(
playlist({ stalkerToken: 'legacy-token-secret' })
@@ -203,7 +221,11 @@ describe('mapPlaylistToStalkerSessionConnection', () => {
});
it.each([
playlist({ stalkerSourceUrl: undefined, portalUrl: undefined, url: '' }),
playlist({
stalkerSourceUrl: undefined,
portalUrl: undefined,
url: '',
}),
playlist({ macAddress: undefined }),
playlist({ _id: '' }),
])('rejects an incomplete playlist mapping', (invalidPlaylist) => {
@@ -136,11 +136,16 @@ function copyPresentStringValues<T extends object>(source: T): T | undefined {
function mapSavedCredentials(
playlist: Playlist
): StalkerSavedCredentials | undefined {
if (playlist.username === undefined || playlist.username.length === 0) {
if (
playlist.username === undefined ||
playlist.username.length === 0 ||
playlist.password === undefined ||
playlist.password.length === 0
) {
return undefined;
}
return {
password: playlist.password ?? '',
password: playlist.password,
username: playlist.username,
};
}
@@ -59,6 +59,24 @@ const PROVISIONAL_READY: StalkerSessionConnectionOutcome = {
requestId: 'request-provisional-1',
};
const PROVISIONAL_STATELESS_READY: StalkerSessionConnectionOutcome = {
attemptRef: 'opaque-attempt-stateless-1',
capabilities: {
authenticatedSession: false,
playbackContext: false,
},
connectionMode: 'provisional',
kind: 'ready',
persistenceDraft: {
isFullStalkerPortal: false,
stalkerRecipeClassifierVersion: 1,
stalkerRequestRecipe: 'stateless-mac',
},
provisionalReason: 'migration',
recipe: 'stateless-mac',
requestId: 'request-provisional-stateless-1',
};
const RECONNECTED_READY: StalkerSessionConnectionOutcome = {
...READY,
leaseRef: 'opaque-lease-2',
@@ -321,6 +339,30 @@ describe('StalkerSessionService', () => {
}
);
it('forgets the previous full-session lease after committing a stateless redetection', async () => {
bridge.stalkerSessionOpen.mockResolvedValue(READY);
bridge.stalkerSessionControl
.mockResolvedValueOnce(PROVISIONAL_STATELESS_READY)
.mockResolvedValueOnce({
action: 'commit',
kind: 'success',
requestId: 'request-commit-stateless-1',
});
await service.open(PLAYLIST);
await expect(service.forceRedetect(READY.leaseRef)).resolves.toBe(
PROVISIONAL_STATELESS_READY
);
await expect(
service.commit(PROVISIONAL_STATELESS_READY.attemptRef)
).resolves.toMatchObject({
action: 'commit',
kind: 'success',
});
expect(service.getLeaseRef(PLAYLIST._id)).toBeUndefined();
});
it('adapts a current raw application call to a typed lease request and maps the result back to the legacy response shape', async () => {
bridge.stalkerSessionOpen.mockResolvedValue(READY);
bridge.stalkerSessionRequest.mockResolvedValue({
@@ -429,8 +471,7 @@ describe('StalkerSessionService', () => {
expect(bridge.stalkerSessionRequest).toHaveBeenCalledTimes(2);
expect(bridge.stalkerSessionRequest.mock.calls[1][0]).toMatchObject({
leaseRef: 'opaque-lease-2',
operation:
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
});
});
@@ -510,6 +551,7 @@ describe('StalkerSessionService', () => {
it('lets the route continue a first-open credential challenge before the application request', async () => {
const challenge: StalkerSessionConnectionOutcome = {
attemptNumber: 1,
attemptRef: 'opaque-attempt-route-1',
challengeRef: 'opaque-challenge-route-1',
kind: 'credentials-required',
requestId: 'request-open-challenge',
@@ -519,8 +561,7 @@ describe('StalkerSessionService', () => {
bridge.stalkerSessionContinue.mockResolvedValue(READY);
bridge.stalkerSessionRequest.mockResolvedValue({
kind: 'success',
operation:
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
payload: { items: [] },
requestId: 'request-after-route-recovery',
});
@@ -562,6 +603,7 @@ describe('StalkerSessionService', () => {
const failure: StalkerSessionConnectionOutcome = {
attemptNumber: 1,
attemptRef: 'opaque-attempt-active-handler',
challengeRef: 'opaque-challenge-active-handler',
kind: 'credentials-required',
requestId: 'request-active-handler',
@@ -96,7 +96,7 @@ export class StalkerSessionService {
private readonly provisionalByAttemptRef = new Map<
StalkerSessionAttemptRef,
{
readonly leaseRef: StalkerSessionLeaseRef;
readonly leaseRef?: StalkerSessionLeaseRef;
readonly playlistRef: string;
}
>();
@@ -249,7 +249,19 @@ export class StalkerSessionService {
request.action === 'commit' &&
provisional !== undefined
) {
this.rememberLease(provisional.playlistRef, provisional.leaseRef);
if (provisional.leaseRef === undefined) {
const previousLease = this.leaseByPlaylistRef.get(
provisional.playlistRef
);
if (previousLease !== undefined) {
this.forgetLease(previousLease);
}
} else {
this.rememberLease(
provisional.playlistRef,
provisional.leaseRef
);
}
this.forgetProvisionalAttempt(request.attemptRef);
} else if (outcome.kind === 'success' && request.action === 'discard') {
this.forgetProvisionalAttempt(request.attemptRef);
@@ -414,9 +426,7 @@ export class StalkerSessionService {
return recovery;
}
private isRecoverable(
outcome: StalkerSessionNonSuccessOutcome
): boolean {
private isRecoverable(outcome: StalkerSessionNonSuccessOutcome): boolean {
return (
outcome.kind === 'origin-approval-required' ||
outcome.kind === 'credentials-required' ||
@@ -432,25 +442,28 @@ export class StalkerSessionService {
if (outcome.kind !== 'ready') {
return;
}
if (outcome.recipe !== 'full-session') {
if (outcome.connectionMode === 'persisted-open') {
const previousLease = this.leaseByPlaylistRef.get(playlistRef);
if (previousLease !== undefined) {
this.forgetLease(previousLease);
}
if (outcome.connectionMode === 'provisional') {
this.provisionalByAttemptRef.set(outcome.attemptRef, {
...(outcome.recipe === 'full-session'
? { leaseRef: outcome.leaseRef }
: {}),
playlistRef,
});
if (outcome.recipe === 'full-session') {
this.attemptRefByProvisionalLease.set(
outcome.leaseRef,
outcome.attemptRef
);
}
return;
}
if (outcome.connectionMode === 'provisional') {
this.provisionalByAttemptRef.set(outcome.attemptRef, {
leaseRef: outcome.leaseRef,
playlistRef,
});
this.attemptRefByProvisionalLease.set(
outcome.leaseRef,
outcome.attemptRef
);
if (outcome.recipe !== 'full-session') {
const previousLease = this.leaseByPlaylistRef.get(playlistRef);
if (previousLease !== undefined) {
this.forgetLease(previousLease);
}
return;
}
@@ -488,7 +501,7 @@ export class StalkerSessionService {
attemptRef: StalkerSessionAttemptRef
): void {
const provisional = this.provisionalByAttemptRef.get(attemptRef);
if (provisional !== undefined) {
if (provisional?.leaseRef !== undefined) {
this.attemptRefByProvisionalLease.delete(provisional.leaseRef);
}
this.provisionalByAttemptRef.delete(attemptRef);