fix(realtime): redact browser event details
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
|
||||
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
|
||||
|
||||
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from '../src/realtime/publicEvent.js';
|
||||
|
||||
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const;
|
||||
|
||||
const turnEvent = (changes = createEmptyRealtimeReadModelChanges()): RealtimeEvent => ({
|
||||
type: 'turnCompleted',
|
||||
at: '2026-08-12T12:34:56.789Z',
|
||||
lastTurnTime: '0185-02-01T00:00:00.000Z',
|
||||
changes,
|
||||
revision: 42,
|
||||
});
|
||||
|
||||
describe('public realtime event privacy boundary', () => {
|
||||
it('suppresses clock-only and unrelated private general turns', () => {
|
||||
expect(toPublicRealtimeEvent(turnEvent(), [viewer])).toBeNull();
|
||||
expect(
|
||||
toPublicRealtimeEvent(
|
||||
turnEvent({
|
||||
...createEmptyRealtimeReadModelChanges(),
|
||||
generalIds: [99],
|
||||
}),
|
||||
[viewer]
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('publishes only viewer-specific boolean invalidations', () => {
|
||||
const publicEvent = toPublicRealtimeEvent(
|
||||
turnEvent({
|
||||
...createEmptyRealtimeReadModelChanges(),
|
||||
generalIds: [7, 99],
|
||||
reservedGeneralIds: [7],
|
||||
recordGeneralIds: [7],
|
||||
}),
|
||||
[viewer]
|
||||
);
|
||||
|
||||
expect(publicEvent).toEqual({
|
||||
type: 'readModelInvalidated',
|
||||
invalidation: {
|
||||
context: true,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: true,
|
||||
contacts: false,
|
||||
boardAccess: true,
|
||||
reservedTurns: true,
|
||||
records: true,
|
||||
frontStatus: false,
|
||||
},
|
||||
});
|
||||
const serialized = JSON.stringify(publicEvent);
|
||||
expect(publicEvent).not.toHaveProperty('at');
|
||||
expect(publicEvent).not.toHaveProperty('lastTurnTime');
|
||||
expect(publicEvent).not.toHaveProperty('revision');
|
||||
for (const forbidden of ['generalIds', 'cityIds', 'nationIds', '99']) {
|
||||
expect(serialized).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps global refresh meaning without exposing its source identity or time', () => {
|
||||
const publicEvent = toPublicRealtimeEvent(
|
||||
{
|
||||
type: 'readModelChanged',
|
||||
at: '2026-08-12T12:34:56.789Z',
|
||||
revision: 43,
|
||||
changes: {
|
||||
...createEmptyRealtimeReadModelChanges(),
|
||||
worldChanged: true,
|
||||
globalRecordsChanged: true,
|
||||
worldHistoryChanged: true,
|
||||
},
|
||||
},
|
||||
[viewer]
|
||||
);
|
||||
|
||||
expect(publicEvent).toMatchObject({
|
||||
type: 'readModelInvalidated',
|
||||
invalidation: { lobby: true, map: true, commands: true, records: true },
|
||||
});
|
||||
expect(JSON.stringify(publicEvent)).not.toMatch(/2026|0185|revision|Ids/u);
|
||||
});
|
||||
|
||||
it('uses a conservative identifier-free fallback for an older daemon', () => {
|
||||
expect(
|
||||
toPublicRealtimeEvent(
|
||||
{
|
||||
type: 'turnCompleted',
|
||||
at: '2026-08-12T12:34:56.789Z',
|
||||
lastTurnTime: '0185-02-01T00:00:00.000Z',
|
||||
},
|
||||
[viewer]
|
||||
)
|
||||
).toEqual({
|
||||
type: 'readModelInvalidated',
|
||||
invalidation: {
|
||||
context: true,
|
||||
lobby: true,
|
||||
map: true,
|
||||
commands: true,
|
||||
contacts: true,
|
||||
boardAccess: true,
|
||||
reservedTurns: true,
|
||||
records: true,
|
||||
frontStatus: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('filters message events per viewer and removes mailbox, sender, message, and time fields', () => {
|
||||
const event: RealtimeEvent = {
|
||||
type: 'messageCreated',
|
||||
at: '2026-08-12T12:34:56.789Z',
|
||||
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId,
|
||||
msgType: 'national',
|
||||
messageId: 123,
|
||||
senderId: 99,
|
||||
};
|
||||
|
||||
expect(toPublicRealtimeEvent(event, [viewer])).toEqual({ type: 'messagesInvalidated' });
|
||||
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
|
||||
});
|
||||
|
||||
it('requests an identity refresh only when the viewer general may have changed', () => {
|
||||
expect(
|
||||
shouldReloadRealtimeViewerIdentity(
|
||||
turnEvent({ ...createEmptyRealtimeReadModelChanges(), generalIds: [7] }),
|
||||
viewer
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldReloadRealtimeViewerIdentity(
|
||||
turnEvent({ ...createEmptyRealtimeReadModelChanges(), generalIds: [99] }),
|
||||
viewer
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('merges previous and committed identities across an ownership transition', () => {
|
||||
const event: RealtimeEvent = {
|
||||
type: 'readModelChanged',
|
||||
at: '2026-08-12T12:34:56.789Z',
|
||||
revision: 44,
|
||||
changes: {
|
||||
...createEmptyRealtimeReadModelChanges(),
|
||||
generalIds: [7],
|
||||
nationIds: [3],
|
||||
frontStatusNationIds: [3],
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
toPublicRealtimeEvent(event, [viewer, { generalId: 7, cityId: 4, nationId: 3 }])
|
||||
).toMatchObject({
|
||||
type: 'readModelInvalidated',
|
||||
invalidation: {
|
||||
context: true,
|
||||
commands: true,
|
||||
boardAccess: true,
|
||||
frontStatus: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user