2026-06-29 17:40:42 -04:00
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 < SourceKind , SourceFetch >([[ '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 );
});
2026-06-29 19:32:09 -04:00
// --- 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 );
});
2026-06-29 21:17:57 -04:00
// --- 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 < string , string > };
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 ;
}
});
2026-06-29 21:31:59 -04:00
// --- 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 < string , string > };
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' );
});