Files
rplace/test/lib/get-user-id.test.js
tiennm99 42d1ca19ee feat(canvas): cookie+IP rate-limit identity and broadcast sequence numbers
- resolveIdentity prefers an opaque rplace_id cookie; falls back to a
  cf-connecting-ip hash; in production a request with neither now returns
  500 no_identity instead of bucketing all such traffic together
- /api/canvas issues Set-Cookie when no cookie is present so subsequent
  requests escape NAT-shared IP buckets (mobile/CGNAT users)
- DO maintains an in-memory monotonic broadcast counter; broadcast frames
  carry { seq } so the client can detect missed pixels and refetch
- client tracks lastSeq, refetches on gap, resets on every (re)connect

NAT/CGNAT users previously shared a single 1Hz bucket per egress IP. With
cookie identity they each get their own bucket. Cookie is HttpOnly, Secure,
SameSite=Lax, 1y Max-Age. Stripped/cleared cookies fall through to IP.

The seq counter resets on DO hibernation rehydrate; client always refetches
on reconnect, so a reset is indistinguishable from a fresh connect.

Plan: plans/260510-0232-fix-do-migration-followups/phase-02-cookie-ip-identity.md
2026-05-10 03:00:39 +07:00

80 lines
3.1 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import { resolveIdentity, NoIdentityError } from '../../src/lib/get-user-id.js';
/** Helper to build a mock Request with selected headers. */
function mockRequest(headers = {}) {
return new Request('http://localhost', { headers: new Headers(headers) });
}
describe('resolveIdentity — cookie-first', () => {
it('returns cookie:<uuid> when rplace_id cookie is present', async () => {
const cookie = '12345678-1234-1234-1234-123456789abc';
const r = await resolveIdentity(mockRequest({ cookie: `rplace_id=${cookie}` }));
expect(r.id).toBe(`cookie:${cookie}`);
expect(r.mintCookieValue).toBeUndefined();
});
it('rejects malformed cookie values and falls back to IP', async () => {
const r = await resolveIdentity(mockRequest({
cookie: 'rplace_id=" OR 1=1 --"',
'cf-connecting-ip': '1.2.3.4',
}));
expect(r.id).toMatch(/^ip:[0-9a-f]{16}$/);
expect(r.mintCookieValue).toMatch(/^[0-9a-f-]{36}$/);
});
it('cookie wins over IP when both present', async () => {
const cookie = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
const r = await resolveIdentity(mockRequest({
cookie: `rplace_id=${cookie}`,
'cf-connecting-ip': '1.2.3.4',
}));
expect(r.id).toBe(`cookie:${cookie}`);
expect(r.mintCookieValue).toBeUndefined();
});
});
describe('resolveIdentity — IP fallback', () => {
it('returns ip:<hash> with mintCookieValue when only IP is present', async () => {
const r = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '1.2.3.4' }));
expect(r.id).toMatch(/^ip:[0-9a-f]{16}$/);
expect(r.mintCookieValue).toMatch(/^[0-9a-f-]{36}$/);
});
it('IP hash is deterministic per IP', async () => {
const a = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '192.168.1.1' }));
const b = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '192.168.1.1' }));
expect(a.id).toBe(b.id);
});
it('different IPs produce different hashes', async () => {
const a = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '1.1.1.1' }));
const b = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '2.2.2.2' }));
expect(a.id).not.toBe(b.id);
});
it('mintCookieValue is unique per call', async () => {
const a = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '1.2.3.4' }));
const b = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '1.2.3.4' }));
expect(a.mintCookieValue).not.toBe(b.mintCookieValue);
});
});
describe('resolveIdentity — no cookie, no IP', () => {
it('falls back to dev:shared in non-production env', async () => {
const r = await resolveIdentity(mockRequest({}), { ENVIRONMENT: 'development' });
expect(r.id).toBe('dev:shared');
expect(r.mintCookieValue).toMatch(/^[0-9a-f-]{36}$/);
});
it('falls back to dev:shared when env is undefined', async () => {
const r = await resolveIdentity(mockRequest({}));
expect(r.id).toBe('dev:shared');
});
it('throws NoIdentityError in production', async () => {
await expect(resolveIdentity(mockRequest({}), { ENVIRONMENT: 'production' }))
.rejects.toBeInstanceOf(NoIdentityError);
});
});