test(email): add unit tests for webmail server security primitives#219
Open
ahmedhesham6 wants to merge 2 commits into
Open
test(email): add unit tests for webmail server security primitives#219ahmedhesham6 wants to merge 2 commits into
ahmedhesham6 wants to merge 2 commits into
Conversation
apps/email/server had no tests. This adds 43 covering the modules where a silent regression is most costly, all pure logic with no IMAP/SMTP or network dependency. - crypto.ts session credential encryption (AES-256-GCM) - rate-limit.ts the sign-in brute-force guard - sanitize.ts the XSS boundary for HTML from arbitrary senders - client-ip.ts the key every rate limiter buckets on - schemas.ts sign-in validation and settings bounds Cases target the boundaries rather than the happy path: GCM tamper detection in the IV, ciphertext and tag separately; the exact 28/27-byte payload framing; IV uniqueness across identical plaintexts; the rate limiter's exact max/max+1 and rollover instants plus the retryAfter floor; that x-forwarded-for stays untrusted even with no x-real-ip; and that signInSchema keeps rejecting client-supplied imapHost/smtpHost, which is what stops a phishing page redirecting a password to an attacker-controlled mail host. Each test was checked by mutating the source and confirming it fails: trusting x-forwarded-for fails 2, dropping .strict() fails 1, removing the rel/target link hardening fails 6, a rate-limit off-by-one fails 7, and disabling the GCM auth tag fails 4. Where sanitize-html's real behavior is weaker than one might assume, the test asserts what actually happens and comments the residual risk rather than asserting an untrue guarantee: <style> is allowed and keeps javascript: in CSS, and a percent-encoded scheme survives the scheme check. No sanitizer behavior is changed here. Wiring notes: @zero/server is nested below the apps/* workspace glob, so it is not a turbo member and a test script there alone would never run in CI; the @repo/email orchestrator forwards to it using the same "cd server &&" pattern as its lint script. tsconfig.json did not include test/, so the tests were unchecked by tsc -- adding it surfaced one real type error, now fixed. build is tsc --noEmit and the release entrypoint is server/src/main.ts, so nothing under test/ ships. Refs oblien#216 No files under apps/email/server/src were modified.
…rkspace The first pass used `bun test`, on the reasoning that the package is Bun-native (bun:sqlite, hono/bun, @types/bun) and had no vitest dependency. That was the wrong call: seven of the eight workspaces that test at all use `vitest run`, so a lone Bun runner would have been an odd one out for a contributor to trip over. Converted all five files. Two needed more than an import swap: - rate-limit: `setSystemTime`/`Bun.sleep` -> vi fake timers. The GC case is stronger for it. It used to sleep 20ms of real time and hope the sweep had run; it now advances the timer queue so the interval actually fires. Note this needs advanceTimersByTime, not setSystemTime -- the latter moves Date.now() while leaving the timer pending, so nothing is ever collected and the test passes vacuously. - client-ip: `hono/bun`'s barrel also pulls the SSG and websocket adapters, which dereference the `Bun` global at import, so the file threw `Bun is not defined` under vitest's Node runtime before any test ran. Mocked to the one function used, getConnInfo, whose own module is runtime-agnostic. The logic under test (header precedence, trimming, the throwing-fallback path) is all clientIp's own and stays real -- I re-ran the x-forwarded-for mutation through the mock and it still fails 2 tests, same as before. vitest ^4.0.18 added as a devDependency, matching the spec used by the other seven. apps/email/server keeps its own bun.lock (it is not a member of the root apps/* workspace glob), so the dependency had to be installed there rather than inherited by hoisting -- hence the lockfile change. Pinned resolution is 4.0.18, identical to root; a plain install had drifted to 4.1.10. @types/bun stays: the server genuinely runs on Bun at runtime. Only the test runner moved. Re-verified all six mutations under the new runner, with failure counts unchanged: XFF trust 2, .strict() 1, rel/target 6, off-by-one 7, setAuthTag 4, static IV 1. Root `bun run test` 8/8 and `bun run build` 12/12 pass; the release bundle contains no test files and no vitest.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #216 — picking up the call for test coverage before the stable release.
What and why
apps/email/serverhad no tests. This adds 43 across the five modules where a silent regression is most expensive, all pure logic with no IMAP/SMTP or network dependency:crypto.tsrate-limit.tssanitize.tsclient-ip.tsschemas.tsNo file under
apps/email/server/srcis modified.Cases aim at boundaries rather than happy paths — GCM tamper detection in the IV, ciphertext and tag separately; the exact 28/27-byte payload framing; IV uniqueness across identical plaintexts; the limiter's exact
max/max+1and the exact rollover instant; theretryAfterfloor; thatx-forwarded-forstays untrusted even whenx-real-ipis absent; and thatsignInSchemakeeps rejecting client-suppliedimapHost/smtpHost— the check that stops a phishing page redirecting a password to an attacker-controlled mail host.How I verified it
Every test was checked by mutating the source and confirming it fails. Reverted after each, and re-run after the vitest conversion below with identical counts:
x-forwarded-foras a fallback.strict()fromsignInSchemarel/targetlink hardening>=→>)setAuthTagrandomBytesAlso run and green:
bun run testat the repo root — 8/8 workspacesbun run buildat the repo root — 12/12 taskstsc --noEmitinapps/email/server— exit 0bun format— cleanapps/email/dist) contains no test files and no vitestRunner
Tests use vitest, matching the seven other workspaces that test.
vitest ^4.0.18is added as a devDependency using the same spec as the rest, resolving to 4.0.18 exactly as at root.Two files needed more than an import swap, both worth a reviewer's eye:
rate-limit—setSystemTime/Bun.sleepbecamevifake timers, which makes the GC case stronger: it previously slept 20ms of real time and hoped the sweep had run, and now advances the timer queue so the interval actually fires. This needsadvanceTimersByTime, notsetSystemTime— the latter movesDate.now()while leaving the timer pending, so nothing is collected and the test passes vacuously.client-ip—hono/bun's barrel also pulls in the SSG and websocket adapters, which dereference theBunglobal at import, so the file threwBun is not definedunder vitest's Node runtime before any test ran. It is mocked down to the single function used,getConnInfo, whose own module is runtime-agnostic. Everything under test (header precedence, trimming, the throwing-fallback path) isclientIp's own logic and stays real — thex-forwarded-formutation still fails 2 tests through the mock.@types/bunstays in place: the server genuinely runs on Bun (bun:sqlite,hono/bun). Only the test runner moved.Wiring
@zero/serveris not a turbo workspace member —workspacesisapps/*and it sits a level deeper, so atestscript there alone would never run in CI. The@repo/emailorchestrator forwards to it with the samecd server &&pattern its existing scripts use.@repo/emailnow appears in the turbo test graph.bun.lockrather than being hoisted from root, so vitest had to be installed there; that is the lockfile change in the diff.tsconfig.jsondid not includetest/, so the tests were invisible totsc. Adding it surfaced one real type error (HeadersInitis not in scope underlib: ["ES2022"]), now fixed.buildistsc --noEmitand the release entrypoint isserver/src/main.ts, so nothing undertest/ships.One deliberate choice worth flagging
Where
sanitize-html's real behavior is weaker than one might assume, the test asserts what actually happens and comments the residual risk, rather than asserting a guarantee that does not hold. Two such cases:<style>is allowed and retainsjavascript:in CSS, and a percent-encoded scheme (JaVaScRiPt%3A…) survives the scheme check.I have not changed any sanitizer behavior here — that is a behavior change and per CONTRIBUTING belongs in its own issue, opened as #220. If you would rather these tests assert the hardened behavior instead, say so and I will follow whichever way you decide.
Tests also avoid the
.dev-secrets.jsonwrite hazard insrc/env.tsby settingSESSION_ENCRYPTION_KEYandBRANDING_ADMIN_TOKENbefore that module loads; confirmed no.dev-secrets.jsonordata/directory is created by a run.Unrelated pre-existing issue spotted
apps/email'slintscript runscd server && bun run lint, but neitherservernorclientdefines alintscript, so it fails on cleanmain. It is not in the turbo graph so CI never calls it. Left alone as out of scope — happy to send a one-line fix separately if useful.