fix(12): satisfy CI fast-checks — lint unused vars, typed contract-test body, prettier
CI / changes (pull_request) Successful in 3s
CI / fast-checks (pull_request) Successful in 2m16s
CI / api (pull_request) Failing after 1m37s
CI / harness (pull_request) Failing after 1h3m45s
CI / security (pull_request) Failing after 11s
CI / gate (pull_request) Failing after 1s

- Remove unused 'res'/'container' assignments (no-unused-vars)
- setupClient.contract.test.ts: typed parseSentBody helper + non-async json mock
  (no-unsafe-*/require-await)
- Prettier format 7 setup files

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Berger
2026-06-16 16:53:14 -04:00
co-authored by Claude Opus 4.8
parent f485b38324
commit a193bc8236
7 changed files with 172 additions and 132 deletions
+9 -3
View File
@@ -89,15 +89,21 @@ export async function oidcConfigFallbackMiddleware(c: Context, next: Next): Prom
// container is already running, the in-process value is stale until restart. // container is already running, the in-process value is stale until restart.
// A container restart is required to pick up any changed OIDC config values. // A container restart is required to pick up any changed OIDC config values.
if (key === 'oidc_issuer') { if (key === 'oidc_issuer') {
console.info('[oidcFallback] Writing OIDC_ISSUER from app_config — container restart required to update this value'); console.info(
'[oidcFallback] Writing OIDC_ISSUER from app_config — container restart required to update this value',
);
process.env.OIDC_ISSUER = row.value; process.env.OIDC_ISSUER = row.value;
} }
if (key === 'oidc_client_id') { if (key === 'oidc_client_id') {
console.info('[oidcFallback] Writing OIDC_CLIENT_ID from app_config — container restart required to update this value'); console.info(
'[oidcFallback] Writing OIDC_CLIENT_ID from app_config — container restart required to update this value',
);
process.env.OIDC_CLIENT_ID = row.value; process.env.OIDC_CLIENT_ID = row.value;
} }
if (key === 'app_external_url') { if (key === 'app_external_url') {
console.info('[oidcFallback] Writing OIDC_AUTH_EXTERNAL_URL from app_config — container restart required to update this value'); console.info(
'[oidcFallback] Writing OIDC_AUTH_EXTERNAL_URL from app_config — container restart required to update this value',
);
process.env.OIDC_AUTH_EXTERNAL_URL = row.value; process.env.OIDC_AUTH_EXTERNAL_URL = row.value;
} }
} }
+31 -20
View File
@@ -148,7 +148,10 @@ setupRouter.post('/validate/db', async (c) => {
await db.execute(sql`SELECT 1`); await db.execute(sql`SELECT 1`);
return c.json({ ok: true }, 200); return c.json({ ok: true }, 200);
} catch (err) { } catch (err) {
console.error('[setup/validate/db] DB round-trip failed:', err instanceof Error ? err.message : String(err)); console.error(
'[setup/validate/db] DB round-trip failed:',
err instanceof Error ? err.message : String(err),
);
return c.json({ ok: false, error: 'DB unavailable' }, 503); return c.json({ ok: false, error: 'DB unavailable' }, 503);
} }
}); });
@@ -217,7 +220,10 @@ setupRouter.post('/validate/vapid', async (c) => {
const publicKey = process.env.VAPID_PUBLIC_KEY ?? ''; const publicKey = process.env.VAPID_PUBLIC_KEY ?? '';
if (!privateKey || !publicKey) { if (!privateKey || !publicKey) {
return c.json({ ok: false, error: 'VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY env vars must be set' }, 400); return c.json(
{ ok: false, error: 'VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY env vars must be set' },
400,
);
} }
// Gap 2: assert the operator-submitted public key matches the env public key BEFORE // Gap 2: assert the operator-submitted public key matches the env public key BEFORE
@@ -230,11 +236,14 @@ setupRouter.post('/validate/vapid', async (c) => {
const submittedPublicKey = submittedRow?.value; const submittedPublicKey = submittedRow?.value;
if (!submittedPublicKey || submittedPublicKey !== publicKey) { if (!submittedPublicKey || submittedPublicKey !== publicKey) {
return c.json({ return c.json(
{
ok: false, ok: false,
error: error:
'VAPID public key does not match the configured key pair. Paste the exact VAPID_PUBLIC_KEY printed by `npm run generate-secrets`.', 'VAPID public key does not match the configured key pair. Paste the exact VAPID_PUBLIC_KEY printed by `npm run generate-secrets`.',
}, 400); },
400,
);
} }
try { try {
@@ -248,10 +257,13 @@ setupRouter.post('/validate/vapid', async (c) => {
); );
return c.json({ ok: true }, 200); return c.json({ ok: true }, 200);
} catch (err) { } catch (err) {
return c.json({ return c.json(
{
ok: false, ok: false,
error: err instanceof Error ? err.message : 'VAPID validation failed', error: err instanceof Error ? err.message : 'VAPID validation failed',
}, 400); },
400,
);
} }
}); });
@@ -298,7 +310,9 @@ setupRouter.post('/credential', zValidator('json', credentialSchema, noEchoHook)
)) as unknown as [{ count: string | number }[], unknown]; )) as unknown as [{ count: string | number }[], unknown];
const unclaimedCount = Number(countRows[0][0]?.count ?? 0); const unclaimedCount = Number(countRows[0][0]?.count ?? 0);
if (unclaimedCount > 0) { if (unclaimedCount > 0) {
throw Object.assign(new Error('An unclaimed user already exists'), { code: 'DUPLICATE_UNCLAIMED' }); throw Object.assign(new Error('An unclaimed user already exists'), {
code: 'DUPLICATE_UNCLAIMED',
});
} }
// Step 1: Assign color (first unused from palette, or round-robin fallback) // Step 1: Assign color (first unused from palette, or round-robin fallback)
@@ -322,20 +336,22 @@ setupRouter.post('/credential', zValidator('json', credentialSchema, noEchoHook)
}) })
.$returningId(); .$returningId();
const [row] = await tx const [row] = await tx.select().from(users).where(eq(users.id, inserted.id)).limit(1);
.select()
.from(users)
.where(eq(users.id, inserted.id))
.limit(1);
return row; return row;
}); });
} catch (err) { } catch (err) {
if (err instanceof Error && (err as NodeJS.ErrnoException & { code?: string }).code === 'DUPLICATE_UNCLAIMED') { if (
err instanceof Error &&
(err as NodeJS.ErrnoException & { code?: string }).code === 'DUPLICATE_UNCLAIMED'
) {
// A concurrent request already created the unclaimed admin row // A concurrent request already created the unclaimed admin row
return c.json({ error: 'Setup already in progress' }, 409); return c.json({ error: 'Setup already in progress' }, 409);
} }
console.error('[setup/POST /credential] Transaction error:', err instanceof Error ? err.message : String(err)); console.error(
'[setup/POST /credential] Transaction error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503); return c.json({ error: 'Service unavailable' }, 503);
} }
@@ -353,12 +369,7 @@ setupRouter.post('/credential', zValidator('json', credentialSchema, noEchoHook)
// Step 3: Validate + encrypt + store the credential via the shared helper (D-09) // Step 3: Validate + encrypt + store the credential via the shared helper (D-09)
// The user row MUST exist before this call (Pitfall 5 — FK constraint). // The user row MUST exist before this call (Pitfall 5 — FK constraint).
try { try {
await validateEncryptAndStoreCredential( await validateEncryptAndStoreCredential(localUser.id, fastmailEmail, appPassword, 'caldav');
localUser.id,
fastmailEmail,
appPassword,
'caldav',
);
} catch (err) { } catch (err) {
// Roll back the local user insert on credential failure to avoid orphaned rows // Roll back the local user insert on credential failure to avoid orphaned rows
await db.delete(users).where(eq(users.id, localUser.id)); await db.delete(users).where(eq(users.id, localUser.id));
+5 -7
View File
@@ -203,7 +203,8 @@ async function seedCredential(userId: number): Promise<void> {
// Env and DB cleanup between tests // Env and DB cleanup between tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const VAPID_PUBLIC_KEY = 'BKO9RLPqxNQ7GOLHsQ5kFXqMkfJBfElkd5h9ECQ0gRu7R5SJP6Ct5GkRuPxqY1u0UVY84_z7JGJsLhO-wChk_sE'; const VAPID_PUBLIC_KEY =
'BKO9RLPqxNQ7GOLHsQ5kFXqMkfJBfElkd5h9ECQ0gRu7R5SJP6Ct5GkRuPxqY1u0UVY84_z7JGJsLhO-wChk_sE';
const VAPID_PRIVATE_KEY = 'QEkqsrxLqpv_ynpCECWFLYlOxCEGd-_O5u0AXzY_qEY'; const VAPID_PRIVATE_KEY = 'QEkqsrxLqpv_ynpCECWFLYlOxCEGd-_O5u0AXzY_qEY';
beforeEach(async () => { beforeEach(async () => {
@@ -223,9 +224,7 @@ beforeEach(async () => {
afterEach(async () => { afterEach(async () => {
// Clean up seeded rows between tests // Clean up seeded rows between tests
await db.delete(memberCredentials); await db.delete(memberCredentials);
await db await db.delete(users).where(eq(users.oidcIss, 'https://auth.test.setup'));
.delete(users)
.where(eq(users.oidcIss, 'https://auth.test.setup'));
// Delete local (wizard-created) users with null oidcIss // Delete local (wizard-created) users with null oidcIss
// We identify them by displayName prefix for safety // We identify them by displayName prefix for safety
// Use raw delete of unclaimed users (the test-seeded ones) // Use raw delete of unclaimed users (the test-seeded ones)
@@ -632,9 +631,8 @@ describe('/api/setup/* — 423 when effectively configured (D-10 effective-confi
process.env.VAPID_PUBLIC_KEY = VAPID_PUBLIC_KEY; process.env.VAPID_PUBLIC_KEY = VAPID_PUBLIC_KEY;
const app = await getApp(); const app = await getApp();
const res = await app.fetch(jsonRequest('GET', '/api/setup/status')); // The /status route always returns the status; the guard kicks in on mutation
// status route always returns the status — but guard kicks in on mutation routes // routes. Test a mutation route to confirm 423.
// Test a mutation route to confirm 423
const completeRes = await app.fetch(jsonRequest('POST', '/api/setup/complete')); const completeRes = await app.fetch(jsonRequest('POST', '/api/setup/complete'));
expect(completeRes.status).toBe(423); expect(completeRes.status).toBe(423);
}); });
+12 -4
View File
@@ -636,7 +636,9 @@ export async function validateSetupDb(): Promise<void> {
if (res.status === 423) throw new SetupAlreadyLockedError(); if (res.status === 423) throw new SetupAlreadyLockedError();
if (!res.ok) { if (!res.ok) {
throw new Error('Cannot reach the database. Check DB_HOST, DB_PORT, DB_USER, DB_PASSWORD in your Docker environment and try again.'); throw new Error(
'Cannot reach the database. Check DB_HOST, DB_PORT, DB_USER, DB_PASSWORD in your Docker environment and try again.',
);
} }
} }
@@ -650,7 +652,9 @@ export async function validateSetupOidc(): Promise<void> {
if (res.status === 423) throw new SetupAlreadyLockedError(); if (res.status === 423) throw new SetupAlreadyLockedError();
if (!res.ok) { if (!res.ok) {
throw new Error('OIDC discovery failed. Check the issuer URL and that Authelia is reachable from the server.'); throw new Error(
'OIDC discovery failed. Check the issuer URL and that Authelia is reachable from the server.',
);
} }
} }
@@ -663,7 +667,9 @@ export async function validateSetupVapid(): Promise<void> {
if (res.status === 423) throw new SetupAlreadyLockedError(); if (res.status === 423) throw new SetupAlreadyLockedError();
if (!res.ok) { if (!res.ok) {
throw new Error('VAPID validation failed. Check that your VAPID keys were generated with `npm run generate-secrets`.'); throw new Error(
'VAPID validation failed. Check that your VAPID keys were generated with `npm run generate-secrets`.',
);
} }
} }
@@ -686,7 +692,9 @@ export async function postSetupCredential(payload: SetupCredentialPayload): Prom
if (res.status === 423) throw new SetupAlreadyLockedError(); if (res.status === 423) throw new SetupAlreadyLockedError();
if (!res.ok) { if (!res.ok) {
throw new Error("Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again."); throw new Error(
"Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again.",
);
} }
} }
+41 -26
View File
@@ -20,7 +20,12 @@
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { postSetupConfig, postSetupCredential, type SetupConfigPayload, type SetupCredentialPayload } from './client.js'; import {
postSetupConfig,
postSetupCredential,
type SetupConfigPayload,
type SetupCredentialPayload,
} from './client.js';
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -28,11 +33,21 @@ function mockFetchResponse(body: unknown, status = 200): Response {
return { return {
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,
status, status,
json: async () => body, json: () => Promise.resolve(body),
clone: function () { return this as unknown as Response; }, clone: function () {
return this as unknown as Response;
},
} as unknown as Response; } as unknown as Response;
} }
// Extract the JSON body sent on the first fetch call as a typed record, so the
// strict typed-lint rules (no-unsafe-*) are satisfied without scattering casts.
function parseSentBody(spy: ReturnType<typeof vi.fn>): Record<string, unknown> {
const calls = spy.mock.calls as unknown as Array<[unknown, RequestInit]>;
const init = calls[0][1];
return JSON.parse(init.body as string) as Record<string, unknown>;
}
// Build a canonical camelCase payload using the contract type. // Build a canonical camelCase payload using the contract type.
// If SetupConfigPayload still has snake_case fields, TypeScript will error here // If SetupConfigPayload still has snake_case fields, TypeScript will error here
// (the interface and this test agree on the camelCase contract). // (the interface and this test agree on the camelCase contract).
@@ -61,7 +76,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
await postSetupConfig(VALID_PAYLOAD); await postSetupConfig(VALID_PAYLOAD);
const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); const body = parseSentBody(fetchSpy);
expect(body).toHaveProperty('oidcIssuer', 'https://auth.example.com'); expect(body).toHaveProperty('oidcIssuer', 'https://auth.example.com');
expect(body).not.toHaveProperty('oidc_issuer'); expect(body).not.toHaveProperty('oidc_issuer');
}); });
@@ -70,7 +85,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
await postSetupConfig(VALID_PAYLOAD); await postSetupConfig(VALID_PAYLOAD);
const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); const body = parseSentBody(fetchSpy);
expect(body).toHaveProperty('oidcClientId', 'familysync'); expect(body).toHaveProperty('oidcClientId', 'familysync');
expect(body).not.toHaveProperty('oidc_client_id'); expect(body).not.toHaveProperty('oidc_client_id');
}); });
@@ -79,7 +94,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
await postSetupConfig(VALID_PAYLOAD); await postSetupConfig(VALID_PAYLOAD);
const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); const body = parseSentBody(fetchSpy);
expect(body).toHaveProperty('vapidPublicKey', 'BH_example_public_key'); expect(body).toHaveProperty('vapidPublicKey', 'BH_example_public_key');
expect(body).not.toHaveProperty('vapid_public_key'); expect(body).not.toHaveProperty('vapid_public_key');
}); });
@@ -88,7 +103,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
await postSetupConfig(VALID_PAYLOAD); await postSetupConfig(VALID_PAYLOAD);
const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); const body = parseSentBody(fetchSpy);
expect(body).toHaveProperty('appExternalUrl', 'https://familysync.example.com'); expect(body).toHaveProperty('appExternalUrl', 'https://familysync.example.com');
expect(body).not.toHaveProperty('app_url'); expect(body).not.toHaveProperty('app_url');
}); });
@@ -97,7 +112,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
await postSetupConfig(VALID_PAYLOAD); await postSetupConfig(VALID_PAYLOAD);
const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); const body = parseSentBody(fetchSpy);
expect(Object.keys(body).sort()).toEqual( expect(Object.keys(body).sort()).toEqual(
['appExternalUrl', 'oidcClientId', 'oidcIssuer', 'vapidPublicKey'].sort(), ['appExternalUrl', 'oidcClientId', 'oidcIssuer', 'vapidPublicKey'].sort(),
); );
@@ -125,15 +140,17 @@ describe('postSetupConfig — error rendering (BUG 2 regression)', () => {
error: { error: {
name: 'ZodError', name: 'ZodError',
issues: [ issues: [
{ code: 'invalid_string', message: 'oidcIssuer must be an https URL', path: ['oidcIssuer'] }, {
code: 'invalid_string',
message: 'oidcIssuer must be an https URL',
path: ['oidcIssuer'],
},
], ],
}, },
}; };
fetchSpy.mockResolvedValueOnce(mockFetchResponse(zodErrorBody, 400)); fetchSpy.mockResolvedValueOnce(mockFetchResponse(zodErrorBody, 400));
await expect( await expect(postSetupConfig(VALID_PAYLOAD)).rejects.toSatisfy((err: Error) => {
postSetupConfig(VALID_PAYLOAD),
).rejects.toSatisfy((err: Error) => {
// The error message must NOT be "[object Object]" // The error message must NOT be "[object Object]"
expect(err.message).not.toBe('[object Object]'); expect(err.message).not.toBe('[object Object]');
// The error message must be a non-empty string // The error message must be a non-empty string
@@ -149,31 +166,29 @@ describe('postSetupConfig — error rendering (BUG 2 regression)', () => {
error: { error: {
name: 'ZodError', name: 'ZodError',
issues: [ issues: [
{ code: 'invalid_string', message: 'oidcIssuer must be an https URL', path: ['oidcIssuer'] }, {
code: 'invalid_string',
message: 'oidcIssuer must be an https URL',
path: ['oidcIssuer'],
},
], ],
}, },
}; };
fetchSpy.mockResolvedValueOnce(mockFetchResponse(zodErrorBody, 400)); fetchSpy.mockResolvedValueOnce(mockFetchResponse(zodErrorBody, 400));
await expect( await expect(postSetupConfig(VALID_PAYLOAD)).rejects.toThrow('oidcIssuer must be an https URL');
postSetupConfig(VALID_PAYLOAD),
).rejects.toThrow('oidcIssuer must be an https URL');
}); });
it('throws a fallback status error when error body is empty', async () => { it('throws a fallback status error when error body is empty', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse({}, 400)); fetchSpy.mockResolvedValueOnce(mockFetchResponse({}, 400));
await expect( await expect(postSetupConfig(VALID_PAYLOAD)).rejects.toThrow('400');
postSetupConfig(VALID_PAYLOAD),
).rejects.toThrow('400');
}); });
it('returns void on 200 success', async () => { it('returns void on 200 success', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
await expect( await expect(postSetupConfig(VALID_PAYLOAD)).resolves.toBeUndefined();
postSetupConfig(VALID_PAYLOAD),
).resolves.toBeUndefined();
}); });
}); });
@@ -202,7 +217,7 @@ describe('postSetupCredential — payload contract (CR-01 regression)', () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
await postSetupCredential(VALID_CREDENTIAL_PAYLOAD); await postSetupCredential(VALID_CREDENTIAL_PAYLOAD);
const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); const body = parseSentBody(fetchSpy);
expect(Object.keys(body).sort()).toEqual(['appPassword', 'fastmailEmail'].sort()); expect(Object.keys(body).sort()).toEqual(['appPassword', 'fastmailEmail'].sort());
}); });
@@ -210,7 +225,7 @@ describe('postSetupCredential — payload contract (CR-01 regression)', () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
await postSetupCredential(VALID_CREDENTIAL_PAYLOAD); await postSetupCredential(VALID_CREDENTIAL_PAYLOAD);
const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); const body = parseSentBody(fetchSpy);
expect(body).not.toHaveProperty('providerType'); expect(body).not.toHaveProperty('providerType');
}); });
@@ -218,7 +233,7 @@ describe('postSetupCredential — payload contract (CR-01 regression)', () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
await postSetupCredential(VALID_CREDENTIAL_PAYLOAD); await postSetupCredential(VALID_CREDENTIAL_PAYLOAD);
const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); const body = parseSentBody(fetchSpy);
expect(body).toHaveProperty('fastmailEmail', 'user@fastmail.com'); expect(body).toHaveProperty('fastmailEmail', 'user@fastmail.com');
}); });
@@ -226,7 +241,7 @@ describe('postSetupCredential — payload contract (CR-01 regression)', () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
await postSetupCredential(VALID_CREDENTIAL_PAYLOAD); await postSetupCredential(VALID_CREDENTIAL_PAYLOAD);
const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); const body = parseSentBody(fetchSpy);
expect(body).toHaveProperty('appPassword', 'secret-app-password'); expect(body).toHaveProperty('appPassword', 'secret-app-password');
}); });
}); });
+34 -13
View File
@@ -51,7 +51,10 @@ function makeQueryClient() {
}); });
} }
function renderSetupPage(queryClient: QueryClient, props: React.ComponentProps<typeof SetupPage> = {}) { function renderSetupPage(
queryClient: QueryClient,
props: React.ComponentProps<typeof SetupPage> = {},
) {
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter> <MemoryRouter>
@@ -149,7 +152,7 @@ describe('SetupPage — Welcome step', () => {
}); });
it('has aria-live region for validation status', async () => { it('has aria-live region for validation status', async () => {
const { container } = renderSetupPage(queryClient); renderSetupPage(queryClient);
// The step 2 config form has aria-live; step 1 welcome step does not yet show validation // The step 2 config form has aria-live; step 1 welcome step does not yet show validation
// but the structure has it via the general error block. We advance to step 2 to test. // but the structure has it via the general error block. We advance to step 2 to test.
// For now we verify that once rendered, the component tree has the correct aria-live // For now we verify that once rendered, the component tree has the correct aria-live
@@ -239,15 +242,20 @@ describe('SetupPage — Step 2 VAPID validation (CR-01 gap)', () => {
const { postSetupConfig } = await import('../api/client.js'); const { postSetupConfig } = await import('../api/client.js');
(postSetupConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); (postSetupConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
fireEvent.change(screen.getByLabelText('App URL'), { target: { value: 'https://app.example.com' } }); fireEvent.change(screen.getByLabelText('App URL'), {
fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { target: { value: 'https://auth.example.com' } }); target: { value: 'https://app.example.com' },
});
fireEvent.change(screen.getByLabelText('OIDC issuer URL'), {
target: { value: 'https://auth.example.com' },
});
fireEvent.change(screen.getByLabelText('OIDC client ID'), { target: { value: 'familysync' } }); fireEvent.change(screen.getByLabelText('OIDC client ID'), { target: { value: 'familysync' } });
fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } }); fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } });
fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' })); fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' }));
} }
it('calls validateSetupVapid after DB and OIDC pass in step 2', async () => { it('calls validateSetupVapid after DB and OIDC pass in step 2', async () => {
const { validateSetupDb, validateSetupOidc, validateSetupVapid } = await import('../api/client.js'); const { validateSetupDb, validateSetupOidc, validateSetupVapid } =
await import('../api/client.js');
(validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); (validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); (validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); (validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
@@ -261,7 +269,8 @@ describe('SetupPage — Step 2 VAPID validation (CR-01 gap)', () => {
}); });
it('renders a VAPID validation row after step 2 validation completes', async () => { it('renders a VAPID validation row after step 2 validation completes', async () => {
const { validateSetupDb, validateSetupOidc, validateSetupVapid } = await import('../api/client.js'); const { validateSetupDb, validateSetupOidc, validateSetupVapid } =
await import('../api/client.js');
(validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); (validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); (validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); (validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
@@ -276,11 +285,14 @@ describe('SetupPage — Step 2 VAPID validation (CR-01 gap)', () => {
}); });
it('does NOT show Continue when VAPID validation fails', async () => { it('does NOT show Continue when VAPID validation fails', async () => {
const { validateSetupDb, validateSetupOidc, validateSetupVapid } = await import('../api/client.js'); const { validateSetupDb, validateSetupOidc, validateSetupVapid } =
await import('../api/client.js');
(validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); (validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); (validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupVapid as ReturnType<typeof vi.fn>).mockRejectedValue( (validateSetupVapid as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('VAPID validation failed. Check that your VAPID keys were generated with `npm run generate-secrets`.') new Error(
'VAPID validation failed. Check that your VAPID keys were generated with `npm run generate-secrets`.',
),
); );
await advanceToStep2(); await advanceToStep2();
@@ -296,7 +308,8 @@ describe('SetupPage — Step 2 VAPID validation (CR-01 gap)', () => {
}); });
it('shows Continue only when db, oidc, AND vapid all pass', async () => { it('shows Continue only when db, oidc, AND vapid all pass', async () => {
const { validateSetupDb, validateSetupOidc, validateSetupVapid } = await import('../api/client.js'); const { validateSetupDb, validateSetupOidc, validateSetupVapid } =
await import('../api/client.js');
(validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); (validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); (validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); (validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
@@ -360,8 +373,12 @@ describe('SetupPage — Step 2 Instance copy + DB-name field (gaps 1, 3)', () =>
(validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); (validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
await advanceToStep2(); await advanceToStep2();
fireEvent.change(screen.getByLabelText('App URL'), { target: { value: 'https://app.example.com' } }); fireEvent.change(screen.getByLabelText('App URL'), {
fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { target: { value: 'https://auth.example.com' } }); target: { value: 'https://app.example.com' },
});
fireEvent.change(screen.getByLabelText('OIDC issuer URL'), {
target: { value: 'https://auth.example.com' },
});
fireEvent.change(screen.getByLabelText('OIDC client ID'), { target: { value: 'familysync' } }); fireEvent.change(screen.getByLabelText('OIDC client ID'), { target: { value: 'familysync' } });
fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } }); fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } });
fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' })); fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' }));
@@ -403,8 +420,12 @@ describe('SetupPage — Back navigation preserves Instance fields (gap 4)', () =
fireEvent.click(await screen.findByRole('button', { name: 'Continue' })); fireEvent.click(await screen.findByRole('button', { name: 'Continue' }));
await screen.findByText('Instance Configuration'); await screen.findByText('Instance Configuration');
fireEvent.change(screen.getByLabelText('App URL'), { target: { value: 'https://app.example.com' } }); fireEvent.change(screen.getByLabelText('App URL'), {
fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { target: { value: 'https://auth.example.com' } }); target: { value: 'https://app.example.com' },
});
fireEvent.change(screen.getByLabelText('OIDC issuer URL'), {
target: { value: 'https://auth.example.com' },
});
fireEvent.change(screen.getByLabelText('OIDC client ID'), { target: { value: 'familysync' } }); fireEvent.change(screen.getByLabelText('OIDC client ID'), { target: { value: 'familysync' } });
fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } }); fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } });
fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' })); fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' }));
+22 -41
View File
@@ -175,14 +175,8 @@ function StepIndicator({ currentStep }: StepIndicatorProps) {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
background: background: isCompleted || isActive ? 'var(--color-member-0, #4a90d9)' : 'transparent',
isCompleted || isActive border: isCompleted || isActive ? 'none' : '2px solid var(--color-border, #e2e4e9)',
? 'var(--color-member-0, #4a90d9)'
: 'transparent',
border:
isCompleted || isActive
? 'none'
: '2px solid var(--color-border, #e2e4e9)',
color: isCompleted || isActive ? '#ffffff' : 'var(--color-text-muted, #9ca3af)', color: isCompleted || isActive ? '#ffffff' : 'var(--color-text-muted, #9ca3af)',
fontSize: 'var(--text-label-size, 13px)', fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600, fontWeight: 600,
@@ -220,11 +214,7 @@ function StepIndicator({ currentStep }: StepIndicatorProps) {
)} )}
<div style={{ ...circleStyle, zIndex: 1, position: 'relative' }}> <div style={{ ...circleStyle, zIndex: 1, position: 'relative' }}>
{isCompleted ? ( {isCompleted ? <CheckCircle size={16} aria-hidden="true" /> : <span>{stepNum}</span>}
<CheckCircle size={16} aria-hidden="true" />
) : (
<span>{stepNum}</span>
)}
</div> </div>
<span <span
@@ -383,12 +373,10 @@ function Step1Welcome({ onContinue, stepHeadingRef }: Step1Props) {
lineHeight: 1.5, lineHeight: 1.5,
}} }}
> >
This wizard will guide you through configuring your self-hosted instance. Before This wizard will guide you through configuring your self-hosted instance. Before continuing,
continuing, run{' '} run <code>npm run generate-secrets</code> from the repo to generate your instance secrets
<code>npm run generate-secrets</code>{' '} and add them to your Docker environment. You&apos;ll also need: your OIDC client credentials
from the repo to generate your instance secrets and add them to your Docker environment. (Authelia) and a Fastmail account with an app password. This takes about 5 minutes.
You&apos;ll also need: your OIDC client credentials (Authelia) and a Fastmail account
with an app password. This takes about 5 minutes.
</p> </p>
{/* Before you start note block */} {/* Before you start note block */}
@@ -465,7 +453,9 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S
}); });
const dbName = setupStatus?.dbName ?? ''; const dbName = setupStatus?.dbName ?? '';
const [validationRows, setValidationRows] = useState<Pick<ValidationRowStatus, 'db' | 'oidc' | 'vapid'>>({ const [validationRows, setValidationRows] = useState<
Pick<ValidationRowStatus, 'db' | 'oidc' | 'vapid'>
>({
db: 'idle', db: 'idle',
oidc: 'idle', oidc: 'idle',
vapid: 'idle', vapid: 'idle',
@@ -518,9 +508,7 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S
} }
}, },
onError: (err) => { onError: (err) => {
setFieldError( setFieldError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
err instanceof Error ? err.message : 'Something went wrong. Please try again.',
);
}, },
}); });
@@ -654,9 +642,7 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S
placeholder="familysync" placeholder="familysync"
style={inputStyle(false)} style={inputStyle(false)}
/> />
<div style={helperStyle}> <div style={helperStyle}>The client ID registered in Authelia for this application.</div>
The client ID registered in Authelia for this application.
</div>
</div> </div>
{/* VAPID Public Key */} {/* VAPID Public Key */}
@@ -673,8 +659,7 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S
style={inputStyle(false)} style={inputStyle(false)}
/> />
<div style={helperStyle}> <div style={helperStyle}>
Paste the <code>VAPID_PUBLIC_KEY</code> value from{' '} Paste the <code>VAPID_PUBLIC_KEY</code> value from <code>npm run generate-secrets</code>.
<code>npm run generate-secrets</code>.
</div> </div>
</div> </div>
@@ -721,7 +706,10 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S
/> />
{/* General field error (before validation rows show) */} {/* General field error (before validation rows show) */}
{fieldError && validationRows.db === 'idle' && validationRows.oidc === 'idle' && validationRows.vapid === 'idle' && ( {fieldError &&
validationRows.db === 'idle' &&
validationRows.oidc === 'idle' &&
validationRows.vapid === 'idle' && (
<div <div
role="status" role="status"
aria-live="polite" aria-live="polite"
@@ -806,15 +794,12 @@ function Step3Credential({ onBack, onSuccess, onLocked, stepHeadingRef }: Step3P
onLocked(); onLocked();
return; return;
} }
setErrorText( setErrorText(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
err instanceof Error ? err.message : 'Something went wrong. Please try again.',
);
}, },
}); });
const isPending = completeMutation.isPending || finalMutation.isPending; const isPending = completeMutation.isPending || finalMutation.isPending;
const saveDisabled = const saveDisabled = isPending || email.trim().length === 0 || password.trim().length === 0;
isPending || email.trim().length === 0 || password.trim().length === 0;
function handleValidate() { function handleValidate() {
if (saveDisabled) return; if (saveDisabled) return;
@@ -853,9 +838,8 @@ function Step3Credential({ onBack, onSuccess, onLocked, stepHeadingRef }: Step3P
lineHeight: 1.5, lineHeight: 1.5,
}} }}
> >
Add the Fastmail app password for the first household member. This credential is Add the Fastmail app password for the first household member. This credential is validated
validated against Fastmail CalDAV before saving. The password is never stored in plain against Fastmail CalDAV before saving. The password is never stored in plain text.
text.
</p> </p>
{/* Fastmail email */} {/* Fastmail email */}
@@ -1177,10 +1161,7 @@ export function SetupPage({ alreadyLocked = false }: SetupPageProps) {
{/* Step card (Surface 3) */} {/* Step card (Surface 3) */}
{step === 1 && ( {step === 1 && (
<Step1Welcome <Step1Welcome onContinue={() => setStep(2)} stepHeadingRef={stepHeadingRef} />
onContinue={() => setStep(2)}
stepHeadingRef={stepHeadingRef}
/>
)} )}
{step === 2 && ( {step === 2 && (
<Step2Config <Step2Config