fix(19): satisfy CI fast-checks + secret scan
CI / changes (pull_request) Successful in 9s
CI / api (pull_request) Successful in 3m2s
CI / fast-checks (pull_request) Successful in 4m20s
CI / security (pull_request) Successful in 1m14s
CI / harness (pull_request) Successful in 6m56s
CI / gate (pull_request) Successful in 2s
CI / changes (pull_request) Successful in 9s
CI / api (pull_request) Successful in 3m2s
CI / fast-checks (pull_request) Successful in 4m20s
CI / security (pull_request) Successful in 1m14s
CI / harness (pull_request) Successful in 6m56s
CI / gate (pull_request) Successful in 2s
Lint (eslint --max-warnings 0): - index.ts: disable no-unsafe-argument on the type-only Context mismatch when delegating to the OIDC handler inside the local-session skip wrapper - localAuth.ts: handleLogout is sync (no await) — drop async (require-await) - devBypass.ts: disable detect-possible-timing-attacks on the public well-known dev-placeholder string compare (not a secret comparison) - remove dead code / unused bindings flagged by no-unused-vars: makeTestApp (localSession.test), makeUnauthContext + BrowserContext import (login.spec), unused memberId (admin.test), unused txSelectCount counter (me.test) - localAuthMiddleware.test / me.test: fix unused + reflow-detached eslint-disable directives Format: prettier --write across the 20 Phase-19 files that were never formatted. Secret scan (gitleaks): allowlist two false positives — the synthetic >=32-char TEST_SECRET in localSession.test.ts, and .planning/ design prose (a generic-api-key regex hit on "credential atomically, 409-equivalent"). Neither is a real secret. Verified locally: format:check, lint, typecheck, md:lint, gitleaks (no leaks), PWA 266/266, API 452/452. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
91ab9d1f78
commit
b6490feff4
@@ -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