From a504deb9c082e38b5f52b5a4c8ffefe45ef41ea2 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sat, 19 Sep 2026 23:42:04 +0530 Subject: [PATCH 1/3] test: qualify account switching persistence and session expiry --- e2e/account-switch.spec.ts | 156 ++++++ src/app/api/test/expire-session/route.test.ts | 93 ++++ src/app/api/test/expire-session/route.ts | 28 ++ src/server/account-persistence.test.ts | 473 ++++++++++++++++++ 4 files changed, 750 insertions(+) create mode 100644 e2e/account-switch.spec.ts create mode 100644 src/app/api/test/expire-session/route.test.ts create mode 100644 src/app/api/test/expire-session/route.ts create mode 100644 src/server/account-persistence.test.ts diff --git a/e2e/account-switch.spec.ts b/e2e/account-switch.spec.ts new file mode 100644 index 00000000..5dabec1f --- /dev/null +++ b/e2e/account-switch.spec.ts @@ -0,0 +1,156 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { waitForHydrated } from './fixtures/hydration'; + +/** + * P06 / live#14 browser-level account qualification, all synthetic: + * + * 1. A signs in, saves private work, signs out through the real nav control; + * B signs in on the same browser and must not see, or be forwarded, A's + * rows — at the page level and at the session-scoped read API. + * 2. An actually-expired session (the auth_session row aged out through the + * test-only endpoint, not a forged cookie) produces exactly one sign-in + * prompt that retains the private destination and returns to it. + * + * Skips automatically when the server runs without ENABLE_TEST_AUTH=1. + */ + +const PASSWORD = 'e2e-test-password-not-a-secret'; +const ORIGIN = 'http://localhost:3000'; + +async function signUp(page: Page, email: string, name: string): Promise { + const res = await page.request.post('/api/auth/sign-up/email', { + headers: { Origin: ORIGIN }, + data: { email, password: PASSWORD, name }, + failOnStatusCode: false, + }); + if (res.status() === 404) { + test.skip(true, 'Test auth disabled — run the dev server with ENABLE_TEST_AUTH=1'); + } + expect(res.ok(), `sign-up for ${email} failed (${res.status()})`).toBeTruthy(); + const body = (await res.json()) as { user: { id: string } }; + return body.user.id; +} + +async function signIn(page: Page, email: string): Promise { + const res = await page.request.post('/api/auth/sign-in/email', { + headers: { Origin: ORIGIN }, + data: { email, password: PASSWORD }, + failOnStatusCode: false, + }); + expect(res.ok(), `sign-in for ${email} failed (${res.status()})`).toBeTruthy(); +} + +async function completeOnboarding(page: Page): Promise { + const res = await page.request.post('/api/test/complete-onboarding', { + headers: { Origin: ORIGIN }, + failOnStatusCode: false, + }); + expect(res.ok(), `onboarding completion failed (${res.status()})`).toBeTruthy(); +} + +test('a second account on the same browser cannot see or be forwarded the first account', async ({ + page, +}) => { + const emailA = `e2e-switch-a-${crypto.randomUUID()}@significanthobbies.test`; + const userIdA = await signUp(page, emailA, 'Account A'); + await completeOnboarding(page); + + // A creates uniquely named private work through the real UI. + const secret = `Orion drill ${crypto.randomUUID().slice(0, 8)}`; + await page.goto('/live-more'); + await page.getByLabel('What do you still want to live?').fill(secret); + const keep = page.getByRole('button', { name: 'Keep this exact dream' }); + await waitForHydrated(keep); + await keep.click(); + await expect(page.getByText('1 dream is now in your atlas.')).toBeVisible(); + await page.goto('/bucket-list'); + const aRow = page.getByRole('group', { name: `Controls for ${secret}` }); + await expect(aRow).toHaveCount(1); + + // Sign out through the real nav control — that path also refreshes the + // client router, so a cached private view cannot survive into B's session. + const menu = page.getByRole('button', { name: 'Open account menu' }); + await waitForHydrated(menu); + await menu.click(); + await page.getByRole('menuitem', { name: 'Sign out' }).click(); + await expect(page).toHaveURL(/\/$/); + const afterSignOut = await page.request.get('/api/personal-platform/session', { + failOnStatusCode: false, + }); + expect(afterSignOut.status(), 'sign-out must revoke the session').toBe(401); + + // Anonymous again on this browser: whichever surface renders, A's row must not. + await page.goto('/bucket-list'); + await expect(page.getByRole('group', { name: `Controls for ${secret}` })).toHaveCount(0); + await expect(page.getByText(secret)).toHaveCount(0); + + // B signs in on the same browser profile. + const emailB = `e2e-switch-b-${crypto.randomUUID()}@significanthobbies.test`; + const userIdB = await signUp(page, emailB, 'Account B'); + await completeOnboarding(page); + const bSession = await page.request.get('/api/personal-platform/session'); + expect((await bSession.json()).userId).toBe(userIdB); + + // B's session-scoped reads see an empty account, not A's record. + const bSummary = await page.request.get('/api/personal-platform/live/summary', { + headers: { 'X-Personal-User-Id': userIdB }, + }); + expect(bSummary.ok()).toBeTruthy(); + expect((await bSummary.json()).activeCount).toBe(0); + + // B's cookie forwarded as A's id fails closed at the real handler. + const spoofed = await page.request.get('/api/personal-platform/live/summary', { + headers: { 'X-Personal-User-Id': userIdA }, + failOnStatusCode: false, + }); + expect(spoofed.status()).toBe(401); + + await page.goto('/bucket-list'); + await expect(page.getByRole('group', { name: `Controls for ${secret}` })).toHaveCount(0); + await expect(page.getByText(secret)).toHaveCount(0); +}); + +test('an expired session gets one sign-in prompt that retains and returns to the destination', async ({ + page, +}) => { + const email = `e2e-expiry-${crypto.randomUUID()}@significanthobbies.test`; + await signUp(page, email, 'Expiry Owner'); + await completeOnboarding(page); + + // Build a real private list so the destination is an owner-guarded route. + await page.goto('/bucket-list/new'); + const month = page.getByRole('button', { name: /This month/i }); + await waitForHydrated(month); + await month.click(); + await page.getByRole('button', { name: /Keep it cozy/i }).click(); + await page.getByRole('button', { name: 'Make my Life Bingo' }).click(); + await page.getByRole('button', { name: 'Save list' }).click(); + // `/bucket-list/new` itself matches a lax id pattern — exclude it so the + // assertion cannot settle before the post-save navigation finishes. + await expect(page).toHaveURL(/\/bucket-list\/(?!new$)[^/]+$/); + const listPath = new URL(page.url()).pathname; + + // Age the stored session out — the real row, not a discarded cookie. + const expired = await page.request.post('/api/test/expire-session', { + headers: { Origin: ORIGIN }, + failOnStatusCode: false, + }); + if (expired.status() === 404) { + test.skip(true, 'Test auth disabled — run the dev server with ENABLE_TEST_AUTH=1'); + } + expect(expired.ok()).toBeTruthy(); + + // One prompt, destination retained — not a loop, not a silent homepage send. + await page.goto(listPath); + await expect(page).toHaveURL(`/login?callbackUrl=${encodeURIComponent(listPath)}`); + await expect( + page.getByRole('button', { name: 'Continue with Google', exact: true }) + ).toBeVisible(); + + // Signing back in returns to the same private destination with its data. + await signIn(page, email); + await page.goto(`/login?callbackUrl=${encodeURIComponent(listPath)}`); + await expect(page).toHaveURL(listPath); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); +}); diff --git a/src/app/api/test/expire-session/route.test.ts b/src/app/api/test/expire-session/route.test.ts new file mode 100644 index 00000000..bc745e6c --- /dev/null +++ b/src/app/api/test/expire-session/route.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fixture = vi.hoisted(() => ({ + getSession: vi.fn(), + update: vi.fn(), + set: vi.fn(), + where: vi.fn(), + headers: vi.fn(), + eq: vi.fn((column: unknown, value: unknown) => ({ column, value })), +})); + +vi.mock('next/headers', () => ({ headers: fixture.headers })); +vi.mock('~/lib/auth', () => ({ auth: { api: { getSession: fixture.getSession } } })); +vi.mock('~/db/schema', () => ({ session: { token: 'session.token' } })); +vi.mock('drizzle-orm', () => ({ eq: fixture.eq })); +vi.mock('~/server/db', () => ({ db: { update: fixture.update } })); + +import { POST } from './route'; + +describe('POST /api/test/expire-session', () => { + afterEach(() => vi.unstubAllEnvs()); + + beforeEach(() => { + vi.unstubAllEnvs(); + fixture.getSession.mockReset(); + fixture.update.mockReset(); + fixture.set.mockReset(); + fixture.where.mockReset(); + fixture.headers.mockReset(); + fixture.eq.mockClear(); + + fixture.headers.mockResolvedValue(new Headers({ cookie: 'session=test' })); + fixture.update.mockReturnValue({ set: fixture.set }); + fixture.set.mockReturnValue({ where: fixture.where }); + fixture.where.mockResolvedValue({ success: true }); + }); + + it('returns 404 in production even when test auth is enabled, without auth or DB access', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('ENABLE_TEST_AUTH', '1'); + + const response = await POST(); + + expect(response.status).toBe(404); + expect(fixture.getSession).not.toHaveBeenCalled(); + expect(fixture.update).not.toHaveBeenCalled(); + }); + + it('returns 404 outside production when test auth is disabled', async () => { + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('ENABLE_TEST_AUTH', '0'); + + const response = await POST(); + + expect(response.status).toBe(404); + expect(fixture.getSession).not.toHaveBeenCalled(); + expect(fixture.update).not.toHaveBeenCalled(); + }); + + it('returns 401 for an unauthenticated caller without touching the DB', async () => { + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('ENABLE_TEST_AUTH', '1'); + fixture.getSession.mockResolvedValue(null); + + const response = await POST(); + + expect(response.status).toBe(401); + expect(fixture.getSession).toHaveBeenCalledWith({ headers: expect.any(Headers) }); + expect(fixture.update).not.toHaveBeenCalled(); + }); + + it('expires only the authenticated caller session token', async () => { + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('ENABLE_TEST_AUTH', '1'); + const token = 'caller-session-token'; + fixture.getSession.mockResolvedValue({ session: { token } }); + + const before = Date.now(); + const response = await POST(); + const after = Date.now(); + + expect(response.status).toBe(200); + expect(fixture.headers).toHaveBeenCalledTimes(1); + expect(fixture.getSession).toHaveBeenCalledTimes(1); + expect(fixture.update).toHaveBeenCalledWith({ token: 'session.token' }); + expect(fixture.set).toHaveBeenCalledTimes(1); + const values = fixture.set.mock.calls[0]?.[0] as { expiresAt: Date }; + expect(values.expiresAt).toBeInstanceOf(Date); + expect(values.expiresAt.getTime()).toBeGreaterThanOrEqual(before - 60_000); + expect(values.expiresAt.getTime()).toBeLessThanOrEqual(after - 60_000); + expect(fixture.where).toHaveBeenCalledWith({ column: 'session.token', value: token }); + }); +}); diff --git a/src/app/api/test/expire-session/route.ts b/src/app/api/test/expire-session/route.ts new file mode 100644 index 00000000..9e17a0b8 --- /dev/null +++ b/src/app/api/test/expire-session/route.ts @@ -0,0 +1,28 @@ +import { eq } from 'drizzle-orm'; +import { headers } from 'next/headers'; +import { NextResponse } from 'next/server'; + +import { session as authSession } from '~/db/schema'; +import { auth } from '~/lib/auth'; +import { db } from '~/server/db'; + +/** + * Test-only expiry of the caller's own session row. Qualifying the + * expired-session return path in a real browser (live#14, P06) requires the + * stored session to actually age out — a fabricated cookie would not exercise + * the session authority. Shares the neighboring complete-onboarding + * endpoint's double gate, so it 404s outside local test-auth runs. + */ +export async function POST() { + if (process.env.NODE_ENV === 'production' || process.env.ENABLE_TEST_AUTH !== '1') { + return new NextResponse(null, { status: 404 }); + } + const current = await auth.api.getSession({ headers: await headers() }); + if (!current?.session.token) return new NextResponse(null, { status: 401 }); + + await db + .update(authSession) + .set({ expiresAt: new Date(Date.now() - 60_000) }) + .where(eq(authSession.token, current.session.token)); + return NextResponse.json({ success: true }); +} diff --git a/src/server/account-persistence.test.ts b/src/server/account-persistence.test.ts new file mode 100644 index 00000000..a808e475 --- /dev/null +++ b/src/server/account-persistence.test.ts @@ -0,0 +1,473 @@ +/** + * Account and persistence qualification on real storage (P06 / live#14). + * + * Until now the suite proved the pure routing helpers and the IndexedDB + * transaction contract, but the cross-account and durability claims lived only + * in Playwright or in source-string assertions. This file runs the production + * query and session code against a real SQLite database: the shipped + * `migrations/d1` SQL is applied to `node:sqlite`, then wrapped in a minimal + * D1 binding so `drizzle-orm/d1` — the same driver `src/server/db.ts` uses — + * executes every query. Sessions are minted by a real better-auth handler and + * verified through the app's own `~/lib/auth` module, whose database binding + * is pointed at the same store, so issuance and verification cross the real + * adapter boundary rather than a fixture. + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { DatabaseSync, type StatementSync } from 'node:sqlite'; + +import { type Auth, betterAuth, type BetterAuthOptions } from 'better-auth'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/d1'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +import * as schema from '~/db/schema'; +import { + account, + bucketListItems, + habitLogs, + habits, + session, + user, + users, + verification, +} from '~/db/schema'; +import { + addBucketListItem, + removeBucketListItem, + updateBucketListItem, + updateBucketListItemStatus, +} from '~/lib/actions/bucket-list'; +import { importLocalAccountData } from '~/lib/actions/local-import'; +import { getServerAuthSession } from '~/server/auth'; +import { readLiveRecords, readLiveSummary } from './personal-platform-live'; + +const harness = vi.hoisted(() => ({ + database: null as MemoryD1 | null, + db: null as ReturnType | null, +})); + +vi.mock('~/server/db', () => ({ + get db() { + return harness.db; + }, +})); +vi.mock('~/server/auth', () => ({ getServerAuthSession: vi.fn() })); +vi.mock('next/cache', () => ({ revalidatePath: vi.fn(), revalidateTag: vi.fn() })); +vi.mock('~/lib/analytics', () => ({ trackCoreAction: vi.fn() })); + +const mockSession = vi.mocked(getServerAuthSession); + +type SqlParam = string | number | bigint | null | Uint8Array; + +function normalizeParam(value: unknown): SqlParam { + if (value === undefined || value === null) return null; + if (typeof value === 'boolean') return value ? 1 : 0; + return value as SqlParam; +} + +/** + * The slice of the D1 binding contract `drizzle-orm/d1` actually calls: + * `prepare().bind()` plus `run`/`all`/`raw`/`first`, and `batch` for grouped + * writes. A SQL pattern can be armed via `failOn` so the next matching + * statement rejects — the storage-failure leg of the retry contract. + */ +class MemoryD1 { + private fault: RegExp | null = null; + private readonly sqlite: DatabaseSync; + + constructor(sqlite: DatabaseSync) { + this.sqlite = sqlite; + } + + failOn(pattern: RegExp | null) { + this.fault = pattern; + } + + prepare(sql: string) { + const fault = this.fault; + const stmt = this.sqlite.prepare(sql); + return new BoundStatement(stmt, () => fault?.test(sql) ?? false); + } + + async batch(statements: BoundStatement[]) { + this.sqlite.exec('BEGIN'); + try { + const results = []; + for (const statement of statements) results.push(await statement.all()); + this.sqlite.exec('COMMIT'); + return results; + } catch (error) { + this.sqlite.exec('ROLLBACK'); + throw error; + } + } + + exec(sql: string) { + this.sqlite.exec(sql); + return { count: 0, duration: 0 }; + } +} + +class BoundStatement { + private params: SqlParam[] = []; + private readonly stmt: StatementSync; + private readonly faulted: () => boolean; + + constructor(stmt: StatementSync, faulted: () => boolean) { + this.stmt = stmt; + this.faulted = faulted; + } + + bind(...params: unknown[]) { + this.params = params.map(normalizeParam); + return this; + } + + private guard() { + if (this.faulted()) throw new Error('Injected D1 storage failure'); + } + + async run() { + this.guard(); + const result = this.stmt.run(...this.params); + return { + success: true, + meta: { changes: result.changes, last_row_id: Number(result.lastInsertRowid) }, + }; + } + + async all() { + this.guard(); + return { results: this.stmt.all(...this.params) as Record[], success: true }; + } + + async raw() { + this.guard(); + return (this.stmt.all(...this.params) as Record[]).map((row) => + Object.values(row) + ); + } + + async first(column?: string) { + this.guard(); + const row = this.stmt.get(...this.params) as Record | undefined; + if (!row) return null; + return column === undefined ? row : (row[column] ?? null); + } +} + +function migratedDatabase(): MemoryD1 { + const sqlite = new DatabaseSync(':memory:'); + const dir = join(process.cwd(), 'migrations', 'd1'); + for (const file of readdirSync(dir) + .filter((name) => name.endsWith('.sql')) + .sort()) { + sqlite.exec(readFileSync(join(dir, file), 'utf8')); + } + return new MemoryD1(sqlite); +} + +function issuer(db: ReturnType): Auth { + return betterAuth({ + // Same local-development fallback secret as ~/lib/auth, so the signed + // session cookies this issuer mints verify under the production module. + secret: 'significant-hobbies-local-development-secret-32-chars', + baseURL: 'http://localhost:3000', + database: drizzleAdapter(db, { + provider: 'sqlite', + schema: { user, session, account, verification }, + }), + emailAndPassword: { enabled: true }, + }) as unknown as Auth; +} + +interface SignedInAccount { + userId: string; + /** Signed `token.hmac` cookie value, exactly as a browser would send it. */ + cookie: string; + /** Raw session token as stored in `auth_session.token`. */ + token: string; +} + +async function signUp( + issuerAuth: Auth, + email: string, + name: string +): Promise { + const response = await issuerAuth.handler( + new Request('http://localhost:3000/api/auth/sign-up/email', { + method: 'POST', + headers: { 'content-type': 'application/json', origin: 'http://localhost:3000' }, + body: JSON.stringify({ email, password: 'p06-test-password-not-a-secret', name }), + }) + ); + if (response.status !== 200) { + throw new Error(`sign-up failed with status ${response.status}`); + } + const cookie = response.headers + .getSetCookie() + .map((value) => /better-auth\.session_token=([^;]+)/.exec(value)?.[1]) + .find(Boolean); + if (!cookie) throw new Error('sign-up did not set a session cookie'); + const body = (await response.json()) as { user: { id: string } }; + // Mirror src/lib/auth.ts's databaseHook: auth_user rows need a matching + // app-level User row for ownership foreign keys. + await harness.db!.insert(users).values({ id: body.user.id, name, email }).onConflictDoNothing(); + return { + userId: body.user.id, + cookie, + token: cookie.split('.')[0] as string, + }; +} + +/** + * Production auth serves an https base URL, so its cookie can carry the + * `__Secure-` prefix; send both spellings so the real lookup runs regardless + * of which name the module resolves first. + */ +function sessionHeaders(cookie: string): Headers { + return new Headers({ + cookie: `better-auth.session_token=${cookie}; __Secure-better-auth.session_token=${cookie}`, + }); +} + +function requestWith(accountInfo: SignedInAccount, forwardedUserId: string): Request { + return new Request('https://live.significanthobbies.com/api/personal-platform/live/summary', { + headers: { + cookie: `better-auth.session_token=${accountInfo.cookie}; __Secure-better-auth.session_token=${accountInfo.cookie}`, + 'X-Personal-User-Id': forwardedUserId, + }, + }); +} + +function asUser(accountInfo: SignedInAccount) { + mockSession.mockResolvedValue({ + user: { + id: accountInfo.userId, + email: null, + name: null, + image: null, + username: null, + }, + }); +} + +async function itemRows(userId: string) { + return harness.db!.select().from(bucketListItems).where(eq(bucketListItems.userId, userId)); +} + +let db!: ReturnType; +let store!: MemoryD1; +let auth!: Auth; +let personalPlatformUser!: (request: Request) => Promise; + +beforeAll(async () => { + store = migratedDatabase(); + db = drizzle(store as unknown as Parameters[0], { schema }); + harness.database = store; + harness.db = db; + // Loaded after the db binding is mocked so the production auth module's + // adapter talks to this store rather than the Cloudflare context. + auth = (await import('~/lib/auth')).auth as unknown as Auth; + personalPlatformUser = (await import('~/app/api/personal-platform/live/route-helpers')) + .personalPlatformUser; +}); + +describe('session authority on durable storage', () => { + it('resolves each issued cookie to its own account through the configured auth module', async () => { + const issuerAuth = issuer(db); + const a = await signUp(issuerAuth, 'p06-a@example.test', 'Account A'); + const b = await signUp(issuerAuth, 'p06-b@example.test', 'Account B'); + expect(a.userId).not.toBe(b.userId); + + const sessionA = await auth.api.getSession({ headers: sessionHeaders(a.cookie) }); + expect(sessionA?.user.id).toBe(a.userId); + const sessionB = await auth.api.getSession({ headers: sessionHeaders(b.cookie) }); + expect(sessionB?.user.id).toBe(b.userId); + }); + + it('fails closed for expired and forged sessions instead of reviving them', async () => { + const issuerAuth = issuer(db); + const a = await signUp(issuerAuth, 'p06-expiry@example.test', 'Expiry Check'); + + await expect( + auth.api.getSession({ headers: sessionHeaders('forged-token-value') }) + ).resolves.toBeNull(); + + await db + .update(session) + .set({ expiresAt: new Date(Date.now() - 60_000) }) + .where(eq(session.token, a.token)); + await expect(auth.api.getSession({ headers: sessionHeaders(a.cookie) })).resolves.toBeNull(); + + const expired = await personalPlatformUser(requestWith(a, a.userId)); + expect(expired).toBeInstanceOf(Response); + expect((expired as Response).status).toBe(401); + }); +}); + +describe('two-account read isolation', () => { + it('scopes summaries and records to the session owner only', async () => { + const issuerAuth = issuer(db); + const a = await signUp(issuerAuth, 'p06-iso-a@example.test', 'Isolated A'); + const b = await signUp(issuerAuth, 'p06-iso-b@example.test', 'Isolated B'); + await db.insert(bucketListItems).values([ + { userId: a.userId, title: 'A private dream', status: 'planned' }, + { userId: b.userId, title: 'B private dream', status: 'done' }, + ]); + + const summaryA = await readLiveSummary(a.userId); + expect(summaryA.activeCount).toBe(1); + expect(summaryA.latest?.title).toBe('A private dream'); + + const recordsB = await readLiveRecords(b.userId, { + limit: 50, + offset: 0, + includeSensitive: true, + }); + expect(recordsB.items).toHaveLength(1); + expect(recordsB.items[0]?.record.title).toBe('B private dream'); + + expect(await personalPlatformUser(requestWith(a, a.userId))).toBe(a.userId); + expect(await personalPlatformUser(requestWith(b, b.userId))).toBe(b.userId); + + // B's session forwarded as A's id, and a forwarded id with no session at + // all, both fail closed. + const spoofed = await personalPlatformUser(requestWith(b, a.userId)); + expect(spoofed).toBeInstanceOf(Response); + expect((spoofed as Response).status).toBe(401); + + const anonymous = await personalPlatformUser( + new Request('https://live.significanthobbies.com/api/personal-platform/live/summary', { + headers: { 'X-Personal-User-Id': a.userId }, + }) + ); + expect(anonymous).toBeInstanceOf(Response); + expect((anonymous as Response).status).toBe(401); + }); +}); + +describe('bucket item writes follow durable commit', () => { + it('a retried create after a lost acknowledgement reuses the stored row', async () => { + const issuerAuth = issuer(db); + const a = await signUp(issuerAuth, 'p06-retry@example.test', 'Retry Owner'); + asUser(a); + + const first = await addBucketListItem({ title: 'See the aurora' }); + expect(first).toEqual({ success: true, id: first.id, added: true }); + + // The acknowledgement was lost on the wire, so the client sends the same + // mutation again — the stored row must be returned, not duplicated. + const retry = await addBucketListItem({ title: 'See the aurora' }); + expect(retry).toEqual({ success: true, id: first.id, added: false }); + await expect(itemRows(a.userId)).resolves.toHaveLength(1); + + // The dedupe key is normalized, so casing/whitespace variants of the same + // retry converge on the same row too. + const variant = await addBucketListItem({ title: 'SEE THE AURORA!' }); + expect(variant).toEqual({ success: true, id: first.id, added: false }); + await expect(itemRows(a.userId)).resolves.toHaveLength(1); + }); + + it('rejects when storage fails instead of reporting success before commit', async () => { + const issuerAuth = issuer(db); + const a = await signUp(issuerAuth, 'p06-fault@example.test', 'Fault Owner'); + asUser(a); + + store.failOn(/insert/i); + await expect(addBucketListItem({ title: 'Lost in transit' })).rejects.toThrow(); + store.failOn(null); + await expect(itemRows(a.userId)).resolves.toHaveLength(0); + }); + + it('scopes status, edit and delete to the owner — a second account cannot mutate them', async () => { + const issuerAuth = issuer(db); + const a = await signUp(issuerAuth, 'p06-own-a@example.test', 'Owner A'); + const b = await signUp(issuerAuth, 'p06-own-b@example.test', 'Owner B'); + + asUser(a); + const created = await addBucketListItem({ title: 'A only dream' }); + expect(created.added).toBe(true); + + asUser(b); + await expect(updateBucketListItemStatus(created.id as string, 'done')).resolves.toEqual({ + success: true, + }); + await expect(updateBucketListItem(created.id as string, { targetYear: 2030 })).resolves.toEqual( + { success: true } + ); + await expect(removeBucketListItem(created.id as string)).resolves.toEqual({ + success: true, + }); + + // Every mutation returned success without touching A's row — the update + // shapes are idempotent no-ops for a non-owner, so B's retries stay safe. + const rows = await itemRows(a.userId); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe('planned'); + expect(rows[0]?.targetYear).toBeNull(); + expect(rows[0]?.completedAt).toBeNull(); + + asUser(a); + await updateBucketListItemStatus(created.id as string, 'done'); + const [done] = await itemRows(a.userId); + expect(done?.status).toBe('done'); + expect(done?.completedAt).toBeInstanceOf(Date); + await removeBucketListItem(created.id as string); + await expect(itemRows(a.userId)).resolves.toHaveLength(0); + }); +}); + +describe('local account import replay', () => { + const localHabit = { + id: 'local-habit-p06', + name: 'Evening walk', + status: 'active', + targetFrequency: 'daily', + icon: null, + }; + + it('replays queued mutations without duplicating rows and fails closed across accounts', async () => { + const issuerAuth = issuer(db); + const a = await signUp(issuerAuth, 'p06-imp-a@example.test', 'Import A'); + const b = await signUp(issuerAuth, 'p06-imp-b@example.test', 'Import B'); + + const payload = { + profile: null, + onboarding: null, + daily: { + habits: [localHabit], + logs: [ + { + id: 'local-log-p06', + habitId: localHabit.id, + dayDate: '2026-09-19', + completed: true, + }, + ], + journals: [], + }, + commitments: null, + }; + + asUser(a); + await expect(importLocalAccountData(payload)).resolves.toEqual({ success: true }); + // A lost acknowledgement means the client replays the whole import — the + // stable local ids make the second run a no-op rather than a second copy. + await expect(importLocalAccountData(payload)).resolves.toEqual({ success: true }); + expect(await db.select().from(habits).where(eq(habits.id, localHabit.id))).toHaveLength(1); + expect(await db.select().from(habitLogs).where(eq(habitLogs.id, 'local-log-p06'))).toHaveLength( + 1 + ); + + // B signing in on the same device must not absorb rows already owned by A: + // the id guard rejects the replay rather than re-keying A's records. + asUser(b); + const crossAccount = await importLocalAccountData(payload); + expect(crossAccount.success).toBe(false); + expect(await db.select().from(habits).where(eq(habits.userId, b.userId))).toHaveLength(0); + }); +}); From fb98e2a5d681b32894179765402069b5734aed34 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sat, 19 Sep 2026 23:46:43 +0530 Subject: [PATCH 2/3] test: run SQLite persistence coverage in the Node environment --- src/server/account-persistence.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server/account-persistence.test.ts b/src/server/account-persistence.test.ts index a808e475..e0bf8991 100644 --- a/src/server/account-persistence.test.ts +++ b/src/server/account-persistence.test.ts @@ -1,3 +1,5 @@ +// @vitest-environment node + /** * Account and persistence qualification on real storage (P06 / live#14). * From ab33f26794689a4abd0860d3da07bd5572ceb796 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sat, 19 Sep 2026 23:50:48 +0530 Subject: [PATCH 3/3] test: fail visibly when account qualification fixtures are missing --- e2e/account-switch.spec.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/e2e/account-switch.spec.ts b/e2e/account-switch.spec.ts index 5dabec1f..56669f2d 100644 --- a/e2e/account-switch.spec.ts +++ b/e2e/account-switch.spec.ts @@ -12,7 +12,7 @@ import { waitForHydrated } from './fixtures/hydration'; * test-only endpoint, not a forged cookie) produces exactly one sign-in * prompt that retains the private destination and returns to it. * - * Skips automatically when the server runs without ENABLE_TEST_AUTH=1. + * Requires the configured local test-auth server; missing fixtures fail visibly. */ const PASSWORD = 'e2e-test-password-not-a-secret'; @@ -24,9 +24,7 @@ async function signUp(page: Page, email: string, name: string): Promise data: { email, password: PASSWORD, name }, failOnStatusCode: false, }); - if (res.status() === 404) { - test.skip(true, 'Test auth disabled — run the dev server with ENABLE_TEST_AUTH=1'); - } + expect(res.status(), 'Run the local test server with ENABLE_TEST_AUTH=1').not.toBe(404); expect(res.ok(), `sign-up for ${email} failed (${res.status()})`).toBeTruthy(); const body = (await res.json()) as { user: { id: string } }; return body.user.id; @@ -136,9 +134,7 @@ test('an expired session gets one sign-in prompt that retains and returns to the headers: { Origin: ORIGIN }, failOnStatusCode: false, }); - if (expired.status() === 404) { - test.skip(true, 'Test auth disabled — run the dev server with ENABLE_TEST_AUTH=1'); - } + expect(expired.status(), 'Run the local test server with ENABLE_TEST_AUTH=1').not.toBe(404); expect(expired.ok()).toBeTruthy(); // One prompt, destination retained — not a loop, not a silent homepage send.