import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { appRouter } from '../router.ts'; import { resolveSessionUserId, type Context } from '../context.ts'; import { createDb, initSchema } from '../../db/client.ts'; import { createCacheRepository, type Quote, type PriceCandle, type SymbolMeta, type SourceKind } from '../../cache/CacheRepository.ts'; import { FakeSourceAdapter, type SourceFetch } from '../../adapters/SourceAdapter.ts'; import { AdapterQueue } from '../../queue/AdapterQueue.ts'; function setup() { const db = createDb({ path: ':memory:' }); initSchema(db); const fake = new FakeSourceAdapter('yfinance') .set('yfinance:quote:NVDA', { symbol: 'NVDA', price: 194.97, change: 2.44, changePercent: 1.27 } as Quote, 'live_quote') .set('yfinance:candles:NVDA:1d', [{ ts: '2026-06-27', o: 192, h: 196, l: 191, c: 194.97, v: 1.2e8, adjClose: 194.9 }] as PriceCandle[], 'daily_permanent') .set('yfinance:symbol:NVDA', { symbol: 'NVDA', name: 'NVIDIA Corporation', sector: 'Technology', industry: 'Semiconductors', tickerKind: 'equity' } as SymbolMeta, 'symbol_meta'); const adapters = new Map([['yfinance', fake]]); const queue = new AdapterQueue({ db, adapters, rateLimitMs: { yfinance: 0 } }); const cache = createCacheRepository({ db, scheduler: queue }); queue.cache = cache; const freshCtx = (req?: Request): Context => ({ db, cache, resHeaders: new Headers(), userId: req ? resolveSessionUserId(db, req) : null }); return { db, fake, queue, cache, freshCtx }; } const cookieHeader = (res: Headers) => res.get('set-cookie')?.split(';')[0] ?? ''; const reqWithCookie = (cookie: string) => new Request('http://localhost/api/trpc', { headers: { cookie } }); test('signup creates a user + session row and sets a signed cookie', async () => { const { db, freshCtx } = setup(); const ctx = freshCtx(); const caller = appRouter.createCaller(ctx); const res = await caller.auth.signup({ email: 'A@B.CO', password: 'password123' }); assert.ok(res.userId); const u = db.prepare('SELECT email, pw_hash FROM users WHERE id=?').get(res.userId) as { email: string; pw_hash: string }; assert.equal(u.email, 'a@b.co'); // normalized lowercase assert.ok(u.pw_hash.startsWith('scrypt$')); assert.equal((db.prepare('SELECT COUNT(*) AS c FROM sessions').get() as { c: number }).c, 1); assert.ok(ctx.resHeaders.get('set-cookie'), 'cookie must be set'); // session cookie round-trip: resolve userId from the cookie const req = reqWithCookie(cookieHeader(ctx.resHeaders)); assert.equal(resolveSessionUserId(db, req), res.userId); }); test('duplicate signup is CONFLICT', async () => { const { freshCtx } = setup(); const caller = appRouter.createCaller(freshCtx()); await caller.auth.signup({ email: 'a@b.co', password: 'password123' }); await assert.rejects(() => appRouter.createCaller(freshCtx()).auth.signup({ email: 'a@b.co', password: 'password123' }), (e: { code: string }) => e.code === 'CONFLICT'); }); test('login succeeds with correct password; fails UNAUTHORIZED with wrong password', async () => { const { freshCtx } = setup(); await appRouter.createCaller(freshCtx()).auth.signup({ email: 'a@b.co', password: 'password123' }); const ctx = freshCtx(); const caller = appRouter.createCaller(ctx); const res = await caller.auth.login({ email: 'A@B.CO', password: 'password123' }); assert.ok(res.userId); assert.ok(ctx.resHeaders.get('set-cookie')); await assert.rejects(() => caller.auth.login({ email: 'a@b.co', password: 'wrong' }), (e: { code: string }) => e.code === 'UNAUTHORIZED'); }); test('me returns null unauthenticated; prefs when session resolves', async () => { const { freshCtx } = setup(); const signupCtx = freshCtx(); const { userId } = await appRouter.createCaller(signupCtx).auth.signup({ email: 'a@b.co', password: 'password123' }); assert.equal(await appRouter.createCaller(freshCtx()).auth.me(), null); const req = reqWithCookie(cookieHeader(signupCtx.resHeaders)); const me = await appRouter.createCaller(freshCtx(req)).auth.me(); assert.equal(me?.userId, userId); assert.equal(me?.complexity, 'beginner'); }); test('logout clears the session cookie', async () => { const { freshCtx } = setup(); const ctx = freshCtx(); await appRouter.createCaller(ctx).auth.logout(); const c = ctx.resHeaders.get('set-cookie') ?? ''; assert.match(c, /Max-Age=0/); }); test('market.snapshot returns nulls + stale when cache is empty (and queues refreshes)', async () => { const { db, freshCtx } = setup(); const snap = await appRouter.createCaller(freshCtx()).market.snapshot({ symbol: 'nVdA' }); // case-normalized assert.equal(snap.symbol, 'NVDA'); assert.equal(snap.quote, null); assert.equal(snap.candles, null); assert.equal(snap.sector, null); assert.equal(snap.stale.quote && snap.stale.candles && snap.stale.sector, true); assert.equal((db.prepare("SELECT COUNT(*) AS c FROM adapter_queue WHERE status='pending'").get() as { c: number }).c, 3); }); test('market.snapshot serves cached values (not stale) after drain populates cache', async () => { const { queue, freshCtx } = setup(); await appRouter.createCaller(freshCtx()).market.snapshot({ symbol: 'NVDA' }); // queues await queue.drain(); // populates cache from FakeSourceAdapter const snap = await appRouter.createCaller(freshCtx()).market.snapshot({ symbol: 'NVDA' }); assert.equal(snap.quote?.price, 194.97); assert.equal(snap.sector?.sector, 'Technology'); assert.equal(snap.candles?.length, 1); assert.equal(snap.stale.quote, false); assert.equal(snap.stale.candles, false); assert.equal(snap.stale.sector, false); }); // --- Slice 2: 2FA flow --- import { totp as computeTotp } from '../../auth/totp.ts'; test('2FA: enable2fa -> confirm2fa -> login requires totp', async () => { const { freshCtx } = setup(); // signup const signupCtx = freshCtx(); const { userId } = await appRouter.createCaller(signupCtx).auth.signup({ email: 'a@b.co', password: 'password123' }); // enable2fa (protected: set ctx.userId) const eCtx = freshCtx(); eCtx.userId = userId; const en = await appRouter.createCaller(eCtx).auth.enable2fa({}); assert.ok(en.totpSecret); assert.ok(en.qrUrl.startsWith('otpauth://TOTP/')); assert.equal(en.backupCodes.length, 10); // confirm2fa with a valid code const code = computeTotp(en.totpSecret); const cCtx = freshCtx(); cCtx.userId = userId; const c = await appRouter.createCaller(cCtx).auth.confirm2fa({ totp: code }); assert.equal(c.ok, true); // login WITHOUT totp now fails await assert.rejects(() => appRouter.createCaller(freshCtx()).auth.login({ email: 'a@b.co', password: 'password123' }), (e: { code: string }) => e.code === 'UNAUTHORIZED'); // login WITH valid totp succeeds const code2 = computeTotp(en.totpSecret); const res = await appRouter.createCaller(freshCtx()).auth.login({ email: 'a@b.co', password: 'password123', totp: code2 }); assert.equal(res.userId, userId); }); // --- Slice 2b: OAuth (github) --- test('oauth: oauthStart returns a redirect URL + CSRF state cookie; oauthCallback links/creates a user', async () => { const { db, cache } = setup(); process.env.GITHUB_CLIENT_ID = 'gh_id'; process.env.GITHUB_CLIENT_SECRET = 'gh_secret'; type Ctx = { db: typeof db; cache: typeof cache; resHeaders: Headers; userId: string | null; cookies: Record }; const startCtx: Ctx = { db, cache, resHeaders: new Headers(), userId: null, cookies: {} }; const start = await appRouter.createCaller(startCtx).auth.oauthStart({ provider: 'github', redirectUri: 'http://localhost/cb' }); assert.ok(start.redirectUrl.includes('client_id=gh_id')); assert.ok(start.state.length > 0); const setCookie = startCtx.resHeaders.get('set-cookie') ?? ''; const stateVal = setCookie.split(';')[0].slice('iflow_oauth_state='.length); assert.ok(stateVal, 'oauth state cookie set'); const origFetch = global.fetch; let calls = 0; global.fetch = (async (url: unknown) => { calls++; const u = String(url); if (u.includes('/access_token')) return new Response(JSON.stringify({ access_token: 'tok' }), { status: 200, headers: { 'content-type': 'application/json' } }); return new Response(JSON.stringify({ id: 42, email: 'ghuser@x.co', name: 'GH User' }), { status: 200, headers: { 'content-type': 'application/json' } }); }) as typeof fetch; try { const cbCtx: Ctx = { db, cache, resHeaders: new Headers(), userId: null, cookies: { iflow_oauth_state: stateVal } }; // wrong state -> rejected (CSRF) await assert.rejects(() => appRouter.createCaller({ ...cbCtx, cookies: { iflow_oauth_state: 'bogus' } }).auth.oauthCallback({ provider: 'github', code: 'abc', state: start.state, redirectUri: 'http://localhost/cb' }), (e: { code: string }) => e.code === 'BAD_REQUEST'); // correct state -> creates user + session const res = await appRouter.createCaller(cbCtx).auth.oauthCallback({ provider: 'github', code: 'abc', state: start.state, redirectUri: 'http://localhost/cb' }); assert.ok(res.userId); const u = db.prepare('SELECT email,oauth_subject,oauth_provider,pw_hash FROM users WHERE id=?').get(res.userId) as { email: string; oauth_subject: string; oauth_provider: string; pw_hash: string }; assert.equal(u.email, 'ghuser@x.co'); assert.equal(u.oauth_subject, '42'); assert.equal(u.oauth_provider, 'github'); assert.equal(u.pw_hash, 'oauth', 'OAuth-only account has a sentinel pw_hash'); assert.ok(cbCtx.resHeaders.get('set-cookie'), 'session cookie set'); assert.equal(calls, 2, 'token + userinfo fetches'); // second callback with same identity -> reuses the existing linked user (no duplicate) const res2 = await appRouter.createCaller({ ...cbCtx, resHeaders: new Headers() }).auth.oauthCallback({ provider: 'github', code: 'abc2', state: start.state, redirectUri: 'http://localhost/cb' }); assert.equal(res2.userId, res.userId, 'reuses existing linked user'); assert.equal((db.prepare('SELECT COUNT(*) AS c FROM users').get() as { c: number }).c, 1, 'no duplicate users'); } finally { global.fetch = origFetch; delete process.env.GITHUB_CLIENT_ID; delete process.env.GITHUB_CLIENT_SECRET; } }); // --- Slice 3: onboarding --- test('onboarding.complete writes complexity/risk/drawdown + starter watchlist + subscribes demand', async () => { const { db, cache, freshCtx } = setup(); const signupCtx = freshCtx(); const { userId } = await appRouter.createCaller(signupCtx).auth.signup({ email: 'a@b.co', password: 'password123' }); const oCtx = { db, cache, resHeaders: new Headers(), userId, cookies: {} as Record }; const res = await appRouter.createCaller(oCtx).onboarding.complete({ complexity: 'beginner' }); assert.ok(res.watchlistId); const u = db.prepare('SELECT complexity, risk_tolerance, drawdown_tolerance FROM users WHERE id=?').get(userId) as { complexity: string; risk_tolerance: string; drawdown_tolerance: number }; assert.equal(u.complexity, 'beginner'); assert.equal(u.risk_tolerance, 'moderate'); assert.equal(u.drawdown_tolerance, -20); const wl = db.prepare('SELECT symbols FROM watchlists WHERE owner_id=?').get(userId) as { symbols: string }; assert.equal(JSON.parse(wl.symbols).length, 9); const demand = await cache.demandSet(); assert.ok(demand.includes('NVDA') && demand.includes('BTC'), 'starter symbols subscribed to demand set'); });