Skip to content

Commit b57d30b

Browse files
committed
feat: continue restart plan
1 parent 24bda88 commit b57d30b

6 files changed

Lines changed: 249 additions & 1 deletion

File tree

new-deepnotes/PLAN_PROGRESS.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ Living checklist for the greenfield work described in [docs/RESTART_PLAN.md](../
3131

3232
## Phase 3 checklist (REST + Drizzle)
3333

34+
### Test coverage (Phase 3 account surface)
35+
36+
- [x] **Rate limit:** failed login counters (`login-rate-limit.test.ts`).
37+
- [x] **Email crypto:** `encryptUserEmail` / `decryptUserEmail` + `hashUserEmail` (legacy parity cases).
38+
- [x] **Email change mailer:** `sendEmailChangeVerificationEmail` (dev skip, missing API key, Resend errors/success via mocked `fetch`).
39+
- [x] **HTTP contracts:** OpenAPI path presence; Zod for `userEmailChange*`, password change byte fields (`schemas/users.test.ts`).
40+
- [x] **Worker smoke:** `503` when env/DB not configured for `/api/users/me/email-change` (+ confirm), alongside other session routes.
41+
- [ ] **DB integration:** register → email-change request/confirm (or password change) against **template DB** with real `perform*` + minimal fixtures.
42+
3443
### Sessions + account (current)
3544

3645
- [x] Document **sessions** REST paths + request schemas in OpenAPI; demo + `users/me` contracts updated.
@@ -102,6 +111,18 @@ Cross-cutting work so the new SPA does not repeat **legacy `apps/client`** patte
102111
- [ ] **Contract tests** for the fetch wrapper (MSW or recorded OpenAPI fixtures)—optional until multiple features consume the API.
103112
- [ ] **E2E smoke** (Playwright recommended): login or session refresh with **httpOnly cookies** against **local compose** or **Cloudflare preview**—add CI job when stable enough (can start `manual`/`workflow_dispatch` if cost is a concern).
104113

114+
#### Automated tests — package matrix (maintenance)
115+
116+
| Package / app | Role | What runs today | Gaps (highest value next) |
117+
|---------------|------|------------------|---------------------------|
118+
| **`@deepnotes/db`** | Drizzle + migrations | `template-db.test.ts`: clone template DB, smoke SQL | More assertions on FKs / critical columns after schema grows |
119+
| **`@deepnotes/session`** | Auth, account, crypto orchestration | `login-rate-limit.test.ts` (Redis port in memory); **`encrypt-user-email.test.ts`** (round-trip, case exceptions, tamper); **`email-hash.test.ts`** (stability, secret sensitivity, exceptions); **`send-email-change-code.test.ts`** (SEND_EMAILS=false no fetch, missing key **503**, Resend **502**/OK) | **Integration:** `performUserRegister`, login, password/email change against **template DB** + mocked Redis; JWT cookie helpers |
120+
| **`@deepnotes/api`** | Zod + OpenAPI | `openapi.test.ts` (health + route registry); **`schemas/users.test.ts`** (email/password change bodies, 6-digit code) | Schemas for sessions + remaining routes; optional **snapshot** of OpenAPI fragment for drift |
121+
| **`@deepnotes/api-worker`** | Hono on Worker | `index.test.ts`: health, OpenAPI JSON, **503** when secrets/DB not bound (incl. email-change paths) | **200-path tests** with test `SessionEnv` + Hyperdrive stub + template DB (heavier CI job) |
122+
| **`@deepnotes/web`** | SPA | `app.test.ts` (mount `App.vue`) | Auth UI + API client as in §5.8 |
123+
124+
**Principle:** keep **fast unit tests** on pure crypto, Zod, and mail/HTTP branches; add **Postgres-backed** flows incrementally (same template pattern as `@deepnotes/db`) so Phase 3 routes do not regress silently.
125+
105126
### Progress vs legacy (reference only)
106127

107128
| Legacy (`apps/client`) | New (`new-deepnotes/apps/web`) |
@@ -119,7 +140,7 @@ Cross-cutting work so the new SPA does not repeat **legacy `apps/client`** patte
119140
- [ ] Cold API dev start under **2 s** (no `inspect-brk` by default) — validate on a typical laptop.
120141
- [ ] Collab + realtime: at least one integration test each (Redis + deps).
121142
- [x] SQL-heavy paths: real Postgres tests; prefer **template DB** cloning (§5.7) — `@deepnotes/db` template test.
122-
- [ ] Auth, crypto, Stripe: automated coverage beyond smoke; **no** generic repository layer (§5.0).
143+
- [ ] Auth, crypto, Stripe: automated coverage beyond smoke; **no** generic repository layer (§5.0). **Progress:** crypto email path (`encrypt-user-email`, `email-hash`), Resend branch behavior, and **user** Zod bodies are covered in Vitest; session **perform\*** flows still need DB integration tests.
123144
- [x] No tRPC / superjson / RevenueCat / key-rotation in **this** tree (keep absent); product sign-off for IAP/Stripe when billing ships.
124145
- [x] Client: zero undocumented forks, or a short owned exception list — see [docs/CLIENT_FORKS.md](./docs/CLIENT_FORKS.md).
125146
- [ ] Cloudflare: deploy runbook; Hyperdrive + Postgres + Redis proven in staging; collab/realtime topology chosen and load-tested.
@@ -138,6 +159,7 @@ Use this when resuming: **(done)** account HTTP surface through email change inc
138159

139160
| Date | Change |
140161
|------|--------|
162+
| 2026-04-26 | **Tests:** `@deepnotes/session``encrypt-user-email.test.ts`, `email-hash.test.ts`, `send-email-change-code.test.ts`; `@deepnotes/api``schemas/users.test.ts`; api-worker — email-change routes in `503` matrix; PLAN_PROGRESS — package test matrix + Phase 3 test checklist. |
141163
| 2026-04-26 | Phase 3: **email change**`POST /api/users/me/email-change` + `…/confirm` (`change-user-email.ts`, `decryptUserEmail`, `send-email-change-code`); `userEmailChange*Request` schemas, OpenAPI, Hono; TRPC_REST_MAP; PLAN_PROGRESS detail + suggested Phase 3 order. |
142164
| 2026-04-26 | Phase 3: **`POST /api/users/me/password`**`performUserPasswordChange` (`change-user-password.ts`): old password verify, demo **403**, new keyrings + PHC, invalidate all `sessions`, clear cookies **204**; `userPasswordChangeRequestSchema`, OpenAPI + worker; export **`byteB64`** from `@deepnotes/api`; TRPC_REST_MAP rows for change-password; PLAN_PROGRESS Phase 3 account section expanded. |
143165
| 2026-04-26 | Phase 2 + §5.8: `@deepnotes/web` — Vitest + happy-dom + `@vue/test-utils`, `vite.config` from `vitest/config`, `src/app.test.ts`; Phase 3: `DELETE /api/users/me` + `performUserAccountDelete` (ownership guard, Drizzle tx, clear cookies); `userAccountDeleteRequestSchema` + OpenAPI; api-worker route; TRPC_REST_MAP note on delete body / Stripe hook. |

new-deepnotes/apps/api-worker/src/index.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ describe("api-worker", () => {
3232
["DELETE", "/api/users/me"],
3333
["POST", "/api/users"],
3434
["POST", "/api/users/email-verification/resend"],
35+
["POST", "/api/users/me/email-change"],
36+
["POST", "/api/users/me/email-change/confirm"],
3537
] as const)("returns 503 for %s %s when auth env is not configured", async (method, path) => {
3638
const res = await app.request(`http://test${path}`, { method });
3739
expect(res.status).toBe(503);
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
userEmailChangeConfirmRequestSchema,
5+
userEmailChangeRequestSchema,
6+
userPasswordChangeRequestSchema,
7+
} from "./users.js";
8+
9+
const oneByteB64 = Buffer.from([0xab]).toString("base64");
10+
11+
describe("user request schemas (REST body validation)", () => {
12+
it("userEmailChangeRequestSchema accepts byte fields and email", () => {
13+
const parsed = userEmailChangeRequestSchema.parse({
14+
oldLoginHash: oneByteB64,
15+
newEmail: "new@example.com",
16+
});
17+
expect(parsed.oldLoginHash).toEqual(new Uint8Array([0xab]));
18+
expect(parsed.newEmail).toBe("new@example.com");
19+
});
20+
21+
it("userEmailChangeConfirmRequestSchema requires six-digit code", () => {
22+
const body = {
23+
oldLoginHash: oneByteB64,
24+
emailVerificationCode: "042069",
25+
newLoginHash: Buffer.from([1]).toString("base64"),
26+
userEncryptedPrivateKeyring: Buffer.from([2]).toString("base64"),
27+
userEncryptedSymmetricKeyring: Buffer.from([3]).toString("base64"),
28+
};
29+
const ok = userEmailChangeConfirmRequestSchema.parse(body);
30+
expect(ok.emailVerificationCode).toBe("042069");
31+
expect(() =>
32+
userEmailChangeConfirmRequestSchema.parse({
33+
...body,
34+
emailVerificationCode: "12345",
35+
}),
36+
).toThrow();
37+
});
38+
39+
it("userPasswordChangeRequestSchema decodes all byte fields", () => {
40+
const a = Buffer.from("aa", "utf8").toString("base64");
41+
const b = Buffer.from("bb", "utf8").toString("base64");
42+
const c = Buffer.from("cc", "utf8").toString("base64");
43+
const d = Buffer.from("dd", "utf8").toString("base64");
44+
const p = userPasswordChangeRequestSchema.parse({
45+
oldLoginHash: a,
46+
newLoginHash: b,
47+
userEncryptedPrivateKeyring: c,
48+
userEncryptedSymmetricKeyring: d,
49+
});
50+
expect(new TextDecoder().decode(p.oldLoginHash)).toBe("aa");
51+
expect(new TextDecoder().decode(p.newLoginHash)).toBe("bb");
52+
});
53+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { hashUserEmail } from "./email-hash.js";
4+
5+
describe("hashUserEmail", () => {
6+
it("is stable for the same normalized email and secret", async () => {
7+
const secret = "test-email-hmac-secret";
8+
const exceptions = "";
9+
const a = await hashUserEmail("MixEd@Case.com", secret, exceptions);
10+
const b = await hashUserEmail("mixed@case.com", secret, exceptions);
11+
expect(Buffer.from(a).equals(Buffer.from(b))).toBe(true);
12+
});
13+
14+
it("differs when the secret differs", async () => {
15+
const exceptions = "";
16+
const h1 = await hashUserEmail("same@x.co", "secret-a", exceptions);
17+
const h2 = await hashUserEmail("same@x.co", "secret-b", exceptions);
18+
expect(Buffer.from(h1).equals(Buffer.from(h2))).toBe(false);
19+
});
20+
21+
it("respects case-sensitivity exceptions like encryptUserEmail", async () => {
22+
const secret = "s";
23+
const ex = "Special@X.co";
24+
const hPreserve = await hashUserEmail("Special@X.co", secret, ex);
25+
const hLower = await hashUserEmail("special@x.co", secret, ex);
26+
expect(Buffer.from(hPreserve).equals(Buffer.from(hLower))).toBe(false);
27+
});
28+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it, beforeAll } from "vitest";
2+
3+
import { ensureSodiumReady } from "./crypto/session-crypto.js";
4+
import { decryptUserEmail, encryptUserEmail } from "./encrypt-user-email.js";
5+
6+
/** 32-byte XChaCha key as standard base64 */
7+
const TEST_KEY_B64 = Buffer.alloc(32, 9).toString("base64");
8+
9+
describe("encryptUserEmail / decryptUserEmail", () => {
10+
beforeAll(async () => {
11+
await ensureSodiumReady();
12+
});
13+
14+
it("round-trips and lowercases by default", () => {
15+
const email = "User@Example.COM";
16+
const ct = encryptUserEmail(email, TEST_KEY_B64, "");
17+
const out = decryptUserEmail(ct, TEST_KEY_B64, "");
18+
expect(out).toBe("user@example.com");
19+
});
20+
21+
it("preserves casing for addresses in EMAIL_CASE_SENSITIVITY_EXCEPTIONS", () => {
22+
const email = "PreserveCase@X.org";
23+
const exceptions = "PreserveCase@X.org";
24+
const ct = encryptUserEmail(email, TEST_KEY_B64, exceptions);
25+
const out = decryptUserEmail(ct, TEST_KEY_B64, exceptions);
26+
expect(out).toBe("PreserveCase@X.org");
27+
});
28+
29+
it("rejects tampered ciphertext", () => {
30+
const ct = encryptUserEmail("a@b.co", TEST_KEY_B64, "");
31+
const tampered = new Uint8Array(ct);
32+
tampered[0] = (tampered[0] ?? 0) ^ 0xff;
33+
expect(() => decryptUserEmail(tampered, TEST_KEY_B64, "")).toThrow();
34+
});
35+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
import type { SessionEnv } from "./env.js";
4+
import { sendEmailChangeVerificationEmail } from "./send-email-change-code.js";
5+
6+
function minimalEnv(
7+
overrides: Partial<Pick<SessionEnv, "SEND_EMAILS" | "RESEND_API_KEY">>,
8+
): SessionEnv {
9+
return {
10+
ACCESS_SECRET: "a",
11+
REFRESH_SECRET: "b",
12+
USER_EMAIL_SECRET: "c",
13+
USER_EMAIL_ENCRYPTION_KEY: Buffer.alloc(32, 1).toString("base64"),
14+
USER_REHASHED_LOGIN_HASH_ENCRYPTION_KEY: Buffer.alloc(32, 2).toString(
15+
"base64",
16+
),
17+
USER_AUTHENTICATOR_SECRET_ENCRYPTION_KEY: Buffer.alloc(32, 3).toString(
18+
"base64",
19+
),
20+
USER_RECOVERY_CODES_ENCRYPTION_KEY: Buffer.alloc(32, 4).toString("base64"),
21+
...overrides,
22+
};
23+
}
24+
25+
describe("sendEmailChangeVerificationEmail", () => {
26+
afterEach(() => {
27+
vi.restoreAllMocks();
28+
});
29+
30+
it("no-ops when SEND_EMAILS is false (no Resend, no fetch)", async () => {
31+
const fetchSpy = vi.spyOn(globalThis, "fetch");
32+
await sendEmailChangeVerificationEmail({
33+
env: minimalEnv({ SEND_EMAILS: "false" }),
34+
toEmail: "u@x.co",
35+
emailVerificationCode: "123456",
36+
});
37+
expect(fetchSpy).not.toHaveBeenCalled();
38+
});
39+
40+
it("throws 503 when outbound email is enabled but RESEND_API_KEY is missing", async () => {
41+
await expect(
42+
sendEmailChangeVerificationEmail({
43+
env: minimalEnv({ SEND_EMAILS: "true", RESEND_API_KEY: undefined }),
44+
toEmail: "u@x.co",
45+
emailVerificationCode: "000000",
46+
}),
47+
).rejects.toMatchObject({
48+
name: "SessionError",
49+
status: 503,
50+
code: "SERVICE_UNAVAILABLE",
51+
});
52+
});
53+
54+
it("throws 503 when RESEND_API_KEY is whitespace only", async () => {
55+
await expect(
56+
sendEmailChangeVerificationEmail({
57+
env: minimalEnv({
58+
SEND_EMAILS: "true",
59+
RESEND_API_KEY: " \t",
60+
}),
61+
toEmail: "u@x.co",
62+
emailVerificationCode: "000000",
63+
}),
64+
).rejects.toMatchObject({ status: 503 });
65+
});
66+
67+
it("throws 502 when Resend returns non-OK", async () => {
68+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
69+
new Response("bad", { status: 422 }),
70+
);
71+
await expect(
72+
sendEmailChangeVerificationEmail({
73+
env: minimalEnv({
74+
SEND_EMAILS: "true",
75+
RESEND_API_KEY: "re_test",
76+
}),
77+
toEmail: "u@x.co",
78+
emailVerificationCode: "654321",
79+
}),
80+
).rejects.toMatchObject({
81+
status: 502,
82+
code: "EMAIL_SEND_FAILED",
83+
});
84+
});
85+
86+
it("resolves when Resend returns OK", async () => {
87+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("", { status: 200 }));
88+
await expect(
89+
sendEmailChangeVerificationEmail({
90+
env: minimalEnv({
91+
SEND_EMAILS: "true",
92+
RESEND_API_KEY: "re_ok",
93+
}),
94+
toEmail: "user@example.com",
95+
emailVerificationCode: "111222",
96+
}),
97+
).resolves.toBeUndefined();
98+
expect(fetch).toHaveBeenCalledWith(
99+
"https://api.resend.com/emails",
100+
expect.objectContaining({
101+
method: "POST",
102+
headers: expect.objectContaining({
103+
Authorization: "Bearer re_ok",
104+
}),
105+
}),
106+
);
107+
});
108+
});

0 commit comments

Comments
 (0)