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
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:
co-authored by
Claude Opus 4.8
parent
f485b38324
commit
a193bc8236
@@ -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.
|
||||
// A container restart is required to pick up any changed OIDC config values.
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,10 @@ setupRouter.post('/validate/db', async (c) => {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return c.json({ ok: true }, 200);
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
@@ -217,7 +220,10 @@ setupRouter.post('/validate/vapid', async (c) => {
|
||||
const publicKey = process.env.VAPID_PUBLIC_KEY ?? '';
|
||||
|
||||
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
|
||||
@@ -230,11 +236,14 @@ setupRouter.post('/validate/vapid', async (c) => {
|
||||
|
||||
const submittedPublicKey = submittedRow?.value;
|
||||
if (!submittedPublicKey || submittedPublicKey !== publicKey) {
|
||||
return c.json({
|
||||
return c.json(
|
||||
{
|
||||
ok: false,
|
||||
error:
|
||||
'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 {
|
||||
@@ -248,10 +257,13 @@ setupRouter.post('/validate/vapid', async (c) => {
|
||||
);
|
||||
return c.json({ ok: true }, 200);
|
||||
} catch (err) {
|
||||
return c.json({
|
||||
return c.json(
|
||||
{
|
||||
ok: false,
|
||||
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];
|
||||
const unclaimedCount = Number(countRows[0][0]?.count ?? 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)
|
||||
@@ -322,20 +336,22 @@ setupRouter.post('/credential', zValidator('json', credentialSchema, noEchoHook)
|
||||
})
|
||||
.$returningId();
|
||||
|
||||
const [row] = await tx
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, inserted.id))
|
||||
.limit(1);
|
||||
const [row] = await tx.select().from(users).where(eq(users.id, inserted.id)).limit(1);
|
||||
|
||||
return row;
|
||||
});
|
||||
} 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
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -353,12 +369,7 @@ setupRouter.post('/credential', zValidator('json', credentialSchema, noEchoHook)
|
||||
// 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).
|
||||
try {
|
||||
await validateEncryptAndStoreCredential(
|
||||
localUser.id,
|
||||
fastmailEmail,
|
||||
appPassword,
|
||||
'caldav',
|
||||
);
|
||||
await validateEncryptAndStoreCredential(localUser.id, fastmailEmail, appPassword, 'caldav');
|
||||
} catch (err) {
|
||||
// Roll back the local user insert on credential failure to avoid orphaned rows
|
||||
await db.delete(users).where(eq(users.id, localUser.id));
|
||||
|
||||
@@ -203,7 +203,8 @@ async function seedCredential(userId: number): Promise<void> {
|
||||
// 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';
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -223,9 +224,7 @@ beforeEach(async () => {
|
||||
afterEach(async () => {
|
||||
// Clean up seeded rows between tests
|
||||
await db.delete(memberCredentials);
|
||||
await db
|
||||
.delete(users)
|
||||
.where(eq(users.oidcIss, 'https://auth.test.setup'));
|
||||
await db.delete(users).where(eq(users.oidcIss, 'https://auth.test.setup'));
|
||||
// Delete local (wizard-created) users with null oidcIss
|
||||
// We identify them by displayName prefix for safety
|
||||
// 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;
|
||||
|
||||
const app = await getApp();
|
||||
const res = await app.fetch(jsonRequest('GET', '/api/setup/status'));
|
||||
// status route always returns the status — but guard kicks in on mutation routes
|
||||
// Test a mutation route to confirm 423
|
||||
// The /status route always returns the status; the guard kicks in on mutation
|
||||
// routes. Test a mutation route to confirm 423.
|
||||
const completeRes = await app.fetch(jsonRequest('POST', '/api/setup/complete'));
|
||||
expect(completeRes.status).toBe(423);
|
||||
});
|
||||
|
||||
@@ -636,7 +636,9 @@ export async function validateSetupDb(): Promise<void> {
|
||||
if (res.status === 423) throw new SetupAlreadyLockedError();
|
||||
|
||||
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.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.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.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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,12 @@
|
||||
*/
|
||||
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -28,11 +33,21 @@ function mockFetchResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
clone: function () { return this as unknown as Response; },
|
||||
json: () => Promise.resolve(body),
|
||||
clone: function () {
|
||||
return this 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.
|
||||
// If SetupConfigPayload still has snake_case fields, TypeScript will error here
|
||||
// (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));
|
||||
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).not.toHaveProperty('oidc_issuer');
|
||||
});
|
||||
@@ -70,7 +85,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||
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).not.toHaveProperty('oidc_client_id');
|
||||
});
|
||||
@@ -79,7 +94,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||
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).not.toHaveProperty('vapid_public_key');
|
||||
});
|
||||
@@ -88,7 +103,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||
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).not.toHaveProperty('app_url');
|
||||
});
|
||||
@@ -97,7 +112,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||
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(
|
||||
['appExternalUrl', 'oidcClientId', 'oidcIssuer', 'vapidPublicKey'].sort(),
|
||||
);
|
||||
@@ -125,15 +140,17 @@ describe('postSetupConfig — error rendering (BUG 2 regression)', () => {
|
||||
error: {
|
||||
name: 'ZodError',
|
||||
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));
|
||||
|
||||
await expect(
|
||||
postSetupConfig(VALID_PAYLOAD),
|
||||
).rejects.toSatisfy((err: Error) => {
|
||||
await expect(postSetupConfig(VALID_PAYLOAD)).rejects.toSatisfy((err: Error) => {
|
||||
// The error message must NOT be "[object Object]"
|
||||
expect(err.message).not.toBe('[object Object]');
|
||||
// The error message must be a non-empty string
|
||||
@@ -149,31 +166,29 @@ describe('postSetupConfig — error rendering (BUG 2 regression)', () => {
|
||||
error: {
|
||||
name: 'ZodError',
|
||||
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));
|
||||
|
||||
await expect(
|
||||
postSetupConfig(VALID_PAYLOAD),
|
||||
).rejects.toThrow('oidcIssuer must be an https URL');
|
||||
await expect(postSetupConfig(VALID_PAYLOAD)).rejects.toThrow('oidcIssuer must be an https URL');
|
||||
});
|
||||
|
||||
it('throws a fallback status error when error body is empty', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse({}, 400));
|
||||
|
||||
await expect(
|
||||
postSetupConfig(VALID_PAYLOAD),
|
||||
).rejects.toThrow('400');
|
||||
await expect(postSetupConfig(VALID_PAYLOAD)).rejects.toThrow('400');
|
||||
});
|
||||
|
||||
it('returns void on 200 success', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||
|
||||
await expect(
|
||||
postSetupConfig(VALID_PAYLOAD),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(postSetupConfig(VALID_PAYLOAD)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,7 +217,7 @@ describe('postSetupCredential — payload contract (CR-01 regression)', () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||
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());
|
||||
});
|
||||
|
||||
@@ -210,7 +225,7 @@ describe('postSetupCredential — payload contract (CR-01 regression)', () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -218,7 +233,7 @@ describe('postSetupCredential — payload contract (CR-01 regression)', () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -226,7 +241,7 @@ describe('postSetupCredential — payload contract (CR-01 regression)', () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
@@ -149,7 +152,7 @@ describe('SetupPage — Welcome step', () => {
|
||||
});
|
||||
|
||||
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
|
||||
// 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
|
||||
@@ -239,15 +242,20 @@ describe('SetupPage — Step 2 VAPID validation (CR-01 gap)', () => {
|
||||
const { postSetupConfig } = await import('../api/client.js');
|
||||
(postSetupConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('App URL'), { target: { value: 'https://app.example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { target: { value: 'https://auth.example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('App URL'), {
|
||||
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('VAPID public key'), { target: { value: 'BHtest123' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' }));
|
||||
}
|
||||
|
||||
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);
|
||||
(validateSetupOidc 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 () => {
|
||||
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);
|
||||
(validateSetupOidc 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 () => {
|
||||
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);
|
||||
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
(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();
|
||||
@@ -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 () => {
|
||||
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);
|
||||
(validateSetupOidc 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);
|
||||
|
||||
await advanceToStep2();
|
||||
fireEvent.change(screen.getByLabelText('App URL'), { target: { value: 'https://app.example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { target: { value: 'https://auth.example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('App URL'), {
|
||||
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('VAPID public key'), { target: { value: 'BHtest123' } });
|
||||
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' }));
|
||||
await screen.findByText('Instance Configuration');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('App URL'), { target: { value: 'https://app.example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { target: { value: 'https://auth.example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('App URL'), {
|
||||
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('VAPID public key'), { target: { value: 'BHtest123' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' }));
|
||||
|
||||
@@ -175,14 +175,8 @@ function StepIndicator({ currentStep }: StepIndicatorProps) {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background:
|
||||
isCompleted || isActive
|
||||
? 'var(--color-member-0, #4a90d9)'
|
||||
: 'transparent',
|
||||
border:
|
||||
isCompleted || isActive
|
||||
? 'none'
|
||||
: '2px solid var(--color-border, #e2e4e9)',
|
||||
background: isCompleted || isActive ? '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)',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
@@ -220,11 +214,7 @@ function StepIndicator({ currentStep }: StepIndicatorProps) {
|
||||
)}
|
||||
|
||||
<div style={{ ...circleStyle, zIndex: 1, position: 'relative' }}>
|
||||
{isCompleted ? (
|
||||
<CheckCircle size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<span>{stepNum}</span>
|
||||
)}
|
||||
{isCompleted ? <CheckCircle size={16} aria-hidden="true" /> : <span>{stepNum}</span>}
|
||||
</div>
|
||||
|
||||
<span
|
||||
@@ -383,12 +373,10 @@ function Step1Welcome({ onContinue, stepHeadingRef }: Step1Props) {
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
This wizard will guide you through configuring your self-hosted instance. Before
|
||||
continuing, run{' '}
|
||||
<code>npm run generate-secrets</code>{' '}
|
||||
from the repo to generate your instance secrets and add them to your Docker environment.
|
||||
You'll also need: your OIDC client credentials (Authelia) and a Fastmail account
|
||||
with an app password. This takes about 5 minutes.
|
||||
This wizard will guide you through configuring your self-hosted instance. Before continuing,
|
||||
run <code>npm run generate-secrets</code> from the repo to generate your instance secrets
|
||||
and add them to your Docker environment. You'll also need: your OIDC client credentials
|
||||
(Authelia) and a Fastmail account with an app password. This takes about 5 minutes.
|
||||
</p>
|
||||
|
||||
{/* Before you start note block */}
|
||||
@@ -465,7 +453,9 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S
|
||||
});
|
||||
const dbName = setupStatus?.dbName ?? '';
|
||||
|
||||
const [validationRows, setValidationRows] = useState<Pick<ValidationRowStatus, 'db' | 'oidc' | 'vapid'>>({
|
||||
const [validationRows, setValidationRows] = useState<
|
||||
Pick<ValidationRowStatus, 'db' | 'oidc' | 'vapid'>
|
||||
>({
|
||||
db: 'idle',
|
||||
oidc: 'idle',
|
||||
vapid: 'idle',
|
||||
@@ -518,9 +508,7 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
setFieldError(
|
||||
err instanceof Error ? err.message : 'Something went wrong. Please try again.',
|
||||
);
|
||||
setFieldError(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"
|
||||
style={inputStyle(false)}
|
||||
/>
|
||||
<div style={helperStyle}>
|
||||
The client ID registered in Authelia for this application.
|
||||
</div>
|
||||
<div style={helperStyle}>The client ID registered in Authelia for this application.</div>
|
||||
</div>
|
||||
|
||||
{/* VAPID Public Key */}
|
||||
@@ -673,8 +659,7 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S
|
||||
style={inputStyle(false)}
|
||||
/>
|
||||
<div style={helperStyle}>
|
||||
Paste the <code>VAPID_PUBLIC_KEY</code> value from{' '}
|
||||
<code>npm run generate-secrets</code>.
|
||||
Paste the <code>VAPID_PUBLIC_KEY</code> value from <code>npm run generate-secrets</code>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -721,7 +706,10 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S
|
||||
/>
|
||||
|
||||
{/* 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
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
@@ -806,15 +794,12 @@ function Step3Credential({ onBack, onSuccess, onLocked, stepHeadingRef }: Step3P
|
||||
onLocked();
|
||||
return;
|
||||
}
|
||||
setErrorText(
|
||||
err instanceof Error ? err.message : 'Something went wrong. Please try again.',
|
||||
);
|
||||
setErrorText(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
||||
},
|
||||
});
|
||||
|
||||
const isPending = completeMutation.isPending || finalMutation.isPending;
|
||||
const saveDisabled =
|
||||
isPending || email.trim().length === 0 || password.trim().length === 0;
|
||||
const saveDisabled = isPending || email.trim().length === 0 || password.trim().length === 0;
|
||||
|
||||
function handleValidate() {
|
||||
if (saveDisabled) return;
|
||||
@@ -853,9 +838,8 @@ function Step3Credential({ onBack, onSuccess, onLocked, stepHeadingRef }: Step3P
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Add the Fastmail app password for the first household member. This credential is
|
||||
validated against Fastmail CalDAV before saving. The password is never stored in plain
|
||||
text.
|
||||
Add the Fastmail app password for the first household member. This credential is validated
|
||||
against Fastmail CalDAV before saving. The password is never stored in plain text.
|
||||
</p>
|
||||
|
||||
{/* Fastmail email */}
|
||||
@@ -1177,10 +1161,7 @@ export function SetupPage({ alreadyLocked = false }: SetupPageProps) {
|
||||
|
||||
{/* Step card (Surface 3) */}
|
||||
{step === 1 && (
|
||||
<Step1Welcome
|
||||
onContinue={() => setStep(2)}
|
||||
stepHeadingRef={stepHeadingRef}
|
||||
/>
|
||||
<Step1Welcome onContinue={() => setStep(2)} stepHeadingRef={stepHeadingRef} />
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Step2Config
|
||||
|
||||
Reference in New Issue
Block a user