super-productivity/packages/super-sync-server/tests/auth-cache.spec.ts
Johannes Millan 2d9988dd73
perf(sync): SuperSync server speed + correctness hardening (#7621)
* docs(sync): add super sync server perf plan

* perf(sync): implement supersync server perf phases

* fix(sync): bracket auth cache invalidation

* fix(sync): avoid empty replay state stringify

* fix(sync): harden supersync batch uploads

* fix super sync review findings

* fix(sync): guard payload bytes backfill rollout

* perf(sync): speed up payload_bytes backfill and index its scan

Raise the backfill batch size (DEFAULT 5->500, MAX 25->1000) so a
100M-row operations table backfills in minutes rather than tens of
hours. Add a CONCURRENTLY partial index on (user_id, id) WHERE
payload_bytes = 0: it drains to empty post-backfill so the boot-time
backfill self-check and the BOOL_OR quota probe stop doing a full
sequential scan to prove absence, and it makes the backfill's per-user
keyset paging a true index seek. Wire the new concurrent-index
migration into both deploy scripts' P3018 recovery path. Add
migration-SQL guard tests for the ADD COLUMN (metadata-only fast path)
and the new partial index.

* fix(sync): bound auth cache invalidation map and bracket every delete

The auth verification cache's invalidationVersions map grew one entry
per lifetime-invalidated user with no eviction (unbounded heap on a
long-lived single replica). Cap it at the same 10k LRU bound as the
entries map, re-inserting the just-invalidated user at the MRU tail so
the CAS race protection still holds for the only window that matters
(one DB round trip). Bracket the passkey/magic-link registration
cleanup deletes with pre+post invalidate to match the documented
convention, and invalidate on verifyEmail so a freshly-verified user
isn't denied for up to the cache TTL.

* perf(sync): skip the redundant exact replay-state measurement

The delta accounting is a proven over-estimate of the serialized state
size, so when the running bound stays within the cap the true size is
too and the final exact JSON.stringify is provably redundant. Skip it
in that case (still measure-and-throw whenever the bound does not prove
safety). This collapses the common small/incremental replay back to
zero expensive full stringifications, matching the old per-op loop
instead of regressing it. Name the entity-key JSON overhead constant
and document that assertReplayStateSize's return value is load-bearing.

* refactor(sync): split processOperationBatch into pipeline stages

Extract the 297-line batch upload method into a thin orchestrator plus
six named single-responsibility stage helpers (validate+clamp, intra-
batch dedupe, classify existing duplicates, conflict-detect, reserve
seq + insert, full-state clock). Behavior-preserving: every stage
writes terminal rejections into the shared results array by index and
the two empty-set guards short-circuit exactly as before. Also share
the timestamp clamp, the duplicate-op SELECT, and the merged
full-state clock persistence between the batch and legacy paths so
they cannot silently diverge.

* test(sync): pin batch error-code divergence and aggregate-once

Strengthen the intra-batch duplicate test to assert same-id /
different-content yields DUPLICATE_OPERATION (deliberate divergence
from the legacy INVALID_OP_ID), and document the divergence in the
plan. Replace the single-full-state aggregate test with two
full-state ops + a spy asserting _aggregatePriorVectorClock runs
exactly once and last-write-wins — the old test could not catch a
per-op-aggregate regression. Add a makeOp fixture factory. Correct
the plan's overstated replay-stringification numbers.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: johannesjo <1456265+johannesjo@users.noreply.github.com>
2026-05-15 17:24:16 +02:00

150 lines
4.3 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as jwt from 'jsonwebtoken';
const jwtSecret = vi.hoisted(() => {
const secret = 'a'.repeat(32);
process.env.JWT_SECRET = secret;
return secret;
});
vi.mock('../src/auth', async (importOriginal) => {
return await importOriginal();
});
vi.mock('../src/logger', () => ({
Logger: {
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
},
}));
import { verifyToken, revokeAllTokens } from '../src/auth';
import { authCache } from '../src/auth-cache';
import { prisma } from '../src/db';
const createToken = (tokenVersion: number = 0): string =>
jwt.sign({ userId: 1, email: 'user@example.com', tokenVersion }, jwtSecret, {
expiresIn: '1h',
});
describe('auth verification cache', () => {
beforeEach(() => {
vi.clearAllMocks();
authCache.clear();
});
it('should reuse a warm verified-token cache entry', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: 1,
tokenVersion: 0,
isVerified: 1,
} as any);
const token = createToken();
await expect(verifyToken(token)).resolves.toEqual({
valid: true,
userId: 1,
email: 'user@example.com',
});
await expect(verifyToken(token)).resolves.toEqual({
valid: true,
userId: 1,
email: 'user@example.com',
});
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
});
it('should fall through to the database on tokenVersion mismatch', async () => {
authCache.set(1, 1, true);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: 1,
tokenVersion: 0,
isVerified: 1,
} as any);
await expect(verifyToken(createToken(0))).resolves.toEqual(
expect.objectContaining({ valid: true }),
);
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
});
it('should invalidate the cache when revoking all tokens', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: 1,
tokenVersion: 0,
isVerified: 1,
} as any);
const token = createToken();
await verifyToken(token);
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
vi.mocked(prisma.user.update).mockResolvedValue({} as any);
await revokeAllTokens(1);
vi.mocked(prisma.user.findUnique).mockClear();
await verifyToken(token);
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
});
it('should not re-cache a token when invalidation happens during verification', async () => {
const token = createToken(0);
let resolveFindUnique!: (value: {
id: number;
tokenVersion: number;
isVerified: number;
}) => void;
vi.mocked(prisma.user.findUnique)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFindUnique = resolve;
}) as ReturnType<typeof prisma.user.findUnique>,
)
.mockResolvedValueOnce({
id: 1,
tokenVersion: 1,
isVerified: 1,
} as any);
vi.mocked(prisma.user.update).mockResolvedValue({} as any);
const inFlightVerification = verifyToken(token);
await Promise.resolve();
await revokeAllTokens(1);
resolveFindUnique({ id: 1, tokenVersion: 0, isVerified: 1 });
await expect(inFlightVerification).resolves.toEqual(
expect.objectContaining({ valid: true }),
);
await expect(verifyToken(token)).resolves.toEqual({
valid: false,
reason: 'Token was revoked. Please log in again to get a new token.',
});
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
it('bounds invalidationVersions and keeps recent invalidations newest', () => {
// A long-ago invalidation must not pin heap forever.
authCache.invalidate(1);
expect(authCache.getInvalidationVersion(1)).toBe(1);
// Push >10k distinct invalidations so user 1 (the oldest) is evicted.
for (let userId = 2; userId <= 10_002; userId++) {
authCache.invalidate(userId);
}
// Evicted -> reverts to the default (0). Memory is bounded.
expect(authCache.getInvalidationVersion(1)).toBe(0);
// A freshly-invalidated user sits at the MRU tail and is retained, so the
// CAS race protection still holds for the window that matters.
expect(authCache.getInvalidationVersion(10_002)).toBe(1);
});
});