dorfteich/apps/collab/src/auth.test.ts
Claude Fable 5 214e707102
All checks were successful
CI / Build container images (pull_request) Successful in 3m27s
CI / Auth e2e pack (pull_request) Successful in 7m51s
CI / Lint, typecheck, test (pull_request) Successful in 4m49s
CI / Import/export fidelity gate (pull_request) Successful in 1m1s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m54s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m51s
CI / Import/export fidelity gate (push) Successful in 54s
fix flaky tampered-token test: flip a significant signature character
The tampered-token case flipped the LAST base64url character of the
signature. Its low bits are padding that decoders ignore, so whenever a
signature ends in 'A' (~1/16 of tokens) the flip to 'B' decodes to the
same bytes and the token verifies — jose compares decoded bytes, unlike
the pre-#188 homegrown code that compared encoded strings. Reproduced
deterministically (20/20 A-ending signatures accepted the flip); CI run
477 and one local full-suite failure were this, not load. Flipping the
first character makes the tamper always significant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 09:40:05 +02:00

175 lines
5.5 KiB
TypeScript

import { HocuspocusProvider } from '@hocuspocus/provider';
import type { CollabTokenClaims } from '@dorfteich/shared';
import { signCollabToken } from '@dorfteich/shared/token-crypto';
import { pino } from 'pino';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { createCollabServer } from './server.js';
import { freePort } from './testing/free-port.js';
import { InMemoryPagePersistence } from './testing/fake-persistence.js';
const secret = 'integration-test-secret-32-chars!!';
const logger = pino({ enabled: false });
/** Poll `predicate` until it is true or the timeout elapses. */
async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error('timed out waiting for condition');
}
describe('collab authentication', () => {
let server: ReturnType<typeof createCollabServer>;
let url: string;
beforeAll(async () => {
server = createCollabServer({
version: 'test',
logger,
tokenSecret: secret,
pingDatabase: async () => ({ ok: true }),
persistence: new InMemoryPagePersistence(),
});
const port = await freePort();
await server.listen(port);
url = `ws://127.0.0.1:${port}`;
});
afterAll(async () => {
await server.destroy();
});
function connect(
name: string,
token: string,
): {
provider: HocuspocusProvider;
doc: Y.Doc;
authenticated: () => boolean;
failed: () => boolean;
synced: () => boolean;
} {
const doc = new Y.Doc();
let authenticated = false;
let failed = false;
let synced = false;
// No WebSocketPolyfill: the provider falls back to Node's global WebSocket.
const provider = new HocuspocusProvider({
url,
name,
token,
document: doc,
onAuthenticated: () => {
authenticated = true;
},
onAuthenticationFailed: () => {
failed = true;
},
onSynced: () => {
synced = true;
},
});
return {
provider,
doc,
authenticated: () => authenticated,
failed: () => failed,
synced: () => synced,
};
}
it('accepts a valid token whose page matches the document', async () => {
const token = await signCollabToken(
{ userId: 'u1', pageId: 'page-ok', mode: 'rw' },
secret,
60,
);
const client = connect('page-ok', token);
await waitFor(client.authenticated);
expect(client.authenticated()).toBe(true);
expect(client.failed()).toBe(false);
client.provider.destroy();
client.doc.destroy();
});
const claims: CollabTokenClaims = { userId: 'u1', pageId: 'page-x', mode: 'rw' };
const rejectionCases: { label: string; name: string; token: () => Promise<string> }[] = [
{
label: 'an expired token',
name: 'page-x',
token: () => signCollabToken(claims, secret, -60),
},
{
label: 'a tampered token',
name: 'page-x',
token: async () => {
const [h, p, s = ''] = (await signCollabToken(claims, secret, 60)).split('.');
// Flip the FIRST signature character — its bits are all significant.
// The last character is not: its low bits are base64url padding that
// decoders ignore, so a last-char flip of a signature ending in 'A'
// decodes to the same bytes and verifies (jose compares decoded
// bytes, unlike the pre-#188 code that compared encoded strings).
const flipped = (s.startsWith('A') ? 'B' : 'A') + s.slice(1);
return `${h}.${p}.${flipped}`;
},
},
{
label: 'a token minted for a different page',
name: 'page-y',
token: () => signCollabToken(claims, secret, 60),
},
{
label: 'a token signed with the wrong secret',
name: 'page-x',
token: () => signCollabToken(claims, 'a-completely-different-secret-32c', 60),
},
];
it.each(rejectionCases)('rejects $label', async ({ name, token }) => {
const client = connect(name, await token());
await waitFor(client.failed);
expect(client.failed()).toBe(true);
expect(client.authenticated()).toBe(false);
client.provider.destroy();
client.doc.destroy();
});
it('lets a read-only client receive updates but discards its writes server-side', async () => {
const page = 'page-shared';
const rw = connect(
page,
await signCollabToken({ userId: 'w', pageId: page, mode: 'rw' }, secret, 60),
);
const ro = connect(
page,
await signCollabToken({ userId: 'r', pageId: page, mode: 'ro' }, secret, 60),
);
await waitFor(() => rw.synced() && ro.synced());
const rwMap = rw.doc.getMap<string>('m');
const roMap = ro.doc.getMap<string>('m');
// A write from the read-write client reaches the read-only client.
rw.doc.transact(() => rwMap.set('fromRw', 'a'));
await waitFor(() => roMap.get('fromRw') === 'a');
// The read-only client writes; the server must drop it.
ro.doc.transact(() => roMap.set('fromRo', 'b'));
// Barrier: a second rw write round-trips to ro; by the time it arrives the
// server has already processed (and dropped) the ro write that preceded it.
rw.doc.transact(() => rwMap.set('barrier', 'c'));
await waitFor(() => roMap.get('barrier') === 'c');
expect(rwMap.get('fromRo')).toBeUndefined();
rw.provider.destroy();
ro.provider.destroy();
rw.doc.destroy();
ro.doc.destroy();
});
});