Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6490feff4 | ||
|
|
91ab9d1f78 |
@@ -26,3 +26,11 @@ paths = ['''apps/api/tests/broker/crypto\.test\.ts''']
|
||||
[[allowlists]]
|
||||
description = "apps/api/tests/routes/setup.test.ts — synthetic VAPID public/private test pair used to set process.env.VAPID_* in the setup-route tests; not a real credential (verified not present in .env)"
|
||||
paths = ['''apps/api/tests/routes/setup\.test\.ts''']
|
||||
|
||||
[[allowlists]]
|
||||
description = "apps/api/tests/auth/localSession.test.ts — TEST_SECRET is a synthetic >=32-char JWT signing secret used only to exercise issue/verify cookie round-trips under Vitest; not a real credential (Phase 19)"
|
||||
paths = ['''apps/api/tests/auth/localSession\.test\.ts''']
|
||||
|
||||
[[allowlists]]
|
||||
description = ".planning/ design docs are internal planning prose (PLAN/SUMMARY/SECURITY/etc.) that frequently discuss credentials, tokens, and auth — they trip generic regex rules (e.g. 'credential atomically, 409-equivalent') but never carry production secrets; not shipped in any image"
|
||||
paths = ['''\.planning/''']
|
||||
|
||||
+5
-6
@@ -4,11 +4,10 @@ milestone: v1.1
|
||||
milestone_name: Operability & Polish
|
||||
current_phase: 999.1
|
||||
current_phase_name: BACKLOG
|
||||
status: executing
|
||||
status: "Phase 19 shipped — PR #23"
|
||||
stopped_at: Phase 19 UI-SPEC approved
|
||||
last_updated: "2026-06-18T00:00:14.867Z"
|
||||
last_activity: 2026-06-18
|
||||
last_activity_desc: Phase 19 complete, transitioned to Phase 999.1
|
||||
last_updated: "2026-06-18T02:49:37.775Z"
|
||||
last_activity: 2026-06-17
|
||||
progress:
|
||||
total_phases: 24
|
||||
completed_phases: 11
|
||||
@@ -30,8 +29,8 @@ See: .planning/PROJECT.md (updated 2026-06-16)
|
||||
|
||||
Phase: 999.1 — Treat Fastmail as one calendar provider; framework supports adding more providers (BACKLOG)
|
||||
Plan: Not started
|
||||
Status: Executing Phase 19
|
||||
Last activity: 2026-06-18 — Phase 19 complete, transitioned to Phase 999.1
|
||||
Status: Phase 19 shipped — PR #23
|
||||
Last activity: 2026-06-17
|
||||
|
||||
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
|
||||
|
||||
|
||||
@@ -45,9 +45,14 @@ const KEY_LEN = 32;
|
||||
function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16);
|
||||
const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
|
||||
return ['scrypt', SCRYPT_N, SCRYPT_R, SCRYPT_P, salt.toString('base64url'), hash.toString('base64url')].join(
|
||||
'$',
|
||||
);
|
||||
return [
|
||||
'scrypt',
|
||||
SCRYPT_N,
|
||||
SCRYPT_R,
|
||||
SCRYPT_P,
|
||||
salt.toString('base64url'),
|
||||
hash.toString('base64url'),
|
||||
].join('$');
|
||||
}
|
||||
|
||||
// ── CLI arg parsing (no new deps — process.argv only) ───────────────────────────────────
|
||||
@@ -109,7 +114,9 @@ if (!dryRun && (!password || password.trim() === '')) {
|
||||
}
|
||||
if (dryRun && !password) {
|
||||
// In dry-run mode a placeholder password is acceptable — skip real validation
|
||||
console.log('[dry-run] Args validated: --username present, --dry-run active (no write will occur)');
|
||||
console.log(
|
||||
'[dry-run] Args validated: --username present, --dry-run active (no write will occur)',
|
||||
);
|
||||
}
|
||||
|
||||
// ── DB connection ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -153,6 +153,9 @@ export function devSessionCookieMiddleware(): MiddlewareHandler {
|
||||
// BL-01: warn loudly if the secret is the well-known dev placeholder. A genuine,
|
||||
// signature-valid session token minted under this known value is trivially forgeable
|
||||
// if the same secret ever leaks into a non-bypass environment.
|
||||
// Not a security comparison — this matches against a PUBLIC well-known placeholder to
|
||||
// emit a warning, so constant-time equality is irrelevant here.
|
||||
// eslint-disable-next-line security/detect-possible-timing-attacks
|
||||
if (secret === 'dev-secret-change-me-0000000000000000') {
|
||||
console.warn(
|
||||
'[devSessionCookieMiddleware] LOCAL_SESSION_SECRET is the well-known dev placeholder. ' +
|
||||
|
||||
@@ -128,7 +128,10 @@ app.get('/callback', async (c) => {
|
||||
return c.redirect('/?error=oidc-link-conflict');
|
||||
}
|
||||
// Unexpected error during link binding — log and continue with normal redirect.
|
||||
console.error('[callback] linkOidcToUser unexpected error:', err instanceof Error ? err.message : String(err));
|
||||
console.error(
|
||||
'[callback] linkOidcToUser unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +196,9 @@ if (!devBypassActive) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// oidcHandler's parameter is typed as the generic Hono Context; our wrapper's c is the
|
||||
// same runtime Context narrowed to '/api/*' — the structural mismatch is type-only.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
await oidcHandler(c, next);
|
||||
});
|
||||
// Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02).
|
||||
|
||||
@@ -140,77 +140,73 @@ const createMemberSchema = z.object({
|
||||
initialPassword: z.string().min(8),
|
||||
});
|
||||
|
||||
adminRouter.post(
|
||||
'/members',
|
||||
zValidator('json', createMemberSchema, noEchoHook),
|
||||
async (c) => {
|
||||
const { displayName, username, initialPassword } = c.req.valid('json');
|
||||
// T-19-06: NEVER log request body, displayName, username, or initialPassword here
|
||||
adminRouter.post('/members', zValidator('json', createMemberSchema, noEchoHook), async (c) => {
|
||||
const { displayName, username, initialPassword } = c.req.valid('json');
|
||||
// T-19-06: NEVER log request body, displayName, username, or initialPassword here
|
||||
|
||||
// Assign the first palette color not already in use (mirrors upsertUser color logic)
|
||||
const usedRows = await db.select({ color: users.color }).from(users);
|
||||
const usedColors = new Set(usedRows.map((r) => r.color));
|
||||
const color =
|
||||
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
|
||||
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
|
||||
// Assign the first palette color not already in use (mirrors upsertUser color logic)
|
||||
const usedRows = await db.select({ color: users.color }).from(users);
|
||||
const usedColors = new Set(usedRows.map((r) => r.color));
|
||||
const color =
|
||||
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
|
||||
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
|
||||
|
||||
// WR-03: hash the initial password BEFORE opening the transaction so the (now async,
|
||||
// threadpool) scrypt work does not hold the DB transaction open for its duration.
|
||||
const initialPasswordHash = await hashPassword(initialPassword);
|
||||
// WR-03: hash the initial password BEFORE opening the transaction so the (now async,
|
||||
// threadpool) scrypt work does not hold the DB transaction open for its duration.
|
||||
const initialPasswordHash = await hashPassword(initialPassword);
|
||||
|
||||
try {
|
||||
let newUserId: number;
|
||||
try {
|
||||
let newUserId: number;
|
||||
|
||||
// T-19-10: atomic transaction — both inserts succeed or both roll back
|
||||
await db.transaction(async (tx) => {
|
||||
// Insert the new users row (no oidcIss/oidcSub — local-only member)
|
||||
const [inserted] = await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
displayName,
|
||||
color,
|
||||
isAdmin: false,
|
||||
claimed: false, // no OIDC identity bound yet
|
||||
})
|
||||
.$returningId();
|
||||
newUserId = inserted.id;
|
||||
// T-19-10: atomic transaction — both inserts succeed or both roll back
|
||||
await db.transaction(async (tx) => {
|
||||
// Insert the new users row (no oidcIss/oidcSub — local-only member)
|
||||
const [inserted] = await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
displayName,
|
||||
color,
|
||||
isAdmin: false,
|
||||
claimed: false, // no OIDC identity bound yet
|
||||
})
|
||||
.$returningId();
|
||||
newUserId = inserted.id;
|
||||
|
||||
// Insert local_credentials row with hashed initial password
|
||||
// If username is already in use, the UNIQUE constraint fires here and rolls back
|
||||
await tx.insert(localCredentials).values({
|
||||
userId: newUserId,
|
||||
username,
|
||||
passwordHash: initialPasswordHash,
|
||||
});
|
||||
// Insert local_credentials row with hashed initial password
|
||||
// If username is already in use, the UNIQUE constraint fires here and rolls back
|
||||
await tx.insert(localCredentials).values({
|
||||
userId: newUserId,
|
||||
username,
|
||||
passwordHash: initialPasswordHash,
|
||||
});
|
||||
});
|
||||
|
||||
// Return the new user's id (the PWA uses it to navigate to the member)
|
||||
return c.json({ id: newUserId! }, 201);
|
||||
} catch (err) {
|
||||
// Username uniqueness violation — UNIQUE constraint on local_credentials.username.
|
||||
// Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY.
|
||||
const isDup =
|
||||
(err instanceof Error && err.message.includes('ER_DUP_ENTRY')) ||
|
||||
(err != null &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
(err as { code?: string }).code === 'ER_DUP_ENTRY') ||
|
||||
(err != null &&
|
||||
typeof err === 'object' &&
|
||||
'cause' in err &&
|
||||
(err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY');
|
||||
if (isDup) {
|
||||
return c.json({ error: 'Username already in use' }, 409);
|
||||
}
|
||||
// Unexpected errors — log message only, never the body or password
|
||||
console.error(
|
||||
'[admin/POST /members] Unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
// Return the new user's id (the PWA uses it to navigate to the member)
|
||||
return c.json({ id: newUserId! }, 201);
|
||||
} catch (err) {
|
||||
// Username uniqueness violation — UNIQUE constraint on local_credentials.username.
|
||||
// Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY.
|
||||
const isDup =
|
||||
(err instanceof Error && err.message.includes('ER_DUP_ENTRY')) ||
|
||||
(err != null &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
(err as { code?: string }).code === 'ER_DUP_ENTRY') ||
|
||||
(err != null &&
|
||||
typeof err === 'object' &&
|
||||
'cause' in err &&
|
||||
(err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY');
|
||||
if (isDup) {
|
||||
return c.json({ error: 'Username already in use' }, 409);
|
||||
}
|
||||
},
|
||||
);
|
||||
// Unexpected errors — log message only, never the body or password
|
||||
console.error(
|
||||
'[admin/POST /members] Unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/admin/members/:id/password
|
||||
|
||||
@@ -186,7 +186,10 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
|
||||
.limit(1);
|
||||
cred = found;
|
||||
} catch (err) {
|
||||
console.error('[localAuth/POST /local/login] DB error:', err instanceof Error ? err.message : String(err));
|
||||
console.error(
|
||||
'[localAuth/POST /local/login] DB error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
|
||||
@@ -200,7 +203,12 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
|
||||
|
||||
if (!valid || !cred) {
|
||||
// Increment failure counter (keyed on username)
|
||||
const cur = loginAttempts.get(key) ?? { count: 0, lockedUntil: 0, lockedOut: false, lockedAt: 0 };
|
||||
const cur = loginAttempts.get(key) ?? {
|
||||
count: 0,
|
||||
lockedUntil: 0,
|
||||
lockedOut: false,
|
||||
lockedAt: 0,
|
||||
};
|
||||
cur.count += 1;
|
||||
cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000;
|
||||
cur.lockedOut = cur.count >= LOCKOUT_FAILURES;
|
||||
@@ -215,7 +223,10 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
|
||||
try {
|
||||
await issueLocalSessionCookie(c, cred.userId);
|
||||
} catch (err) {
|
||||
console.error('[localAuth/POST /local/login] Cookie issue error:', err instanceof Error ? err.message : String(err));
|
||||
console.error(
|
||||
'[localAuth/POST /local/login] Cookie issue error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
return c.json({ ok: true }, 200);
|
||||
@@ -226,7 +237,7 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
|
||||
// GET /local/logout → GET /api/auth/local/logout (browser-redirect alias)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleLogout(c: Context) {
|
||||
function handleLogout(c: Context) {
|
||||
clearLocalSessionCookie(c);
|
||||
return c.json({ ok: true }, 200);
|
||||
}
|
||||
|
||||
+47
-47
@@ -105,7 +105,9 @@ meRouter.get('/', async (c) => {
|
||||
// but isAdmin, needsProviderSetup, and hasLocalCredential are still resolved from the DB (T-10-05).
|
||||
const devUser = c.get('user');
|
||||
if (devUser) {
|
||||
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(devUser.id);
|
||||
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(
|
||||
devUser.id,
|
||||
);
|
||||
return c.json({
|
||||
user: {
|
||||
id: devUser.id,
|
||||
@@ -141,7 +143,9 @@ meRouter.get('/', async (c) => {
|
||||
return c.json({ error: 'Could not resolve user' }, 500);
|
||||
}
|
||||
|
||||
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(user.id);
|
||||
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(
|
||||
user.id,
|
||||
);
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
@@ -232,57 +236,53 @@ const mePasswordSchema = z.object({
|
||||
newPassword: z.string().min(8),
|
||||
});
|
||||
|
||||
meRouter.post(
|
||||
'/password',
|
||||
zValidator('json', mePasswordSchema, meNoEchoHook),
|
||||
async (c) => {
|
||||
// T-19-07: ALWAYS resolve userId from session — never from body
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (!currentUserId) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
meRouter.post('/password', zValidator('json', mePasswordSchema, meNoEchoHook), async (c) => {
|
||||
// T-19-07: ALWAYS resolve userId from session — never from body
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (!currentUserId) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = c.req.valid('json');
|
||||
// T-19-06: NEVER log currentPassword, newPassword, or the request body
|
||||
const { currentPassword, newPassword } = c.req.valid('json');
|
||||
// T-19-06: NEVER log currentPassword, newPassword, or the request body
|
||||
|
||||
// Look up the user's local_credentials row (404 if none — no local credential to change)
|
||||
const [credRow] = await db
|
||||
.select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId })
|
||||
.from(localCredentials)
|
||||
.where(eq(localCredentials.userId, currentUserId))
|
||||
.limit(1);
|
||||
// Look up the user's local_credentials row (404 if none — no local credential to change)
|
||||
const [credRow] = await db
|
||||
.select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId })
|
||||
.from(localCredentials)
|
||||
.where(eq(localCredentials.userId, currentUserId))
|
||||
.limit(1);
|
||||
|
||||
if (!credRow) {
|
||||
return c.json({ error: 'No local credential found' }, 404);
|
||||
}
|
||||
if (!credRow) {
|
||||
return c.json({ error: 'No local credential found' }, 404);
|
||||
}
|
||||
|
||||
// T-19-07: verify current password before any update (WR-03: async scrypt)
|
||||
const isCorrect = await verifyPassword(credRow.passwordHash, currentPassword);
|
||||
if (!isCorrect) {
|
||||
// CR-03: return 403 (NOT 401) for a wrong current password. The PWA's global
|
||||
// MutationCache treats any 401 as "session expired" and arms the re-auth
|
||||
// interstitial / login redirect — so a 401 here would force-log-out a user who
|
||||
// merely mistyped their current password. 403 is in-app authorization-failure and
|
||||
// lets the client surface "current password incorrect" without dropping the session.
|
||||
return c.json({ error: 'Current password incorrect' }, 403);
|
||||
}
|
||||
// T-19-07: verify current password before any update (WR-03: async scrypt)
|
||||
const isCorrect = await verifyPassword(credRow.passwordHash, currentPassword);
|
||||
if (!isCorrect) {
|
||||
// CR-03: return 403 (NOT 401) for a wrong current password. The PWA's global
|
||||
// MutationCache treats any 401 as "session expired" and arms the re-auth
|
||||
// interstitial / login redirect — so a 401 here would force-log-out a user who
|
||||
// merely mistyped their current password. 403 is in-app authorization-failure and
|
||||
// lets the client surface "current password incorrect" without dropping the session.
|
||||
return c.json({ error: 'Current password incorrect' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(localCredentials)
|
||||
.set({ passwordHash: await hashPassword(newPassword) })
|
||||
.where(eq(localCredentials.userId, currentUserId));
|
||||
try {
|
||||
await db
|
||||
.update(localCredentials)
|
||||
.set({ passwordHash: await hashPassword(newPassword) })
|
||||
.where(eq(localCredentials.userId, currentUserId));
|
||||
|
||||
return c.json({ ok: true }, 200);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[me/POST /password] Unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
},
|
||||
);
|
||||
return c.json({ ok: true }, 200);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[me/POST /password] Unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/me/link-oidc — initiate OIDC identity-link flow (AUTH-LOCAL-10, T-19-09)
|
||||
|
||||
@@ -24,7 +24,13 @@
|
||||
|
||||
import { afterEach } from 'vitest';
|
||||
import { db } from '../src/db/client.js';
|
||||
import { lists, listItems, listShares, pushSubscriptions, localCredentials } from '../src/db/schema.js';
|
||||
import {
|
||||
lists,
|
||||
listItems,
|
||||
listShares,
|
||||
pushSubscriptions,
|
||||
localCredentials,
|
||||
} from '../src/db/schema.js';
|
||||
|
||||
/**
|
||||
* Truncate list and push tables in FK-safe order after each test.
|
||||
|
||||
@@ -73,7 +73,6 @@ function makeApp(middleware: ReturnType<typeof vi.fn>, presetUser?: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
app.use('/api/*', middleware());
|
||||
|
||||
let capturedUser: unknown = 'NOT_SET_SENTINEL';
|
||||
@@ -147,7 +146,13 @@ describe('localAuthMiddleware', () => {
|
||||
const res = await app.request('/api/test');
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedUser).toBeDefined();
|
||||
const u = capturedUser as { id: number; oidcIss: string; oidcSub: string; displayName: string | null; color: string };
|
||||
const u = capturedUser as {
|
||||
id: number;
|
||||
oidcIss: string;
|
||||
oidcSub: string;
|
||||
displayName: string | null;
|
||||
color: string;
|
||||
};
|
||||
expect(u.id).toBe(7);
|
||||
expect(u.oidcIss).toBe('https://auth.example.com');
|
||||
expect(u.oidcSub).toBe('sub-abc');
|
||||
|
||||
@@ -18,33 +18,6 @@ import { Hono } from 'hono';
|
||||
const TEST_SECRET = 'test-secret-that-is-at-least-32-characters-long-for-jwt';
|
||||
const TEST_USER_ID = 42;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Create a minimal Hono test app with an issue route and a verify route. */
|
||||
function makeTestApp(secret: string | undefined) {
|
||||
return {
|
||||
setup: async () => {
|
||||
// Import inside function to pick up modified env
|
||||
const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import(
|
||||
'../../src/auth/localSession.js'
|
||||
);
|
||||
const app = new Hono();
|
||||
|
||||
app.post('/issue', async (c) => {
|
||||
await issueLocalSessionCookie(c, TEST_USER_ID);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/verify', async (c) => {
|
||||
const userId = await verifyLocalSessionCookie(c);
|
||||
return c.json({ userId });
|
||||
});
|
||||
|
||||
return app;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -60,9 +33,8 @@ describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => {
|
||||
});
|
||||
|
||||
it('Test 1: issue then verify round-trips userId', async () => {
|
||||
const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import(
|
||||
'../../src/auth/localSession.js'
|
||||
);
|
||||
const { issueLocalSessionCookie, verifyLocalSessionCookie } =
|
||||
await import('../../src/auth/localSession.js');
|
||||
const app = new Hono();
|
||||
|
||||
app.post('/issue', async (c) => {
|
||||
|
||||
@@ -30,7 +30,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../../src/db/client.js';
|
||||
import { users, memberCredentials, calendars, appConfig, localCredentials } from '../../src/db/schema.js';
|
||||
import {
|
||||
users,
|
||||
memberCredentials,
|
||||
calendars,
|
||||
appConfig,
|
||||
localCredentials,
|
||||
} from '../../src/db/schema.js';
|
||||
import { verifyPassword } from '../../src/auth/localCredentials.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -949,7 +955,8 @@ describe('POST /api/admin/members', () => {
|
||||
|
||||
it('Test 3: admin can reset any member password without knowing the current one', async () => {
|
||||
const adminId = await seedUser('admin-reset-pw', true);
|
||||
const memberId = await seedUser('member-reset-target', false);
|
||||
// Seeded for DB-state parity; this test creates its own member via the admin API below.
|
||||
await seedUser('member-reset-target', false);
|
||||
currentDevUserId = adminId;
|
||||
const app = await getApp();
|
||||
|
||||
@@ -1025,7 +1032,9 @@ describe('POST /api/admin/members', () => {
|
||||
// GET /members should show hasLocalCredential:true for this member
|
||||
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
||||
expect(getRes.status).toBe(200);
|
||||
const body = (await getRes.json()) as { members: Array<{ id: number; hasLocalCredential: boolean }> };
|
||||
const body = (await getRes.json()) as {
|
||||
members: Array<{ id: number; hasLocalCredential: boolean }>;
|
||||
};
|
||||
|
||||
const memberRow = body.members.find((m) => m.id === newMemberId);
|
||||
expect(memberRow).toBeDefined();
|
||||
|
||||
@@ -47,15 +47,13 @@ vi.mock('@hono/oidc-auth', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../src/auth/devBypass.js', () => ({
|
||||
devAuthBypass:
|
||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
||||
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
|
||||
localAuthMiddleware:
|
||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
localAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -34,9 +34,9 @@ vi.mock('../../src/db/client.js', () => ({
|
||||
select: vi.fn().mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(mockCredRow ? [mockCredRow] : [])
|
||||
),
|
||||
limit: vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(mockCredRow ? [mockCredRow] : [])),
|
||||
}),
|
||||
}),
|
||||
})),
|
||||
@@ -57,14 +57,12 @@ let issuedUserId: number | null = null;
|
||||
let clearSessionCalled = false;
|
||||
|
||||
vi.mock('../../src/auth/localSession.js', () => ({
|
||||
issueLocalSessionCookie: vi.fn().mockImplementation(
|
||||
(_c: unknown, userId: number) => {
|
||||
issueSessionCalled = true;
|
||||
issuedUserId = userId;
|
||||
// Simulate setting a cookie on the context
|
||||
return Promise.resolve();
|
||||
}
|
||||
),
|
||||
issueLocalSessionCookie: vi.fn().mockImplementation((_c: unknown, userId: number) => {
|
||||
issueSessionCalled = true;
|
||||
issuedUserId = userId;
|
||||
// Simulate setting a cookie on the context
|
||||
return Promise.resolve();
|
||||
}),
|
||||
clearLocalSessionCookie: vi.fn().mockImplementation((_c: unknown) => {
|
||||
clearSessionCalled = true;
|
||||
}),
|
||||
@@ -76,15 +74,13 @@ vi.mock('../../src/auth/localSession.js', () => ({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('../../src/auth/devBypass.js', () => ({
|
||||
devAuthBypass:
|
||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
||||
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
|
||||
localAuthMiddleware:
|
||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
localAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
}));
|
||||
|
||||
vi.mock('@hono/oidc-auth', () => ({
|
||||
@@ -195,7 +191,9 @@ describe('POST /api/auth/local/login', () => {
|
||||
mockCredRow = undefined; // No credential row found
|
||||
|
||||
const app = await getApp();
|
||||
const res = await app.fetch(makeLoginRequest({ username: 'unknown-user', password: 'anypassword' }));
|
||||
const res = await app.fetch(
|
||||
makeLoginRequest({ username: 'unknown-user', password: 'anypassword' }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as { error: string };
|
||||
@@ -212,14 +210,14 @@ describe('POST /api/auth/local/login', () => {
|
||||
// 5 failures to trigger the rate window
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const res = await app.fetch(
|
||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1')
|
||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1'),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
}
|
||||
|
||||
// 6th attempt from same IP → 429
|
||||
const res6 = await app.fetch(
|
||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1')
|
||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1'),
|
||||
);
|
||||
expect(res6.status).toBe(429);
|
||||
const body = (await res6.json()) as { error: string };
|
||||
@@ -233,14 +231,12 @@ describe('POST /api/auth/local/login', () => {
|
||||
|
||||
// 10 failures from same IP → lockout
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await app.fetch(
|
||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
|
||||
);
|
||||
await app.fetch(makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'));
|
||||
}
|
||||
|
||||
// 11th attempt → 423 (locked)
|
||||
const res11 = await app.fetch(
|
||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
|
||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'),
|
||||
);
|
||||
expect(res11.status).toBe(423);
|
||||
const body = (await res11.json()) as { error: string };
|
||||
@@ -252,7 +248,7 @@ describe('POST /api/auth/local/login', () => {
|
||||
loginAttempts.delete('alice');
|
||||
|
||||
const resAfterReset = await app.fetch(
|
||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
|
||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'),
|
||||
);
|
||||
expect(resAfterReset.status).toBe(401);
|
||||
});
|
||||
@@ -295,7 +291,7 @@ describe('POST /api/auth/local/login', () => {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '1.2.3.4' },
|
||||
body: JSON.stringify({ username: 'mysecretusername' }),
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
@@ -319,7 +315,7 @@ describe('POST /api/auth/local/logout', () => {
|
||||
new Request('http://localhost/api/auth/local/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'x-forwarded-for': '1.2.3.4' },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
@@ -336,7 +332,7 @@ describe('GET /api/auth/local/logout', () => {
|
||||
new Request('http://localhost/api/auth/local/logout', {
|
||||
method: 'GET',
|
||||
headers: { 'x-forwarded-for': '1.2.3.4' },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -290,16 +290,20 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]),
|
||||
}),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
// fallback for other selects
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
@@ -346,7 +350,9 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]),
|
||||
}),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
@@ -354,7 +360,9 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
@@ -386,13 +394,18 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
||||
it('Test 3: user with no local_credentials row → 404', async () => {
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
|
||||
vi.mocked(db.select).mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any));
|
||||
vi.mocked(db.select).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
);
|
||||
|
||||
const { app } = await import('../../src/index.js');
|
||||
const res = await app.request('/api/me/password', {
|
||||
@@ -457,7 +470,9 @@ describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
@@ -483,7 +498,9 @@ describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
@@ -520,14 +537,14 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
||||
let deletedLocalCreds = false;
|
||||
|
||||
// Mock: SELECT users WHERE oidc_iss = iss AND oidc_sub = sub → no conflict (empty)
|
||||
let txSelectCount = 0;
|
||||
const mockTx = {
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
txSelectCount++;
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // no conflict
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}),
|
||||
@@ -545,18 +562,25 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
||||
})),
|
||||
};
|
||||
|
||||
vi.mocked(db.select).mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any));
|
||||
vi.mocked(db.select).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise<void>) => {
|
||||
await fn(mockTx);
|
||||
});
|
||||
vi.mocked(db).transaction = vi
|
||||
.fn()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.mockImplementation(async (fn: (tx: any) => Promise<void>) => {
|
||||
await fn(mockTx);
|
||||
});
|
||||
|
||||
await linkOidcToUser(42, iss, sub);
|
||||
|
||||
@@ -574,15 +598,20 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
||||
let deletedLocalCreds = false;
|
||||
|
||||
// Preflight SELECT finds a conflicting user (id=99, different from target=42)
|
||||
vi.mocked(db.select).mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict!
|
||||
}),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any));
|
||||
vi.mocked(db.select).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict!
|
||||
}),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
);
|
||||
|
||||
// Transaction should NEVER be called on conflict
|
||||
const mockTx = {
|
||||
@@ -593,10 +622,12 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
||||
}),
|
||||
})),
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise<void>) => {
|
||||
await fn(mockTx);
|
||||
});
|
||||
vi.mocked(db).transaction = vi
|
||||
.fn()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.mockImplementation(async (fn: (tx: any) => Promise<void>) => {
|
||||
await fn(mockTx);
|
||||
});
|
||||
|
||||
// Should throw OidcLinkConflictError, not proceed to transaction
|
||||
await expect(linkOidcToUser(42, iss, sub)).rejects.toThrow(OidcLinkConflictError);
|
||||
@@ -611,13 +642,20 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
|
||||
// Mock db — not needed for route shape test but avoids errors
|
||||
vi.mocked(db.select).mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any));
|
||||
vi.mocked(db.select).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi
|
||||
.fn()
|
||||
.mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
);
|
||||
|
||||
const { app } = await import('../../src/index.js');
|
||||
const res = await app.request('/api/me/link-oidc', {
|
||||
@@ -630,7 +668,8 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
||||
const body = (await res.json()) as { authorizationUrl?: string; state?: string };
|
||||
// The response must have at minimum a signedState field (or authorizationUrl)
|
||||
// — the exact shape depends on implementation; assert it's an object with a useful field
|
||||
const hasInitiationPayload = 'authorizationUrl' in body || 'state' in body || 'signedState' in body;
|
||||
const hasInitiationPayload =
|
||||
'authorizationUrl' in body || 'state' in body || 'signedState' in body;
|
||||
expect(hasInitiationPayload).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
* pnpm --filter @familysync/pwa test:e2e --grep "login"
|
||||
* pnpm --filter @familysync/pwa exec playwright test --project=desktop login.spec.ts
|
||||
*/
|
||||
import { test, expect, type BrowserContext } from '@playwright/test';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Selectors derived from 19-UI-SPEC.md Surfaces 3-7 (locked by plan 04 implementation)
|
||||
const SELECTORS = {
|
||||
@@ -43,32 +43,6 @@ const SELECTORS = {
|
||||
errorMessage: '[role="status"]',
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an unauthenticated browser context by clearing all cookies and storage.
|
||||
* The devSessionCookieMiddleware issues a new local-session cookie on each API
|
||||
* request, so we need to clear the cookie from the BROWSER side. Navigating to
|
||||
* a page that clears the cookie header is the reliable approach in Playwright.
|
||||
*/
|
||||
async function makeUnauthContext(
|
||||
context: BrowserContext,
|
||||
baseURL: string,
|
||||
): Promise<void> {
|
||||
// Clear all cookies (removes the local-session cookie set by prior API calls)
|
||||
await context.clearCookies();
|
||||
// Also clear localStorage/sessionStorage to avoid any cached auth state
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
// Navigate somewhere to gain origin access, then clear storage
|
||||
await page.goto(baseURL, { waitUntil: 'domcontentloaded', timeout: 10_000 }).catch(() => {});
|
||||
await page.evaluate(() => {
|
||||
try { localStorage.clear(); } catch { /* cross-origin or unavailable */ }
|
||||
try { sessionStorage.clear(); } catch { /* cross-origin or unavailable */ }
|
||||
});
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Only run these specs on the desktop profile. The login form is a standard web
|
||||
// page (not PWA-specific) and Chromium handles cookies most consistently for this test.
|
||||
// iphone/pixel still reach the authed app via the bypass-issued cookie (unchanged behavior).
|
||||
@@ -78,9 +52,7 @@ test.describe('Login form — real auth round-trip (desktop/Chromium only)', ()
|
||||
'Login form tests only run on Chromium (desktop profile) — other profiles use the bypass cookie',
|
||||
);
|
||||
|
||||
test('/login renders all brand + form surfaces (UI-SPEC Surfaces 2-7)', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('/login renders all brand + form surfaces (UI-SPEC Surfaces 2-7)', async ({ page }) => {
|
||||
// Navigate DIRECTLY to /login rather than asserting an unauthenticated root→/login
|
||||
// redirect: under the always-on DEV_AUTH_BYPASS, /api/me is authed via DEV_USER
|
||||
// injection regardless of the cookie, so visiting / lands on /calendar and a
|
||||
@@ -129,10 +101,7 @@ test.describe('Login form — real auth round-trip (desktop/Chromium only)', ()
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test('correct devuser/devpass logs in and navigates out of /login', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test('correct devuser/devpass logs in and navigates out of /login', async ({ page, context }) => {
|
||||
await context.clearCookies();
|
||||
|
||||
await page.goto('/login', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
@@ -195,10 +195,7 @@ export default function App() {
|
||||
Phase 19: shown when the user is unauthenticated AND localEnabled === true.
|
||||
The route itself always renders LoginPage (authMode gating is in the `*` route gate below).
|
||||
LoginPage receives authMode so it can show the optional OIDC button when oidcEnabled. */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={<LoginPage authMode={authModeQuery.data} />}
|
||||
/>
|
||||
<Route path="/login" element={<LoginPage authMode={authModeQuery.data} />} />
|
||||
|
||||
{/* All other routes are gated on setup completion */}
|
||||
<Route
|
||||
@@ -213,7 +210,10 @@ export default function App() {
|
||||
) : meQuery.isError && !meQuery.isLoading && authModeQuery.data?.localEnabled ? (
|
||||
// Unauthenticated + localEnabled: redirect to /login
|
||||
<Navigate to="/login" replace />
|
||||
) : meQuery.isError && !meQuery.isLoading && !authModeQuery.data?.localEnabled && authModeQuery.data?.oidcEnabled ? (
|
||||
) : meQuery.isError &&
|
||||
!meQuery.isLoading &&
|
||||
!authModeQuery.data?.localEnabled &&
|
||||
authModeQuery.data?.oidcEnabled ? (
|
||||
// Unauthenticated + OIDC-only mode: top-level redirect to /api/login (today's behavior)
|
||||
// Use a render side-effect via useEffect isn't available here; use a helper element
|
||||
<OidcRedirect />
|
||||
|
||||
@@ -103,10 +103,7 @@ export async function fetchAuthMode(): Promise<{ localEnabled: boolean; oidcEnab
|
||||
*
|
||||
* Throws nothing on 200 OK — the local-session cookie is set by the server.
|
||||
*/
|
||||
export async function fetchLocalLogin(body: {
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<void> {
|
||||
export async function fetchLocalLogin(body: { username: string; password: string }): Promise<void> {
|
||||
const res = await fetch('/api/auth/local/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -28,7 +28,14 @@ vi.mock('../hooks/usePushSubscription.js', () => ({
|
||||
// Mock client so the test doesn't make real network calls.
|
||||
vi.mock('../api/client.js', () => ({
|
||||
fetchMe: vi.fn().mockResolvedValue({
|
||||
user: { id: 1, displayName: 'Test', color: '#4a90d9', isAdmin: false, needsProviderSetup: false, hasLocalCredential: false },
|
||||
user: {
|
||||
id: 1,
|
||||
displayName: 'Test',
|
||||
color: '#4a90d9',
|
||||
isAdmin: false,
|
||||
needsProviderSetup: false,
|
||||
hasLocalCredential: false,
|
||||
},
|
||||
}),
|
||||
fetchAuthMode: vi.fn().mockResolvedValue({ localEnabled: true, oidcEnabled: false }),
|
||||
fetchChangePassword: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -500,10 +500,7 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
||||
|
||||
{/* Surface 13 — Link OIDC confirmation sheet (hasLocalCredential + oidcEnabled gate) */}
|
||||
{linkOidcOpen && (
|
||||
<LinkOidcSheet
|
||||
isOpen={linkOidcOpen}
|
||||
onClose={() => setLinkOidcOpen(false)}
|
||||
/>
|
||||
<LinkOidcSheet isOpen={linkOidcOpen} onClose={() => setLinkOidcOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -935,7 +932,9 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
|
||||
color: 'var(--color-text-secondary, #6b7280)',
|
||||
}}
|
||||
>
|
||||
{"After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed."}
|
||||
{
|
||||
"After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed."
|
||||
}
|
||||
</p>
|
||||
|
||||
{/* Secondary note */}
|
||||
|
||||
@@ -1241,8 +1241,7 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps
|
||||
});
|
||||
|
||||
const isPending = resetMutation.isPending;
|
||||
const submitDisabled =
|
||||
isPending || newPassword.length === 0 || confirmPassword.length === 0;
|
||||
const submitDisabled = isPending || newPassword.length === 0 || confirmPassword.length === 0;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
||||
@@ -146,10 +146,7 @@ export function LoginPage({ authMode }: LoginPageProps) {
|
||||
const isLoading = loginMutation.isPending;
|
||||
const bothNonEmpty = username.trim().length > 0 && password.length > 0;
|
||||
const submitDisabled =
|
||||
isLoading ||
|
||||
!bothNonEmpty ||
|
||||
loginError === 'rate-limit' ||
|
||||
loginError === 'locked';
|
||||
isLoading || !bothNonEmpty || loginError === 'rate-limit' || loginError === 'locked';
|
||||
|
||||
// Derive whether inputs should show error state
|
||||
const inputHasError = loginError === 'invalid';
|
||||
|
||||
@@ -94,11 +94,11 @@
|
||||
* never the BrandSlot component structure (see 19-UI-SPEC.md §Brand Slot).
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
--brand-logo-bg: var(--color-member-0); /* placeholder circle background */
|
||||
--brand-logo-text: #ffffff; /* placeholder initials color */
|
||||
--brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */
|
||||
--brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */
|
||||
--brand-app-name: 'FamilySync'; /* drives doc only — not used as CSS content */
|
||||
--brand-logo-bg: var(--color-member-0); /* placeholder circle background */
|
||||
--brand-logo-text: #ffffff; /* placeholder initials color */
|
||||
--brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */
|
||||
--brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */
|
||||
--brand-app-name: 'FamilySync'; /* drives doc only — not used as CSS content */
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* BREAKPOINTS (reference; use in @media queries)
|
||||
|
||||
Reference in New Issue
Block a user