- Implement real isSetupLocked() in setupGuard.ts: reads app_config.setup_complete (returns true if value==='true'); else checks member_credentials row + VAPID env for effective-config branch (D-10) - Re-queries DB fresh every call — no module-level cache (D-10/Pitfall 8) - Convert Wave-0 it.todo() scaffolds into real integration tests (17 tests RED) - RED-first 423 guard test: POST /complete twice → first 200, second 423 (Pitfall 8) - D-10 effective-config tests: 423 when credRow AND VAPID env; NOT 423 otherwise - 2 'does NOT return 423' tests pass (404 ≠ 423); all others RED pending Task 2 router
541 lines
21 KiB
TypeScript
541 lines
21 KiB
TypeScript
/**
|
|
* Setup wizard route tests — Plan 12-02.
|
|
*
|
|
* Covers SETUP-01, SETUP-02, SETUP-04 + the 423 guard (Pitfall 8) + D-10
|
|
* effective-config branch.
|
|
*
|
|
* Architecture:
|
|
* - Tests import `app` (not setupRouter directly — mirrors admin.test.ts Pitfall 9 pattern)
|
|
* - Real DB integration against familysync_test (vitest globalSetup provisions it)
|
|
* - credentialSync.js and broker mocks isolate CalDAV calls
|
|
*
|
|
* Route surfaces tested:
|
|
* GET /api/setup/status
|
|
* POST /api/setup/config
|
|
* POST /api/setup/validate/db
|
|
* POST /api/setup/validate/oidc
|
|
* POST /api/setup/validate/vapid
|
|
* POST /api/setup/credential
|
|
* POST /api/setup/complete
|
|
*
|
|
* SETUP-04 / Pitfall 8 — 423 guard:
|
|
* POST /api/setup/complete twice → first 200, second 423
|
|
* POST /api/setup/* when effectively configured (member_credentials + VAPID env) → 423
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { eq } from 'drizzle-orm';
|
|
import { db } from '../../src/db/client.js';
|
|
import { users, memberCredentials, appConfig } from '../../src/db/schema.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CalDAV credential validation — mock to avoid live Fastmail calls
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// We need to control whether validateEncryptAndStoreCredential succeeds or throws.
|
|
// CredentialValidationError is the typed failure signal.
|
|
class MockCredentialValidationError extends Error {
|
|
constructor() {
|
|
super('Credential validation failed');
|
|
this.name = 'CredentialValidationError';
|
|
}
|
|
}
|
|
|
|
let mockValidateCredentialShouldThrow = false;
|
|
let mockValidateCredentialError: Error | null = null;
|
|
|
|
vi.mock('../../src/broker/credentialSync.js', () => ({
|
|
validateEncryptAndStoreCredential: vi.fn().mockImplementation(async () => {
|
|
if (mockValidateCredentialShouldThrow) {
|
|
throw mockValidateCredentialError ?? new MockCredentialValidationError();
|
|
}
|
|
// Success: write a mock credential row so the guard's effective-config check works
|
|
return undefined;
|
|
}),
|
|
CredentialValidationError: MockCredentialValidationError,
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CalDAV client — mock to avoid live network calls
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock('../../src/broker/client.js', () => ({
|
|
createFastmailClient: vi.fn().mockResolvedValue({
|
|
fetchCalendars: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Outbox worker — avoid side-effects during tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock('../../src/broker/outboxWorker.js', () => ({
|
|
loadClientForUser: vi.fn().mockResolvedValue({
|
|
fetchCalendars: () => Promise.resolve([]),
|
|
}),
|
|
startOutboxWorker: vi.fn(),
|
|
initOutboxTrigger: vi.fn(),
|
|
scheduleOutboxDrain: vi.fn(),
|
|
runOutboxDrain: vi.fn(),
|
|
__resetDrainState: vi.fn(),
|
|
stopOutboxTrigger: vi.fn(),
|
|
triggerTargetedResync: vi.fn(),
|
|
assembleRruleString: vi.fn(),
|
|
}));
|
|
|
|
// Mock sync.js to avoid actual CalDAV sync during tests
|
|
vi.mock('../../src/broker/sync.js', () => ({
|
|
syncCalendar: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dev-auth bypass — simulate unauthenticated requests to pre-auth /api/setup routes
|
|
// Setup routes are pre-auth: no user injection needed; bypass still needed to avoid
|
|
// oidcAuthMiddleware redirecting /api/* requests.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock('../../src/auth/devBypass.js', () => ({
|
|
devAuthBypass:
|
|
() => async (c: { set: (k: string, v: unknown) => void }, next: () => Promise<void>) => {
|
|
// No user injection for setup routes — pre-auth surface
|
|
await next();
|
|
},
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OIDC auth middleware — skip for pre-auth surface
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock('@hono/oidc-auth', () => ({
|
|
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
|
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
|
|
getAuth: () => null,
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fetch mock for OIDC discovery validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let mockFetchShouldFail = false;
|
|
let mockFetchStatus = 200;
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockImplementation(async (url: string) => {
|
|
if (mockFetchShouldFail) {
|
|
throw new Error('Network error: connection refused');
|
|
}
|
|
return {
|
|
ok: mockFetchStatus >= 200 && mockFetchStatus < 300,
|
|
status: mockFetchStatus,
|
|
json: async () => ({ issuer: url.replace('/.well-known/openid-configuration', '') }),
|
|
};
|
|
}),
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Request helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function jsonRequest(method: string, path: string, body?: unknown): Request {
|
|
return new Request(`http://localhost${path}`, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import `app` lazily (after mocks are registered) — Pitfall 9: must import
|
|
// `app` not `setupRouter` directly so the pre-auth mount + guard are exercised.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function getApp() {
|
|
const { app } = await import('../../src/index.js');
|
|
return app;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Seed helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function seedUser(label: string, isAdmin = false, claimed = true): Promise<number> {
|
|
const [result] = await db
|
|
.insert(users)
|
|
.values({
|
|
oidcIss: 'https://auth.test.setup',
|
|
oidcSub: `sub-${label}-${randomUUID()}`,
|
|
displayName: `Setup User ${label}`,
|
|
color: '#4A90D9',
|
|
isAdmin,
|
|
claimed,
|
|
})
|
|
.$returningId();
|
|
return result.id;
|
|
}
|
|
|
|
async function seedLocalUser(label: string): Promise<number> {
|
|
const [result] = await db
|
|
.insert(users)
|
|
.values({
|
|
oidcIss: null,
|
|
oidcSub: null,
|
|
displayName: `Local User ${label}`,
|
|
color: '#4A90D9',
|
|
isAdmin: true,
|
|
claimed: false,
|
|
})
|
|
.$returningId();
|
|
return result.id;
|
|
}
|
|
|
|
async function seedCredential(userId: number): Promise<void> {
|
|
await db.insert(memberCredentials).values({
|
|
userId,
|
|
encryptedPassword: '{"iv":"test","authTag":"test","ciphertext":"test"}',
|
|
fastmailEmail: 'test@fastmail.com',
|
|
providerType: 'caldav',
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Env and DB cleanup between tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const VAPID_PUBLIC_KEY = 'BKO9RLPqxNQ7GOLHsQ5kFXqMkfJBfElkd5h9ECQ0gRu7R5SJP6Ct5GkRuPxqY1u0UVY84_z7JGJsLhO-wChk_sE';
|
|
const VAPID_PRIVATE_KEY = 'QEkqsrxLqpv_ynpCECWFLYlOxCEGd-_O5u0AXzY_qEY';
|
|
|
|
beforeEach(async () => {
|
|
process.env.APP_PASSWORD_ENCRYPTION_KEY =
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
|
// Reset mock state to defaults
|
|
mockValidateCredentialShouldThrow = false;
|
|
mockValidateCredentialError = null;
|
|
mockFetchShouldFail = false;
|
|
mockFetchStatus = 200;
|
|
// Remove VAPID env for a fresh start (tests that need them set them explicitly)
|
|
delete process.env.VAPID_PRIVATE_KEY;
|
|
delete process.env.VAPID_PUBLIC_KEY;
|
|
delete process.env.VAPID_SUBJECT;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Clean up seeded rows between tests
|
|
await db.delete(memberCredentials);
|
|
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)
|
|
await db.delete(users).where(eq(users.claimed, false));
|
|
// Clean up app_config keys set during tests
|
|
await db.delete(appConfig).where(eq(appConfig.key, 'setup_complete'));
|
|
await db.delete(appConfig).where(eq(appConfig.key, 'oidc_issuer'));
|
|
await db.delete(appConfig).where(eq(appConfig.key, 'oidc_client_id'));
|
|
await db.delete(appConfig).where(eq(appConfig.key, 'vapid_public_key'));
|
|
await db.delete(appConfig).where(eq(appConfig.key, 'app_external_url'));
|
|
});
|
|
|
|
// ===========================================================================
|
|
// SETUP-01: GET /api/setup/status
|
|
// ===========================================================================
|
|
|
|
describe('GET /api/setup/status', () => {
|
|
it('returns { setupComplete: false } when no setup has been run (fresh state)', async () => {
|
|
const app = await getApp();
|
|
const res = await app.fetch(jsonRequest('GET', '/api/setup/status'));
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { setupComplete: boolean };
|
|
expect(body.setupComplete).toBe(false);
|
|
});
|
|
|
|
it('returns { setupComplete: true } after app_config.setup_complete is set', async () => {
|
|
// Directly set setup_complete in DB (simulates completed setup)
|
|
await db
|
|
.insert(appConfig)
|
|
.values({ key: 'setup_complete', value: 'true' })
|
|
.onDuplicateKeyUpdate({ set: { value: 'true' } });
|
|
|
|
const app = await getApp();
|
|
const res = await app.fetch(jsonRequest('GET', '/api/setup/status'));
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { setupComplete: boolean };
|
|
expect(body.setupComplete).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// SETUP-01: POST /api/setup/config
|
|
// ===========================================================================
|
|
|
|
describe('POST /api/setup/config', () => {
|
|
it('returns 200 and upserts all four config keys into app_config', async () => {
|
|
const app = await getApp();
|
|
const res = await app.fetch(
|
|
jsonRequest('POST', '/api/setup/config', {
|
|
oidcIssuer: 'https://auth.example.com',
|
|
oidcClientId: 'familysync-client',
|
|
vapidPublicKey: VAPID_PUBLIC_KEY,
|
|
appExternalUrl: 'https://familysync.example.com',
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { ok: boolean };
|
|
expect(body.ok).toBe(true);
|
|
|
|
// Verify rows were written to app_config
|
|
const [issuerRow] = await db
|
|
.select({ value: appConfig.value })
|
|
.from(appConfig)
|
|
.where(eq(appConfig.key, 'oidc_issuer'))
|
|
.limit(1);
|
|
expect(issuerRow?.value).toBe('https://auth.example.com');
|
|
});
|
|
|
|
it('returns 400 when oidcIssuer is not an https URL', async () => {
|
|
const app = await getApp();
|
|
const res = await app.fetch(
|
|
jsonRequest('POST', '/api/setup/config', {
|
|
oidcIssuer: 'http://insecure.example.com',
|
|
oidcClientId: 'familysync-client',
|
|
vapidPublicKey: VAPID_PUBLIC_KEY,
|
|
appExternalUrl: 'https://familysync.example.com',
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// SETUP-02: POST /api/setup/validate/db
|
|
// ===========================================================================
|
|
|
|
describe('POST /api/setup/validate/db', () => {
|
|
it('returns 200 { ok: true } when DB is reachable (SELECT 1 succeeds)', async () => {
|
|
const app = await getApp();
|
|
const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/db'));
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { ok: boolean };
|
|
expect(body.ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// SETUP-02: POST /api/setup/validate/vapid
|
|
// ===========================================================================
|
|
|
|
describe('POST /api/setup/validate/vapid', () => {
|
|
it('returns 200 { ok: true } for a valid VAPID key pair from generate-secrets', async () => {
|
|
process.env.VAPID_PUBLIC_KEY = VAPID_PUBLIC_KEY;
|
|
process.env.VAPID_PRIVATE_KEY = VAPID_PRIVATE_KEY;
|
|
|
|
const app = await getApp();
|
|
const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/vapid'));
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { ok: boolean };
|
|
expect(body.ok).toBe(true);
|
|
});
|
|
|
|
it('returns 400 { ok: false } when VAPID env vars are missing', async () => {
|
|
// VAPID env vars not set (cleared in beforeEach)
|
|
const app = await getApp();
|
|
const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/vapid'));
|
|
expect(res.status).toBe(400);
|
|
const body = (await res.json()) as { ok: boolean };
|
|
expect(body.ok).toBe(false);
|
|
});
|
|
|
|
it('returns 400 for an invalid/truncated VAPID key (structural validation)', async () => {
|
|
process.env.VAPID_PUBLIC_KEY = 'not-a-valid-vapid-key';
|
|
process.env.VAPID_PRIVATE_KEY = 'also-not-valid';
|
|
|
|
const app = await getApp();
|
|
const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/vapid'));
|
|
expect(res.status).toBe(400);
|
|
const body = (await res.json()) as { ok: boolean };
|
|
expect(body.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// SETUP-02: POST /api/setup/validate/oidc
|
|
// ===========================================================================
|
|
|
|
describe('POST /api/setup/validate/oidc', () => {
|
|
it('returns 200 { ok: true } when OIDC discovery resolves successfully', async () => {
|
|
// Write issuer to app_config first (normally config step would do this)
|
|
await db
|
|
.insert(appConfig)
|
|
.values({ key: 'oidc_issuer', value: 'https://auth.example.com' })
|
|
.onDuplicateKeyUpdate({ set: { value: 'https://auth.example.com' } });
|
|
|
|
mockFetchShouldFail = false;
|
|
mockFetchStatus = 200;
|
|
|
|
const app = await getApp();
|
|
const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/oidc'));
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { ok: boolean };
|
|
expect(body.ok).toBe(true);
|
|
});
|
|
|
|
it('returns 400 { ok: false } when the OIDC issuer is unreachable (mocked network failure)', async () => {
|
|
await db
|
|
.insert(appConfig)
|
|
.values({ key: 'oidc_issuer', value: 'https://unreachable.example.com' })
|
|
.onDuplicateKeyUpdate({ set: { value: 'https://unreachable.example.com' } });
|
|
|
|
mockFetchShouldFail = true;
|
|
|
|
const app = await getApp();
|
|
const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/oidc'));
|
|
expect(res.status).toBe(400);
|
|
const body = (await res.json()) as { ok: boolean };
|
|
expect(body.ok).toBe(false);
|
|
});
|
|
|
|
it('returns 400 when oidc_issuer is not configured in app_config', async () => {
|
|
const app = await getApp();
|
|
const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/oidc'));
|
|
expect(res.status).toBe(400);
|
|
const body = (await res.json()) as { ok: boolean };
|
|
expect(body.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// SETUP-01: POST /api/setup/credential — CalDAV PROPFIND validation
|
|
// ===========================================================================
|
|
|
|
describe('POST /api/setup/credential', () => {
|
|
it('returns 200 and stores credential when PROPFIND succeeds (mocked)', async () => {
|
|
mockValidateCredentialShouldThrow = false;
|
|
|
|
const app = await getApp();
|
|
const res = await app.fetch(
|
|
jsonRequest('POST', '/api/setup/credential', {
|
|
fastmailEmail: 'operator@fastmail.com',
|
|
appPassword: 'valid-app-password-abc123',
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { ok: boolean };
|
|
expect(body.ok).toBe(true);
|
|
});
|
|
|
|
it('returns 400 when PROPFIND fails (bad Fastmail app password)', async () => {
|
|
mockValidateCredentialShouldThrow = true;
|
|
mockValidateCredentialError = new MockCredentialValidationError();
|
|
|
|
const app = await getApp();
|
|
const res = await app.fetch(
|
|
jsonRequest('POST', '/api/setup/credential', {
|
|
fastmailEmail: 'operator@fastmail.com',
|
|
appPassword: 'bad-password-xyz',
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const bodyText = await res.text();
|
|
// Pitfall 7: submitted password MUST NOT appear in response
|
|
expect(bodyText).not.toContain('bad-password-xyz');
|
|
expect(bodyText).not.toContain('received');
|
|
});
|
|
|
|
it('returns 400 with no echoed password when credential validation fails (Pitfall 7 / no-echo)', async () => {
|
|
mockValidateCredentialShouldThrow = true;
|
|
mockValidateCredentialError = new MockCredentialValidationError();
|
|
|
|
const submittedPassword = 'super-secret-that-must-not-be-echoed-abc456';
|
|
const app = await getApp();
|
|
const res = await app.fetch(
|
|
jsonRequest('POST', '/api/setup/credential', {
|
|
fastmailEmail: 'operator@fastmail.com',
|
|
appPassword: submittedPassword,
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const bodyText = await res.text();
|
|
expect(bodyText).not.toContain(submittedPassword);
|
|
expect(bodyText).not.toContain('received');
|
|
expect(bodyText).not.toContain('issues');
|
|
const body = JSON.parse(bodyText) as { error: string };
|
|
expect(body.error).toBe('Invalid request');
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// SETUP-04: POST /api/setup/complete — 423 guard (Pitfall 8, must be RED before plan 02)
|
|
// ===========================================================================
|
|
|
|
describe('POST /api/setup/complete — 423 guard (SETUP-04 / Pitfall 8)', () => {
|
|
it('returns 200 on first call (fresh setup, wizard not yet locked)', async () => {
|
|
const app = await getApp();
|
|
const res = await app.fetch(jsonRequest('POST', '/api/setup/complete'));
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
// This test is the load-bearing RED test — the 423 must be verified.
|
|
// Second call must return 423 because setup_complete is set after first call.
|
|
it('returns 423 on second call — setup already complete, wizard locked (Pitfall 8)', async () => {
|
|
const app = await getApp();
|
|
|
|
// First call — should succeed and set setup_complete
|
|
const first = await app.fetch(jsonRequest('POST', '/api/setup/complete'));
|
|
expect(first.status).toBe(200);
|
|
|
|
// Second call — must return 423 (guard re-evaluated per call, D-10)
|
|
const second = await app.fetch(jsonRequest('POST', '/api/setup/complete'));
|
|
expect(second.status).toBe(423);
|
|
const body = (await second.json()) as { error: string };
|
|
expect(body.error).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// D-10 effective-config branch: any /api/setup/* → 423 when effectively configured
|
|
// (member_credentials row exists AND VAPID env vars present)
|
|
// ===========================================================================
|
|
|
|
describe('/api/setup/* — 423 when effectively configured (D-10 effective-config branch)', () => {
|
|
it('any /api/setup/* route returns 423 when member_credentials row exists AND VAPID env set', async () => {
|
|
// Seed a user and a credential row (effective-config condition)
|
|
const userId = await seedUser('effective-config', true);
|
|
await seedCredential(userId);
|
|
process.env.VAPID_PRIVATE_KEY = VAPID_PRIVATE_KEY;
|
|
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
|
|
const completeRes = await app.fetch(jsonRequest('POST', '/api/setup/complete'));
|
|
expect(completeRes.status).toBe(423);
|
|
});
|
|
|
|
it('does NOT return 423 when member_credentials row exists but VAPID env is absent', async () => {
|
|
// Seed credential but no VAPID env
|
|
const userId = await seedUser('no-vapid', true);
|
|
await seedCredential(userId);
|
|
// VAPID env not set (cleared in beforeEach)
|
|
|
|
const app = await getApp();
|
|
const res = await app.fetch(jsonRequest('POST', '/api/setup/complete'));
|
|
// Not locked — VAPID absent means effective-config condition is false
|
|
// The complete call may succeed (200) or fail for other reasons, but NOT 423
|
|
expect(res.status).not.toBe(423);
|
|
});
|
|
|
|
it('does NOT return 423 when VAPID env is set but no member_credentials row exists', async () => {
|
|
// Set VAPID env but no credential row
|
|
process.env.VAPID_PRIVATE_KEY = VAPID_PRIVATE_KEY;
|
|
process.env.VAPID_PUBLIC_KEY = VAPID_PUBLIC_KEY;
|
|
|
|
const app = await getApp();
|
|
const res = await app.fetch(jsonRequest('POST', '/api/setup/complete'));
|
|
// Not locked — no credentials means effective-config condition is false
|
|
expect(res.status).not.toBe(423);
|
|
});
|
|
});
|