/** * Integration tests for the settings surface, against the live seeded database. * * pnpm services:up && pnpm db:migrate && pnpm db:seed * * These are mostly authorization and information-leak tests. Settings hands a * user controls over their own account; the failure mode that matters is one of * them reaching somebody else's. */ import { config } from 'dotenv'; import { sql } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; config({ path: '../../.env' }); const { closePool, db } = await import('@linkder/db'); const { appRouter } = await import('../src/root'); const { createInnerContext } = await import('../src/context'); const { createCallerFactory } = await import('../src/trpc'); const createCaller = createCallerFactory(appRouter); type Session = import('../src/context').Session; function callerFor(session: Session | null) { return createCaller(createInnerContext({ db, session })); } const clientSession = (userId: string): Session => ({ userId, role: 'client', name: 'Test Client', email: 'client@test', phone: null, verificationStatus: null, }); const proSession = (userId: string): Session => ({ userId, role: 'pro', name: 'Test Pro', email: 'pro@test', phone: null, verificationStatus: 'verified', }); let alice: string; let bob: string; let aliceEmail: string; let bobEmail: string; /** * A throwaway verified pro, owned by this file. * * Not a seeded one: vitest runs test FILES in parallel, and the location tests * demote a verified pro to `pending` — doing that to a seeded pro would delete a * card out from under deck.router.test.ts mid-run. This one is parked in the * Gulf of Guinea with no trades, so no deck query can reach it either way. */ let pro: string; const PRO_BASE = { lat: 0.5, lng: 0.5 }; // Unique per run: these tests write real addresses onto real rows, and a // leftover from a previous run would collide with users.email's UNIQUE index. const RUN = Math.random().toString(36).slice(2, 8); /** Marks the session rows this file creates, so they can be cleaned up. */ const PROBE_UA = 'SettingsTestProbe'; beforeAll(async () => { /* * The SEEDED clients specifically, not "the first two clients". * * Test files share one database and several of them insert their own client * probes; a bare `role = 'client' ORDER BY id LIMIT 2` picks whichever uuids * happen to sort first, so another file's fixture could land here and then be * deleted underneath these tests. Seeded accounts are the ones on * @linkder.test, and they are stable. * * Order by id, not created_at: the seed writes clients in one batch and * created_at ties, so created_at ordering is not stable between runs. */ const rows = await db.execute<{ id: string; email: string }>( sql`SELECT id, email FROM users WHERE role = 'client' AND email LIKE '%@linkder.test' ORDER BY id LIMIT 2`, ); alice = rows[0]!.id; bob = rows[1]!.id; aliceEmail = rows[0]!.email; bobEmail = rows[1]!.email; await db.execute(sql`DELETE FROM email_change_requests`); await db.execute(sql`DELETE FROM deletion_requests`); await db.execute(sql`DELETE FROM notification_preferences`); await db.execute(sql`DELETE FROM sessions WHERE user_agent = ${PROBE_UA}`); const created = await db.execute<{ id: string }>(sql` INSERT INTO users (name, email, role) VALUES ('Location Probe', ${`location-probe-${RUN}@example.com`}, 'pro') RETURNING id `); pro = created[0]!.id; await db.execute(sql` INSERT INTO pro_profiles ( user_id, headline, bio, hourly_rate_cents, base_location, service_radius_m, verification_status, verified_at ) VALUES ( ${pro}, 'Location probe', 'Exists only for the settings location tests.', 3000, ST_SetSRID(ST_MakePoint(${PRO_BASE.lng}, ${PRO_BASE.lat}), 4326)::geography, 15000, 'verified', now() ) `); }); afterAll(async () => { // Put the addresses back, or the next run starts from a different state. await db.execute(sql`UPDATE users SET email = ${aliceEmail} WHERE id = ${alice}`); await db.execute(sql`UPDATE users SET email = ${bobEmail} WHERE id = ${bob}`); await db.execute(sql`DELETE FROM email_change_requests`); await db.execute(sql`DELETE FROM deletion_requests`); await db.execute(sql`DELETE FROM notification_preferences`); await db.execute(sql`DELETE FROM sessions WHERE user_agent = ${PROBE_UA}`); // Cascades to pro_profiles and audit_log. await db.execute(sql`DELETE FROM users WHERE id = ${pro}`); await db.execute(sql` UPDATE users SET location = NULL, location_text = NULL, search_radius_m = 15000 WHERE id IN (${alice}, ${bob}) `); await closePool(); }); describe('notification preferences', () => { it('returns defaults when the user has never saved any', async () => { const prefs = await callerFor(clientSession(alice)).notification.get(); expect(prefs.smsNewRequest).toBe(true); // Marketing is the one that must default OFF — opt-in, not opt-out. expect(prefs.smsMarketing).toBe(false); expect(prefs.emailMarketing).toBe(false); }); it('a partial update does not reset the preferences it did not mention', async () => { const caller = callerFor(clientSession(alice)); await caller.notification.update({ smsMarketing: true }); await caller.notification.update({ smsNewRequest: false }); const prefs = await caller.notification.get(); expect(prefs.smsMarketing).toBe(true); expect(prefs.smsNewRequest).toBe(false); }); it("one user's preferences are invisible to another", async () => { await callerFor(clientSession(alice)).notification.update({ pushMessages: false }); const bobPrefs = await callerFor(clientSession(bob)).notification.get(); expect(bobPrefs.pushMessages).toBe(true); }); it('rejects an anonymous caller', async () => { await expect(callerFor(null).notification.get()).rejects.toThrow(); }); }); describe('email change', () => { it('does NOT write the address to users.email before it is confirmed', async () => { const caller = callerFor(clientSession(alice)); await caller.user.requestEmailChange({ email: `claimed-${RUN}@example.com` }); const rows = await db.execute<{ count: number }>( sql`SELECT count(*)::int AS count FROM users WHERE email = ${`claimed-${RUN}@example.com`}`, ); // This is the whole point: an unproven address must not occupy the UNIQUE // column, or its real owner can never sign up with Google. expect(rows[0]!.count).toBe(0); }); it('does not reveal whether an address is already registered', async () => { // Requesting someone else's address must look exactly like any other request. await expect( callerFor(clientSession(alice)).user.requestEmailChange({ email: bobEmail }), ).resolves.toMatchObject({ sent: true }); }); it('commits the address once the token comes back', async () => { const caller = callerFor(clientSession(alice)); const { token } = await caller.user.requestEmailChange({ email: `proven-${RUN}@example.com` }); await callerFor(null).user.confirmEmailChange({ token }); const rows = await db.execute<{ email: string; verified: boolean }>( sql`SELECT email, email_verified AS verified FROM users WHERE id = ${alice}`, ); expect(rows[0]!.email).toBe(`proven-${RUN}@example.com`); expect(rows[0]!.verified).toBe(true); }); it('refuses a token twice', async () => { const caller = callerFor(clientSession(alice)); const { token } = await caller.user.requestEmailChange({ email: `once-${RUN}@example.com` }); await callerFor(null).user.confirmEmailChange({ token }); await expect(callerFor(null).user.confirmEmailChange({ token })).rejects.toThrow(); }); it('refuses to commit an address another account already holds', async () => { const { token } = await callerFor(clientSession(alice)).user.requestEmailChange({ email: bobEmail, }); // Only now — ownership proven — is the collision reported. await expect(callerFor(null).user.confirmEmailChange({ token })).rejects.toThrow(/already/i); }); }); describe('sessions', () => { it('never returns another user’s sessions', async () => { await db.execute(sql` INSERT INTO sessions (user_id, token, expires_at, ip_address, user_agent) VALUES (${bob}, ${`bob-token-${RUN}`}, now() + interval '1 day', '10.0.0.1', ${PROBE_UA}) `); const aliceSessions = await callerFor(clientSession(alice)).user.sessions(); expect(aliceSessions.every((s) => s.userAgent !== PROBE_UA)).toBe(true); }); it('never returns the session token', async () => { const rows = await callerFor(clientSession(bob)).user.sessions(); for (const row of rows) { expect(Object.keys(row)).not.toContain('token'); } }); }); describe('deletion request', () => { it('records a request without deleting the user', async () => { const result = await callerFor(clientSession(bob)).user.requestDeletion({ reason: 'testing' }); expect(result.requested).toBe(true); const still = await db.execute<{ count: number }>( sql`SELECT count(*)::int AS count FROM users WHERE id = ${bob}`, ); expect(still[0]!.count).toBe(1); }); it('is idempotent while one is still outstanding', async () => { const second = await callerFor(clientSession(bob)).user.requestDeletion({}); expect(second.alreadyPending).toBe(true); }); }); describe('location and range', () => { it('starts with no pin and the default radius', async () => { const location = await callerFor(clientSession(bob)).user.location(); expect(location.scope).toBe('client'); expect(location.location).toBeNull(); expect(location.radiusM).toBe(15_000); }); it('saves a pin, a label and a radius, and reads them back', async () => { const caller = callerFor(clientSession(alice)); // `device` rather than `place`: a GPS fix is the one source whose // coordinates the server takes at face value, so this test does not need a // geocoder to be configured. await caller.user.updateLocation({ place: { source: 'device', lat: 41.4036, lng: 2.1744, label: 'Gracia, Barcelona' }, radiusM: 8_000, }); const location = await caller.user.location(); expect(location.addressText).toBe('Gracia, Barcelona'); expect(location.radiusM).toBe(8_000); expect(location.location?.lat).toBeCloseTo(41.4036, 4); expect(location.location?.lng).toBeCloseTo(2.1744, 4); }); it('changes only what it was given', async () => { const caller = callerFor(clientSession(alice)); await caller.user.updateLocation({ radiusM: 25_000 }); const location = await caller.user.location(); expect(location.radiusM).toBe(25_000); // The pin saved by the previous test is still there. expect(location.location?.lat).toBeCloseTo(41.4036, 4); }); it('refuses a radius outside the supported range', async () => { const caller = callerFor(clientSession(alice)); await expect(caller.user.updateLocation({ radiusM: 500_000 })).rejects.toThrow(); await expect(caller.user.updateLocation({ radiusM: 10 })).rejects.toThrow(); }); it('refuses an update that says nothing', async () => { await expect(callerFor(clientSession(alice)).user.updateLocation({})).rejects.toThrow(); }); it('never reads or writes another user location', async () => { await callerFor(clientSession(alice)).user.updateLocation({ radiusM: 3_000 }); const bobLocation = await callerFor(clientSession(bob)).user.location(); expect(bobLocation.radiusM).not.toBe(3_000); }); it('rejects an anonymous caller', async () => { await expect(callerFor(null).user.location()).rejects.toThrow(); await expect(callerFor(null).user.updateLocation({ radiusM: 5_000 })).rejects.toThrow(); }); it('reads a pro service area from the profile, not the user row', async () => { // A stray value on the user row must not be what a pro is shown: the deck // matches on the profile, so anything else would display a number that // decides nothing. await db.execute(sql`UPDATE users SET search_radius_m = 1000 WHERE id = ${pro}`); const location = await callerFor(proSession(pro)).user.location(); expect(location.scope).toBe('pro'); expect(location.needsProfile).toBe(false); expect(location.radiusM).toBe(15_000); expect(location.location?.lat).toBeCloseTo(PRO_BASE.lat, 4); expect(location.reviewOnChange).toBe(true); }); it('writes a pro radius to the profile the deck reads', async () => { await callerFor(proSession(pro)).user.updateLocation({ radiusM: 22_000 }); const rows = await db.execute<{ radius: number }>( sql`SELECT service_radius_m AS radius FROM pro_profiles WHERE user_id = ${pro}`, ); expect(rows[0]!.radius).toBe(22_000); }); it('sends a verified pro back for review when the area changes', async () => { await db.execute( sql`UPDATE pro_profiles SET verification_status = 'verified' WHERE user_id = ${pro}`, ); const result = await callerFor(proSession(pro)).user.updateLocation({ radiusM: 30_000 }); expect(result.sentForReview).toBe(true); const rows = await db.execute<{ status: string }>( sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${pro}`, ); // Settings must not become the way around verification. expect(rows[0]!.status).toBe('pending'); const audit = await db.execute<{ count: number }>(sql` SELECT count(*)::int AS count FROM audit_log WHERE actor_id = ${pro} AND action = 'verification.re_review_required' `); expect(audit[0]!.count).toBeGreaterThan(0); }); it('leaves verification alone when nothing material changed', async () => { await db.execute( sql`UPDATE pro_profiles SET verification_status = 'verified' WHERE user_id = ${pro}`, ); // The radius the row already holds, and nothing else. Not material. const result = await callerFor(proSession(pro)).user.updateLocation({ radiusM: 30_000, }); expect(result.sentForReview).toBe(false); const rows = await db.execute<{ status: string }>( sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${pro}`, ); expect(rows[0]!.status).toBe('verified'); }); }); describe('data export', () => { it('returns only the caller’s own rows', async () => { const data = await callerFor(clientSession(alice)).user.exportData(); expect(data.user?.id).toBe(alice); expect(data.jobs.every((j) => j.clientId === alice)).toBe(true); }); });