게임 토큰 회수를 Redis에 유지하고 재시작과 알림 누락을 방어한다
This commit is contained in:
@@ -1242,6 +1242,30 @@ describe('gateway auth flow', () => {
|
||||
expect(validated?.user.username).toBe('tester');
|
||||
});
|
||||
|
||||
it('starts token age before the user snapshot so a concurrent role flush can revoke stale identity', async () => {
|
||||
const { caller, users, sessions } = buildCaller();
|
||||
const user = await users.createUser({ username: 'role-snapshot-race', password: 'synthetic-password' });
|
||||
const parent = await sessions.createSession(user);
|
||||
const issuedAt = new Date().toISOString();
|
||||
vi.useFakeTimers({ now: new Date(issuedAt) });
|
||||
const original = users.findById.bind(users);
|
||||
const find = vi.spyOn(users, 'findById').mockImplementationOnce(async (id) => {
|
||||
const snapshot = await original(id);
|
||||
vi.advanceTimersByTime(5000); // admin mutation/flush arrives after the snapshot.
|
||||
return snapshot;
|
||||
});
|
||||
try {
|
||||
const result = await caller.auth.issueGameSession({
|
||||
sessionToken: parent.sessionToken,
|
||||
profile: 'che:default',
|
||||
});
|
||||
expect(decryptGameSessionToken(result.gameToken, 'test-secret')?.issuedAt).toBe(issuedAt);
|
||||
} finally {
|
||||
find.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('retains the original issuance instant when logout flush overlaps later identity reads', async () => {
|
||||
const { caller, users, sessions } = buildCaller();
|
||||
const user = await users.createUser({ username: 'issue-flush-race', password: 'synthetic-password' });
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createClient } from 'redis';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildSessionRevocationBaselineKey,
|
||||
buildUserSessionRevocationKey,
|
||||
} from '@sammo-ts/common/auth/sessionRevocation';
|
||||
import { RedisGatewayFlushPublisher } from '../src/auth/flushPublisher.js';
|
||||
|
||||
const url = process.env.GATEWAY_SESSION_REDIS_TEST_URL;
|
||||
describe.skipIf(!url)('persistent Gateway flush over real Redis', () => {
|
||||
const client = createClient({ url });
|
||||
const channel = `revocation-publisher-test:${randomUUID()}:flush`;
|
||||
const publisher = new RedisGatewayFlushPublisher(client, channel);
|
||||
beforeAll(async () => {
|
||||
await client.connect();
|
||||
});
|
||||
afterAll(async () => {
|
||||
for await (const keys of client.scanIterator({ MATCH: `${channel}:*`, COUNT: 100 }))
|
||||
if (keys.length) await client.del(keys);
|
||||
await client.quit();
|
||||
});
|
||||
it('initializes once and preserves the original cutoff across publisher restarts', async () => {
|
||||
await publisher.initialize();
|
||||
const baseline = await client.get(buildSessionRevocationBaselineKey(channel));
|
||||
expect(Number(baseline)).toBeGreaterThan(0);
|
||||
await new RedisGatewayFlushPublisher(client, channel).initialize();
|
||||
expect(await client.get(buildSessionRevocationBaselineKey(channel))).toBe(baseline);
|
||||
expect(await client.pTTL(buildSessionRevocationBaselineKey(channel))).toBe(-1);
|
||||
});
|
||||
it('persists a revocation when there are zero subscribers', async () => {
|
||||
const userId = randomUUID();
|
||||
await publisher.publishUserFlush(userId, 'logout');
|
||||
expect(Number(await client.get(buildUserSessionRevocationKey(channel, userId)))).toBeGreaterThan(0);
|
||||
expect(await client.pTTL(buildUserSessionRevocationKey(channel, userId))).toBe(-1);
|
||||
});
|
||||
it('makes the watermark readable before delivering flush metadata', async () => {
|
||||
const subscriber = client.duplicate();
|
||||
await subscriber.connect();
|
||||
const userId = randomUUID();
|
||||
let delivered: { event: Record<string, unknown>; persisted: string | null } | undefined;
|
||||
await subscriber.subscribe(channel, async (raw) => {
|
||||
delivered = {
|
||||
event: JSON.parse(raw) as Record<string, unknown>,
|
||||
persisted: await client.get(buildUserSessionRevocationKey(channel, userId)),
|
||||
};
|
||||
});
|
||||
try {
|
||||
await publisher.publishUserFlush(userId, 'identity-updated', {
|
||||
displayName: 'fixture',
|
||||
identityRevision: 'revision-1',
|
||||
});
|
||||
await vi.waitFor(() => expect(delivered).toBeDefined());
|
||||
expect(delivered!.event).toMatchObject({
|
||||
userId,
|
||||
reason: 'identity-updated',
|
||||
displayName: 'fixture',
|
||||
identityRevision: 'revision-1',
|
||||
});
|
||||
expect(Number(delivered!.persisted)).toBe(Date.parse(delivered!.event.flushedAt as string));
|
||||
} finally {
|
||||
await subscriber.unsubscribe(channel);
|
||||
await subscriber.quit();
|
||||
}
|
||||
});
|
||||
it('never moves a newer watermark backwards under late or concurrent flushes', async () => {
|
||||
const userId = randomUUID();
|
||||
const future = String(Date.now() + 60_000);
|
||||
await client.set(buildUserSessionRevocationKey(channel, userId), future);
|
||||
await Promise.all(Array.from({ length: 8 }, () => publisher.publishUserFlush(userId, 'concurrent')));
|
||||
expect(await client.get(buildUserSessionRevocationKey(channel, userId))).toBe(future);
|
||||
});
|
||||
it('fails closed for an unavailable client instead of publishing without storage', async () => {
|
||||
const offline = client.duplicate();
|
||||
const closed = new RedisGatewayFlushPublisher(offline, channel);
|
||||
await expect(closed.initialize()).rejects.toThrow('unavailable');
|
||||
await expect(closed.publishUserFlush(randomUUID(), 'logout')).rejects.toThrow('unavailable');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user