revision 확인과 서버 event 기반 선택 갱신은 무가점으로 두고 PostgreSQL projection을 다시 만드는 초기·수동·복구 갱신만 1점을 기록한다. 인증 범위에 묶인 1회용 realtime 증표와 회귀 검증을 추가한다.
83 lines
3.7 KiB
TypeScript
83 lines
3.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
|
|
|
import {
|
|
consumeRealtimeAccessGrantHeader,
|
|
createRealtimeAccessGrant,
|
|
REALTIME_ACCESS_GRANT_TTL_MS,
|
|
registerRealtimeAccessGrant,
|
|
verifyRealtimeAccessGrant,
|
|
verifyRealtimeAccessGrantHeader,
|
|
} from '../src/auth/realtimeAccessGrant.js';
|
|
|
|
const secret = 'realtime-access-grant-test-secret-with-enough-entropy';
|
|
const now = new Date('2026-08-17T10:00:00.000Z');
|
|
const auth: GameSessionTokenPayload = {
|
|
version: 1,
|
|
profile: 'hwe:default',
|
|
issuedAt: '2026-08-17T09:00:00.000Z',
|
|
expiresAt: '2026-08-17T11:00:00.000Z',
|
|
sessionId: 'session-private-value',
|
|
user: {
|
|
id: 'user-private-value',
|
|
username: 'grant-user',
|
|
displayName: '갱신 사용자',
|
|
roles: ['user'],
|
|
},
|
|
sanctions: {},
|
|
};
|
|
|
|
describe('realtime access grant', () => {
|
|
it('binds an opaque short-lived grant to the authenticated session and profile', () => {
|
|
const grant = createRealtimeAccessGrant(auth, 'hwe:default', secret, now);
|
|
|
|
expect(grant).not.toContain(auth.user.id);
|
|
expect(grant).not.toContain(auth.sessionId);
|
|
expect(grant).not.toContain('hwe:default');
|
|
expect(verifyRealtimeAccessGrant(grant, auth, 'hwe:default', secret, now)).toBe(true);
|
|
expect(verifyRealtimeAccessGrantHeader([grant], auth, 'hwe:default', secret, now)).toBe(true);
|
|
expect(
|
|
verifyRealtimeAccessGrant(grant, { ...auth, sessionId: 'another-session' }, 'hwe:default', secret, now)
|
|
).toBe(false);
|
|
expect(verifyRealtimeAccessGrant(grant, auth, 'che:default', secret, now)).toBe(false);
|
|
});
|
|
|
|
it('rejects expired, tampered, unauthenticated, and malformed grants', () => {
|
|
const grant = createRealtimeAccessGrant(auth, 'hwe:default', secret, now);
|
|
const atExpiry = new Date(now.getTime() + REALTIME_ACCESS_GRANT_TTL_MS);
|
|
const afterExpiry = new Date(now.getTime() + REALTIME_ACCESS_GRANT_TTL_MS + 1);
|
|
const grantParts = grant.split('.');
|
|
const encryptedPart = grantParts[1] ?? '';
|
|
grantParts[1] = `${encryptedPart.startsWith('A') ? 'B' : 'A'}${encryptedPart.slice(1)}`;
|
|
const tampered = grantParts.join('.');
|
|
|
|
expect(verifyRealtimeAccessGrant(grant, auth, 'hwe:default', secret, atExpiry)).toBe(false);
|
|
expect(verifyRealtimeAccessGrant(grant, auth, 'hwe:default', secret, afterExpiry)).toBe(false);
|
|
expect(verifyRealtimeAccessGrant(tampered, auth, 'hwe:default', secret, now)).toBe(false);
|
|
expect(verifyRealtimeAccessGrant(grant, null, 'hwe:default', secret, now)).toBe(false);
|
|
expect(verifyRealtimeAccessGrant('not-a-grant', auth, 'hwe:default', secret, now)).toBe(false);
|
|
});
|
|
|
|
it('registers a grant in Redis and consumes it exactly once', async () => {
|
|
const values = new Set<string>();
|
|
const redis = {
|
|
set: async (key: string) => {
|
|
if (values.has(key)) return null;
|
|
values.add(key);
|
|
return 'OK';
|
|
},
|
|
eval: async (_script: string, options: { keys: string[] }) =>
|
|
values.delete(options.keys[0] ?? '') ? 1 : 0,
|
|
};
|
|
const grant = createRealtimeAccessGrant(auth, 'hwe:default', secret, now);
|
|
|
|
await expect(registerRealtimeAccessGrant(redis, grant, 'hwe:default')).resolves.toBe(true);
|
|
await expect(consumeRealtimeAccessGrantHeader(redis, grant, auth, 'hwe:default', secret, now)).resolves.toBe(
|
|
true
|
|
);
|
|
await expect(consumeRealtimeAccessGrantHeader(redis, grant, auth, 'hwe:default', secret, now)).resolves.toBe(
|
|
false
|
|
);
|
|
});
|
|
});
|