fix-backend-review-findings (ornith-35): oauth empty-token guard, session-secret startup assertion, OAuth email-link test

Fixes from ornith's backend code review, implemented by ornith-35 (cross-review by
qwopus35b pending). 129/129 tests green.
This commit is contained in:
Investor Flow Build
2026-06-29 23:33:02 -04:00
parent a303720c35
commit 2c7e7a0786
6 changed files with 526 additions and 0 deletions
@@ -173,6 +173,51 @@ test('oauth: oauthStart returns a redirect URL + CSRF state cookie; oauthCallbac
}
});
// --- Slice 2c: OAuth — link existing account by email ---
test('oauthCallback links an OAuth identity to an existing user when emails match', 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> };
// Step 1: sign up with email "link-me@example.com" (creates a password-based account).
const signupCtx: Ctx = { db, cache, resHeaders: new Headers(), userId: null, cookies: {} };
const signup = await appRouter.createCaller(signupCtx).auth.signup({ email: 'link-me@example.com', password: 'password123' });
assert.ok(signup.userId);
// Step 2: oauthStart to get a valid CSRF state + redirect URL.
const start = await appRouter.createCaller(signupCtx).auth.oauthStart({ provider: 'github', redirectUri: 'http://localhost/cb' });
const setCookie = signupCtx.resHeaders.get('set-cookie') ?? '';
// The oauth state cookie may be the 2nd Set-Cookie header; find it by prefix.
const stateMatch = setCookie.match(/iflow_oauth_state=([^;]+)/);
assert.ok(stateMatch, 'oauth state cookie should be set');
const stateVal = stateMatch[1];
// Step 3: oauthCallback with the SAME email from the provider -> should link, NOT create a new row.
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: 999, email: 'link-me@example.com', name: 'Link 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 } };
const res = await appRouter.createCaller(cbCtx).auth.oauthCallback({ provider: 'github', code: 'abc', state: start.state, redirectUri: 'http://localhost/cb' });
assert.equal(res.userId, signup.userId, 'should return the EXISTING user id');
const u = db.prepare('SELECT oauth_subject,oauth_provider,pw_hash FROM users WHERE id=?').get(signup.userId) as { oauth_subject: string; oauth_provider: string; pw_hash: string };
assert.equal(u.oauth_subject, '999', 'oauth_subject updated');
assert.equal(u.oauth_provider, 'github', 'oauth_provider updated');
assert.ok(u.pw_hash.startsWith('scrypt$'), 'original pw_hash preserved as scrypt hash (linked account)');
assert.ok(cbCtx.resHeaders.get('set-cookie'), 'session cookie set');
// No duplicate rows: exactly 1 user.
assert.equal((db.prepare('SELECT COUNT(*) AS c FROM users').get() as { c: number }).c, 1, 'no duplicate user created');
} 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();
+6
View File
@@ -7,6 +7,12 @@ import type { CacheRepository } from '../cache/CacheRepository.ts';
export const SESSION_COOKIE = 'iflow_session';
export const OAUTH_STATE_COOKIE = 'iflow_oauth_state';
// Fail-fast: refuse to run with the unsafe default secret outside dev.
const isDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === undefined;
if (!process.env.IFLOW_SESSION_SECRET && !isDev) {
throw new Error('IFLOW_SESSION_SECRET is not set and NODE_ENV is not "development". The default dev secret must never be used in production.');
}
const SESSION_SECRET = process.env.IFLOW_SESSION_SECRET ?? 'dev-secret-change-me';
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days