mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
fix(stalker): reject replaced late edit targets
This commit is contained in:
1 parent
e4bee6da40
commit
fabffcc902
10 files changed
+102
-17
No files matched your search
+4
@@ -669,6 +669,10 @@ describe('PlaylistInfoComponent', () => {
|
||||
stalkerDeviceId2: 'STORED-DEVICE-2',
|
||||
stalkerSignature1: 'STORED-SIGNATURE-1',
|
||||
stalkerSignature2: 'STORED-SIGNATURE-2',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
portalUrl: 'https://portal.example.com/c',
|
||||
stalkerSerialNumber: 'STORED-SERIAL',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
+2
-1
@@ -413,7 +413,8 @@ export class PlaylistInfoComponent {
|
||||
) {
|
||||
const result =
|
||||
await this.stalkerConnectionEditor.resolveConnection(
|
||||
normalizedPlaylist
|
||||
normalizedPlaylist,
|
||||
this.playlist
|
||||
);
|
||||
if (
|
||||
result.status !==
|
||||
|
||||
+2
-1
@@ -41,7 +41,8 @@ export interface StalkerResolvedConnectionApplyOptions {
|
||||
|
||||
export interface StalkerPlaylistConnectionEditor {
|
||||
resolveConnection(
|
||||
playlist: PlaylistMeta
|
||||
playlist: PlaylistMeta,
|
||||
sourcePlaylist?: PlaylistMeta
|
||||
): Promise<StalkerPlaylistConnectionResult>;
|
||||
/** Atomically persists a resolved edit, then synchronizes in-run state. */
|
||||
applyResolvedConnection(
|
||||
|
||||
@@ -341,7 +341,10 @@ describe('Stalker edited-session coordination', () => {
|
||||
stalkerWatchdogTimeout: 90,
|
||||
stalkerTimeslot: 5,
|
||||
} as Playlist;
|
||||
const fence = await service.beginEditDiscovery(editedPlaylist);
|
||||
const fence = await service.beginEditDiscovery(
|
||||
editedPlaylist,
|
||||
oldPlaylist
|
||||
);
|
||||
|
||||
const persisted = await service.replaceSessionAfterEdit(
|
||||
editedPlaylist,
|
||||
@@ -367,6 +370,35 @@ describe('Stalker edited-session coordination', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a late merge after another connection replaces the playlist ID', async () => {
|
||||
const replacementPlaylist = {
|
||||
...oldPlaylist,
|
||||
portalUrl: 'https://restored.example.com/portal.php',
|
||||
macAddress: '00:1A:79:11:22:33',
|
||||
} as Playlist;
|
||||
transformPlaylistMeta.mockImplementationOnce((_id, transform) =>
|
||||
of(transform(replacementPlaylist))
|
||||
);
|
||||
const editedPlaylist = {
|
||||
...oldPlaylist,
|
||||
portalUrl: 'https://new.example.com/server/load.php',
|
||||
stalkerToken: 'NEW_TOKEN',
|
||||
} as Playlist;
|
||||
const fence = await service.beginEditDiscovery(
|
||||
editedPlaylist,
|
||||
oldPlaylist
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.replaceSessionAfterEdit(editedPlaylist, fence, {
|
||||
preserveCurrentMetadata: true,
|
||||
})
|
||||
).rejects.toThrow(/could not be persisted/i);
|
||||
|
||||
expect(service.getCachedToken(oldPlaylist._id)).toBeNull();
|
||||
expect(updatePlaylistMeta).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not adopt a resolved full session when its atomic write fails', async () => {
|
||||
service.adoptDiscoveredSimplePortal(oldPlaylist);
|
||||
updatePlaylistMeta.mockReturnValueOnce(
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface StalkerEditFence {
|
||||
interface PendingEdit {
|
||||
readonly owner: symbol;
|
||||
readonly configurationFingerprint: string;
|
||||
readonly sourceConfigurationFingerprint: string;
|
||||
}
|
||||
|
||||
/** Serializes authoritative Edit results against pre-edit authentication. */
|
||||
@@ -87,8 +88,14 @@ export class StalkerEditedSessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
async beginEdit(playlist: Playlist): Promise<StalkerEditFence> {
|
||||
async beginEdit(
|
||||
playlist: Playlist,
|
||||
sourcePlaylist: Playlist = playlist
|
||||
): Promise<StalkerEditFence> {
|
||||
const playlistId = playlist._id;
|
||||
if (sourcePlaylist._id !== playlistId) {
|
||||
throw new Error('Stale Stalker playlist configuration');
|
||||
}
|
||||
if (this.pendingEdits.has(playlistId)) {
|
||||
throw new Error('Stalker playlist edit already in progress');
|
||||
}
|
||||
@@ -98,6 +105,8 @@ export class StalkerEditedSessionCoordinator {
|
||||
this.pendingEdits.set(playlistId, {
|
||||
owner: fence.owner,
|
||||
configurationFingerprint,
|
||||
sourceConfigurationFingerprint:
|
||||
stalkerConfigurationFingerprint(sourcePlaylist),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -132,6 +141,7 @@ export class StalkerEditedSessionCoordinator {
|
||||
sessionFingerprint
|
||||
);
|
||||
const owner = fence?.owner ?? Symbol('stalker-edit');
|
||||
const pending = this.pendingEdits.get(playlistId);
|
||||
if (
|
||||
fence &&
|
||||
(fence.playlistId !== playlistId ||
|
||||
@@ -141,11 +151,15 @@ export class StalkerEditedSessionCoordinator {
|
||||
new Error('Stale Stalker playlist configuration')
|
||||
);
|
||||
}
|
||||
const sourceConfigurationFingerprint =
|
||||
pending?.sourceConfigurationFingerprint ??
|
||||
stalkerConfigurationFingerprint(playlist);
|
||||
// Keep the same owner while discovery replaces its input-shaped
|
||||
// fingerprint with the resolved endpoint/mode fingerprint.
|
||||
this.pendingEdits.set(playlistId, {
|
||||
owner,
|
||||
configurationFingerprint,
|
||||
sourceConfigurationFingerprint,
|
||||
});
|
||||
const previous = this.replacements.get(playlistId) ?? Promise.resolve();
|
||||
const replacement = previous
|
||||
@@ -156,6 +170,7 @@ export class StalkerEditedSessionCoordinator {
|
||||
sessionFingerprint,
|
||||
configurationFingerprint,
|
||||
owner,
|
||||
sourceConfigurationFingerprint,
|
||||
options
|
||||
)
|
||||
);
|
||||
@@ -178,6 +193,7 @@ export class StalkerEditedSessionCoordinator {
|
||||
sessionFingerprint: string,
|
||||
configurationFingerprint: string,
|
||||
owner: symbol,
|
||||
sourceConfigurationFingerprint: string,
|
||||
options: { preserveCurrentMetadata?: boolean }
|
||||
): Promise<Playlist> {
|
||||
const playlistId = playlist._id;
|
||||
@@ -215,11 +231,14 @@ export class StalkerEditedSessionCoordinator {
|
||||
const persistedPlaylist = await firstValueFrom(
|
||||
options.preserveCurrentMetadata
|
||||
? playlists.transformPlaylistMeta(playlistId, (current) =>
|
||||
mergeResolvedStalkerConnection(
|
||||
current,
|
||||
playlist,
|
||||
sessionPatch
|
||||
)
|
||||
stalkerConfigurationFingerprint(current) ===
|
||||
sourceConfigurationFingerprint
|
||||
? mergeResolvedStalkerConnection(
|
||||
current,
|
||||
playlist,
|
||||
sessionPatch
|
||||
)
|
||||
: null
|
||||
)
|
||||
: playlists.updatePlaylistMeta({
|
||||
...playlist,
|
||||
|
||||
@@ -253,8 +253,11 @@ export class StalkerSessionService {
|
||||
* before discovery. The opaque fence keeps new authentication out until
|
||||
* the result is either cancelled or atomically persisted.
|
||||
*/
|
||||
beginEditDiscovery(playlist: Playlist): Promise<StalkerEditFence> {
|
||||
return this.editedSessions.beginEdit(playlist);
|
||||
beginEditDiscovery(
|
||||
playlist: Playlist,
|
||||
sourcePlaylist: Playlist = playlist
|
||||
): Promise<StalkerEditFence> {
|
||||
return this.editedSessions.beginEdit(playlist, sourcePlaylist);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user