From bc2b41be87635ef590c70bee4080ffa29716ca9b Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 19 Aug 2026 13:36:58 +0200 Subject: [PATCH] test --- .github/workflows/ci.yml | 5 +- README.md | 10 ++ ROADMAP.md | 27 +++- apps/app/package.json | 3 + apps/app/tests/crypto.test.ts | 148 ++++++++++++++++++ apps/app/tests/errors.test.ts | 30 ++++ apps/app/tests/invitations.test.ts | 98 ++++++++++++ apps/app/tests/mail-templates.test.ts | 119 +++++++++++++++ apps/app/tests/pastes.test.ts | 50 ++++++ apps/app/vitest.config.ts | 15 ++ package.json | 1 + pnpm-lock.yaml | 209 +++++++++++++++++++++++++- 12 files changed, 705 insertions(+), 10 deletions(-) create mode 100644 apps/app/tests/crypto.test.ts create mode 100644 apps/app/tests/errors.test.ts create mode 100644 apps/app/tests/invitations.test.ts create mode 100644 apps/app/tests/mail-templates.test.ts create mode 100644 apps/app/tests/pastes.test.ts create mode 100644 apps/app/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8266b7..c39e519 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ env: jobs: checks: - name: Lint, Typecheck, build, schema + name: Lint, Typecheck, test, build, schema runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -42,6 +42,9 @@ jobs: - name: Typecheck run: pnpm typecheck + - name: Test + run: pnpm test + - name: Build app run: pnpm build diff --git a/README.md b/README.md index b67b918..d301abb 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,16 @@ pnpm db:migrate pnpm dev ``` +Checks, all runnable without a database: + +```bash +pnpm lint +pnpm typecheck +pnpm test +``` + +`pnpm test` runs the unit suite (Vitest). Integration tests are on the [roadmap](ROADMAP.md). + `pnpm dev` and `pnpm build` route through [scripts/nuxt.mjs](scripts/nuxt.mjs), which injects secrets via the Infisical CLI when it is installed and logged in, and runs Nuxt directly otherwise. diff --git a/ROADMAP.md b/ROADMAP.md index 352c9c3..7107d5c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,13 +4,22 @@ Anything not listed here is either done or deliberately out of scope (see the bo ## Before a public v1 -- **Automated tests.** There are none. Every route has been verified by hand, which caught real - bugs, but nothing replays those checks on the next change. The paths worth covering first are the - ones where a silent regression is worst: the read counter (a one-read link must stay one-read - under concurrency), the permission matrix, invitation single-use, and the zero-knowledge round - trip. -- **Continuous integration.** No workflow exists. `pnpm typecheck` and `pnpm build` on every push and - pull request is the minimum before accepting outside contributions. +- **Integration tests.** Unit tests cover the pure logic (see below), but nothing exercises the + routes. The paths where a silent regression would cost most, in order: + 1. **The read counter under concurrency** — fire many simultaneous reveals at a one-read paste and + assert exactly one succeeds. This is the test that guards the atomic + `UPDATE … WHERE read_count < max_reads RETURNING`. + 2. **The permission matrix** — the table in the security docs, case by case. + 3. **Invitations** — single use including two concurrent accepts, address fixed by the invitation + rather than the request body, role always `user`, registration bypass. + 4. **Anonymous restrictions** — no uploads, no server-side sharing, `public_paste_enabled` off. + 5. **Settings semantics** — an absent row means the default, a row holding `null` means unlimited. + 6. **Account deletion** — full cascade, audit log anonymised rather than deleted. + + Needs Vitest with `@nuxt/test-utils` and a real PostgreSQL (a service container in CI), with + tables truncated between tests. Turnstile is bypassed with Cloudflare's always-valid test keys. + Assert on `paste_email_recipients` rows rather than on delivered mail, to avoid depending on a + mail server in CI. ## Waiting on upstream @@ -47,6 +56,10 @@ These were evaluated and rejected. Reopening them needs a new reason, not a remi no account, no follow-up. Counters are a feature of the authenticated tier. - **A global write circuit breaker** and **any CAPTCHA beyond Turnstile.** The existing layers are enough at the scale this targets. +- **Browser end-to-end tests (Playwright).** The encryption is covered by unit tests and the API + will be covered by integration tests; what would remain browser-specific is small — that the + fragment never leaves the client, and that revealing requires a click. Not worth the heaviest + tooling in the stack for that margin. Reconsider if the interface grows substantially. - **Per-account email volume throttling.** Sharing sends a single message with recipients in blind copy, so there is no volume to throttle. It would only make sense alongside a return to one message per recipient. diff --git a/apps/app/package.json b/apps/app/package.json index fefd7f9..249a263 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -13,6 +13,8 @@ "db:migrate": "node ../../scripts/drizzle-kit.mjs migrate", "db:studio": "node ../../scripts/drizzle-kit.mjs studio", "typecheck": "nuxt typecheck", + "test": "vitest run", + "test:watch": "vitest", "lint": "eslint .", "lint:fix": "eslint . --fix" }, @@ -39,6 +41,7 @@ "@types/qrcode": "^1.5.6", "drizzle-kit": "^0.31.10", "eslint": "^10.8.1", + "vitest": "^4.1.10", "vue-tsc": "^3.3.9" } } diff --git a/apps/app/tests/crypto.test.ts b/apps/app/tests/crypto.test.ts new file mode 100644 index 0000000..b649425 --- /dev/null +++ b/apps/app/tests/crypto.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import { + base64ToBytes, + base64UrlToBytes, + bytesToBase64, + bytesToBase64Url, + decryptBytes, + deriveAesKey, + encryptBytes, + generateFragmentKey +} from '../app/utils/crypto' + +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +describe('fragment key', () => { + it('is a 256-bit key, which is what AES-256 needs', () => { + expect(generateFragmentKey()).toHaveLength(32) + }) + + it('never repeats', () => { + const keys = new Set(Array.from({ length: 50 }, () => bytesToBase64(generateFragmentKey()))) + expect(keys.size).toBe(50) + }) +}) + +describe('encoding', () => { + it('round-trips arbitrary bytes through base64', () => { + const bytes = generateFragmentKey() + expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes) + }) + + it('round-trips through base64url', () => { + const bytes = generateFragmentKey() + expect(base64UrlToBytes(bytesToBase64Url(bytes))).toEqual(bytes) + }) + + it('produces base64url that is safe in a URL fragment', () => { + // The whole scheme depends on this surviving a trip through the address bar untouched: + // `+`, `/` and `=` would each be mangled or ambiguous there. + for (let i = 0; i < 50; i++) { + expect(bytesToBase64Url(generateFragmentKey())).toMatch(/^[A-Za-z0-9_-]+$/) + } + }) + + it('handles every possible byte value', () => { + const all = new Uint8Array(256).map((_, i) => i) as Uint8Array + expect(base64ToBytes(bytesToBase64(all))).toEqual(all) + }) +}) + +describe('encryption without a password', () => { + it('round-trips a message', async () => { + const key = await deriveAesKey(generateFragmentKey()) + const { ciphertext, iv } = await encryptBytes(key, encoder.encode('hello')) + expect(decoder.decode(await decryptBytes(key, ciphertext, iv))).toBe('hello') + }) + + it('never reuses an IV, even for identical plaintext under the same key', async () => { + // Reusing an IV under the same key breaks GCM catastrophically, so this is not a style point. + const key = await deriveAesKey(generateFragmentKey()) + const results = await Promise.all( + Array.from({ length: 20 }, () => encryptBytes(key, encoder.encode('same'))) + ) + const ivs = new Set(results.map(r => bytesToBase64(r.iv))) + const ciphertexts = new Set(results.map(r => bytesToBase64(r.ciphertext))) + expect(ivs.size).toBe(20) + expect(ciphertexts.size).toBe(20) + }) + + it('cannot be decrypted with a different key', async () => { + const { ciphertext, iv } = await encryptBytes(await deriveAesKey(generateFragmentKey()), encoder.encode('secret')) + const otherKey = await deriveAesKey(generateFragmentKey()) + await expect(decryptBytes(otherKey, ciphertext, iv)).rejects.toThrow() + }) + + it('rejects tampered ciphertext', async () => { + // GCM authenticates as well as encrypts; a flipped bit must fail loudly rather than decrypt + // to garbage. + const key = await deriveAesKey(generateFragmentKey()) + const { ciphertext, iv } = await encryptBytes(key, encoder.encode('secret')) + ciphertext[0] ^= 0xff + await expect(decryptBytes(key, ciphertext, iv)).rejects.toThrow() + }) +}) + +describe('encryption with a password', () => { + it('round-trips when the same fragment key and password are supplied', async () => { + const fragmentKey = generateFragmentKey() + const { ciphertext, iv } = await encryptBytes(await deriveAesKey(fragmentKey, 'correct horse'), encoder.encode('hello')) + const again = await deriveAesKey(fragmentKey, 'correct horse') + expect(decoder.decode(await decryptBytes(again, ciphertext, iv))).toBe('hello') + }) + + it('fails on a wrong password', async () => { + const fragmentKey = generateFragmentKey() + const { ciphertext, iv } = await encryptBytes(await deriveAesKey(fragmentKey, 'correct horse'), encoder.encode('hello')) + const wrong = await deriveAesKey(fragmentKey, 'wrong horse') + await expect(decryptBytes(wrong, ciphertext, iv)).rejects.toThrow() + }) + + it('fails when the password is right but the fragment key is not', async () => { + // Both halves are required: the password alone is not enough to reach the plaintext. + const { ciphertext, iv } = await encryptBytes(await deriveAesKey(generateFragmentKey(), 'shared'), encoder.encode('hello')) + const otherLink = await deriveAesKey(generateFragmentKey(), 'shared') + await expect(decryptBytes(otherLink, ciphertext, iv)).rejects.toThrow() + }) + + it('derives a different key than the passwordless path for the same fragment key', async () => { + const fragmentKey = generateFragmentKey() + const { ciphertext, iv } = await encryptBytes(await deriveAesKey(fragmentKey, 'pw'), encoder.encode('hello')) + const noPassword = await deriveAesKey(fragmentKey) + await expect(decryptBytes(noPassword, ciphertext, iv)).rejects.toThrow() + }) + + it('uses the fragment key as salt, so the same password yields different keys per paste', async () => { + const a = generateFragmentKey() + const b = generateFragmentKey() + const { ciphertext, iv } = await encryptBytes(await deriveAesKey(a, 'same password'), encoder.encode('hello')) + await expect(decryptBytes(await deriveAesKey(b, 'same password'), ciphertext, iv)).rejects.toThrow() + expect(decoder.decode(await decryptBytes(await deriveAesKey(a, 'same password'), ciphertext, iv))).toBe('hello') + }) +}) + +describe('realistic payloads', () => { + it('round-trips unicode text', async () => { + const key = await deriveAesKey(generateFragmentKey()) + const text = 'мой секрет · 秘密 · 🤫 · accentué' + const { ciphertext, iv } = await encryptBytes(key, encoder.encode(text)) + expect(decoder.decode(await decryptBytes(key, ciphertext, iv))).toBe(text) + }) + + it('round-trips binary file content', async () => { + const key = await deriveAesKey(generateFragmentKey()) + const file = crypto.getRandomValues(new Uint8Array(64 * 1024)) as Uint8Array + const { ciphertext, iv } = await encryptBytes(key, file) + expect(await decryptBytes(key, ciphertext, iv)).toEqual(file) + }) + + it('encrypts a filename under a separate IV from its content', async () => { + // Content and filename share the key but must never share an IV. + const key = await deriveAesKey(generateFragmentKey()) + const content = await encryptBytes(key, encoder.encode('file body')) + const name = await encryptBytes(key, encoder.encode('secret.txt')) + expect(bytesToBase64(content.iv)).not.toBe(bytesToBase64(name.iv)) + expect(decoder.decode(await decryptBytes(key, name.ciphertext, name.iv))).toBe('secret.txt') + }) +}) diff --git a/apps/app/tests/errors.test.ts b/apps/app/tests/errors.test.ts new file mode 100644 index 0000000..7f24279 --- /dev/null +++ b/apps/app/tests/errors.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { fetchErrorMessages } from '../app/utils/errors' + +describe('fetchErrorMessages', () => { + it('returns both fields so each call site can apply its own precedence', () => { + // Nitro routes set statusMessage, Better Auth sets message; call sites order them by + // whichever their endpoint actually populates. + expect(fetchErrorMessages({ data: { statusMessage: 'a', message: 'b' } })).toEqual({ statusMessage: 'a', message: 'b' }) + }) + + it('returns only what is present', () => { + expect(fetchErrorMessages({ data: { statusMessage: 'only' } })).toEqual({ statusMessage: 'only', message: undefined }) + expect(fetchErrorMessages({ data: { message: 'only' } })).toEqual({ statusMessage: undefined, message: 'only' }) + }) + + it('copes with everything a failed fetch can actually throw', () => { + // A network failure, an aborted request or a non-JSON response carry none of this shape. + expect(fetchErrorMessages(undefined)).toEqual({}) + expect(fetchErrorMessages(null)).toEqual({}) + expect(fetchErrorMessages(new Error('network'))).toEqual({}) + expect(fetchErrorMessages('a string')).toEqual({}) + expect(fetchErrorMessages({ data: null })).toEqual({}) + expect(fetchErrorMessages({ data: 'plain text body' })).toEqual({}) + }) + + it('ignores non-string fields rather than passing them to the UI', () => { + expect(fetchErrorMessages({ data: { statusMessage: 500, message: { nested: true } } })) + .toEqual({ statusMessage: undefined, message: undefined }) + }) +}) diff --git a/apps/app/tests/invitations.test.ts b/apps/app/tests/invitations.test.ts new file mode 100644 index 0000000..60ecdac --- /dev/null +++ b/apps/app/tests/invitations.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { + generateInvitationToken, + invitationState, + isInvitedSignup, + runAsInvitedSignup +} from '../server/utils/invitations' + +const HOUR = 60 * 60 * 1000 +const future = () => new Date(Date.now() + HOUR) +const past = () => new Date(Date.now() - HOUR) + +describe('invitationState', () => { + it('is pending while the deadline is ahead', () => { + expect(invitationState({ status: 'pending', expiresAt: future() })).toBe('pending') + }) + + it('derives expired from the deadline rather than the stored status', () => { + // Nothing flips the row to 'expired' on a schedule, so the rule has to hold on read. + expect(invitationState({ status: 'pending', expiresAt: past() })).toBe('expired') + }) + + it('keeps accepted and revoked whatever the deadline says', () => { + expect(invitationState({ status: 'accepted', expiresAt: past() })).toBe('accepted') + expect(invitationState({ status: 'revoked', expiresAt: past() })).toBe('revoked') + expect(invitationState({ status: 'accepted', expiresAt: future() })).toBe('accepted') + }) + + it('treats the exact deadline as already expired', () => { + expect(invitationState({ status: 'pending', expiresAt: new Date(Date.now()) })).toBe('expired') + }) +}) + +describe('generateInvitationToken', () => { + it('is URL-safe, so it survives being emailed as a query parameter', () => { + expect(generateInvitationToken()).toMatch(/^[A-Za-z0-9_-]+$/) + }) + + it('carries 32 bytes of entropy', () => { + // 32 random bytes in base64url, unpadded. This token is the only thing between a stranger + // and an account on a closed instance, so its size is a security property. + expect(generateInvitationToken()).toHaveLength(43) + }) + + it('never repeats', () => { + const tokens = new Set(Array.from({ length: 500 }, generateInvitationToken)) + expect(tokens.size).toBe(500) + }) +}) + +describe('invited signup context', () => { + it('is off by default', () => { + expect(isInvitedSignup()).toBe(false) + }) + + it('is on inside the callback and off again afterwards', async () => { + await runAsInvitedSignup('inv-1', async () => { + expect(isInvitedSignup()).toBe(true) + }) + expect(isInvitedSignup()).toBe(false) + }) + + it('survives awaits inside the callback', async () => { + // The whole point is that it holds for the duration of an async request handler. + await runAsInvitedSignup('inv-1', async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + expect(isInvitedSignup()).toBe(true) + }) + }) + + it('does not leak into concurrent work outside it', async () => { + // If the flag leaked across async contexts, a single invited signup would open the + // registration bypass for every request running at the same time. + let leaked: boolean | null = null + const outside = (async () => { + await new Promise(resolve => setTimeout(resolve, 5)) + leaked = isInvitedSignup() + })() + + await runAsInvitedSignup('inv-1', async () => { + await new Promise(resolve => setTimeout(resolve, 20)) + }) + await outside + + expect(leaked).toBe(false) + }) + + it('clears the context even when the callback throws', async () => { + await expect(runAsInvitedSignup('inv-1', async () => { + throw new Error('signup failed') + })).rejects.toThrow('signup failed') + expect(isInvitedSignup()).toBe(false) + }) + + it('returns whatever the callback returns', async () => { + await expect(runAsInvitedSignup('inv-1', async () => 'created')).resolves.toBe('created') + }) +}) diff --git a/apps/app/tests/mail-templates.test.ts b/apps/app/tests/mail-templates.test.ts new file mode 100644 index 0000000..7a2fd93 --- /dev/null +++ b/apps/app/tests/mail-templates.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { + changeEmailTemplate, + invitationTemplate, + resetPasswordTemplate, + sharedPasteTemplate, + verifyEmailTemplate +} from '../server/utils/mail-templates' + +const URL_ = 'https://shhh.example.com/p/abc#key=xyz' + +const ALL = [ + ['verifyEmail', verifyEmailTemplate({ url: URL_ })], + ['resetPassword', resetPasswordTemplate({ url: URL_ })], + ['changeEmail', changeEmailTemplate({ url: URL_, newEmail: 'new@example.com' })], + ['invitation', invitationTemplate({ url: URL_, expiresInDays: 7 })], + ['sharedPaste', sharedPasteTemplate({ url: URL_, senderName: 'Alice', remainingReads: 2, expiresAt: new Date('2030-01-01T00:00:00Z') })] +] as const + +describe('every template', () => { + it.each(ALL)('%s produces a subject, HTML and a text fallback', (_name, mail) => { + // The plain text part is mandatory (project.md section 9); an empty one would ship a blank + // email to any client that prefers text. + expect(mail.subject.trim()).not.toBe('') + expect(mail.html).toContain('') + expect(mail.text.trim()).not.toBe('') + }) + + it.each(ALL)('%s puts the link in both parts', (_name, mail) => { + expect(mail.html).toContain(URL_) + expect(mail.text).toContain(URL_) + }) + + it.each(ALL)('%s keeps the text part free of markup', (_name, mail) => { + expect(mail.text).not.toMatch(/<[a-z]/i) + }) +}) + +describe('HTML escaping', () => { + it('escapes a hostile sender name', () => { + // The sender name is chosen by a user and lands in an email another person opens. + const mail = sharedPasteTemplate({ + url: URL_, + senderName: '', + remainingReads: null, + expiresAt: new Date('2030-01-01T00:00:00Z') + }) + expect(mail.html).not.toContain('' }) + expect(mail.html).not.toContain('">