mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
fix(stalker): redetect session recipes atomically
This commit is contained in:
1 parent
91d116a572
commit
9988e506de
6 files changed
+234
-42
No files matched your search
+61
-2
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user