Phase 10 — Admin Role & Settings (ADMIN-01/02/03) #17
@@ -0,0 +1,566 @@
|
||||
/**
|
||||
* Admin + self-service credential surface — integration tests (Plan 10-03, TDD RED→GREEN).
|
||||
*
|
||||
* Covers:
|
||||
* Task 2 (admin routes):
|
||||
* - T-10-08: GET /api/admin/members returns 403 for non-admin, 200+list for admin
|
||||
* - T-10-09 (Pitfall 7): POST /api/admin/credentials with invalid password → 400,
|
||||
* no submitted password string in response body; same shape for createFastmailClient
|
||||
* throw and for network/PROPFIND failures
|
||||
* - T-10-09 (Pitfall 7): malformed/bad-email payload → 400 { error: 'Invalid request' }
|
||||
* - T-10-11: valid credential → 200, stored encrypted (encrypted_password != plaintext)
|
||||
* - T-10-08: POST /api/admin/credentials as non-admin → 403
|
||||
* - ADMIN-02: PUT /api/admin/calendars/:id/shared → exactly one calendar is_shared=1
|
||||
* - GET /api/admin/calendars as admin → 200 list; as non-admin → 403
|
||||
*
|
||||
* Task 3 (self-service):
|
||||
* - T-10-12 (Pitfall 6): POST /api/me/credential with body userId for another user
|
||||
* → credential written to session user (currentUserId), NOT the body userId
|
||||
* - POST /api/me/credential with valid credential → 200, stored encrypted
|
||||
* - POST /api/me/credential with bad credential → 400 generic, no echoed password
|
||||
* - POST /api/me/credential does NOT require admin (normal member can use it)
|
||||
*
|
||||
* Architecture:
|
||||
* - Tests import `app` (NOT adminRouter directly — Pitfall 9)
|
||||
* - Real DB integration: dev MariaDB must be running; set DB_HOST=127.0.0.1
|
||||
* - CalDAV (createFastmailClient) is mocked to avoid live Fastmail calls
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../../src/db/client.js';
|
||||
import { users, memberCredentials, calendars } from '../../src/db/schema.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalDAV mock — intercept createFastmailClient so tests do not hit live Fastmail.
|
||||
// Tests that need PROPFIND-success inject a mock client with fetchCalendars resolving.
|
||||
// Tests that need PROPFIND-failure inject a mock client that throws on fetchCalendars.
|
||||
// Tests that need createFastmailClient itself to throw (e.g. bad email format) throw
|
||||
// before returning a client at all.
|
||||
//
|
||||
// Also mock loadClientForUser and triggerTargetedResync from outboxWorker to avoid
|
||||
// the initial-sync touching the real DB in tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type FetchCalendarsResult = { url: string; displayName: string }[];
|
||||
|
||||
let mockFetchCalendars: () => Promise<FetchCalendarsResult> = () =>
|
||||
Promise.resolve([{ url: 'https://caldav.fastmail.com/cal/user/', displayName: 'Test Cal' }]);
|
||||
let mockCreateClientShouldThrow = false;
|
||||
let mockCreateClientError: Error | null = null;
|
||||
|
||||
vi.mock('../../src/broker/client.js', () => ({
|
||||
createFastmailClient: vi.fn().mockImplementation(async () => {
|
||||
if (mockCreateClientShouldThrow) {
|
||||
throw mockCreateClientError ?? new Error('Mock CalDAV client creation error');
|
||||
}
|
||||
return { fetchCalendars: mockFetchCalendars };
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/broker/outboxWorker.js', () => ({
|
||||
loadClientForUser: vi.fn().mockResolvedValue({
|
||||
fetchCalendars: () =>
|
||||
Promise.resolve([{ url: 'https://caldav.fastmail.com/cal/', displayName: 'Test' }]),
|
||||
}),
|
||||
triggerTargetedResync: vi.fn().mockResolvedValue(undefined),
|
||||
startOutboxWorker: vi.fn(),
|
||||
initOutboxTrigger: vi.fn(),
|
||||
scheduleOutboxDrain: vi.fn(),
|
||||
runOutboxDrain: vi.fn(),
|
||||
__resetDrainState: vi.fn(),
|
||||
assembleRruleString: vi.fn(),
|
||||
stopOutboxTrigger: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock sync.js to avoid actual CalDAV sync during tests
|
||||
vi.mock('../../src/broker/sync.js', () => ({
|
||||
syncCalendar: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dev-bypass mock: allows us to simulate different users in tests.
|
||||
// currentDevUserId controls which user is "logged in" via the bypass.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let currentDevUserId = 1;
|
||||
|
||||
vi.mock('../../src/auth/devBypass.js', () => ({
|
||||
devAuthBypass:
|
||||
() => async (c: { set: (k: string, v: unknown) => void }, next: () => Promise<void>) => {
|
||||
c.set('user', { id: currentDevUserId });
|
||||
await next();
|
||||
},
|
||||
}));
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedUser(label: string, isAdmin = false): Promise<number> {
|
||||
const [result] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
oidcIss: 'https://auth.test',
|
||||
oidcSub: `sub-${label}-${randomUUID()}`,
|
||||
displayName: `User ${label}`,
|
||||
color: '#4A90D9',
|
||||
isAdmin,
|
||||
})
|
||||
.$returningId();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async function seedCalendar(userId: number, label: string, isShared = false): Promise<number> {
|
||||
const [result] = await db
|
||||
.insert(calendars)
|
||||
.values({
|
||||
userId,
|
||||
url: `https://caldav.fastmail.com/cal/${label}-${randomUUID()}/`,
|
||||
displayName: `Calendar ${label}`,
|
||||
isShared,
|
||||
})
|
||||
.$returningId();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 `adminRouter` directly so the route mount + guard are exercised.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getApp() {
|
||||
const { app } = await import('../../src/index.js');
|
||||
return app;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encryption key required for encryptPassword
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.APP_PASSWORD_ENCRYPTION_KEY =
|
||||
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
// Reset CalDAV mock state to default (success path)
|
||||
mockCreateClientShouldThrow = false;
|
||||
mockCreateClientError = null;
|
||||
mockFetchCalendars = () =>
|
||||
Promise.resolve([{ url: 'https://caldav.fastmail.com/cal/user/', displayName: 'Test Cal' }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up seeded users and credentials between tests
|
||||
await db.delete(memberCredentials);
|
||||
await db.delete(calendars);
|
||||
await db.delete(users).where(eq(users.oidcIss, 'https://auth.test'));
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// GET /api/admin/members
|
||||
// ===========================================================================
|
||||
|
||||
describe('GET /api/admin/members', () => {
|
||||
it('returns 403 for a non-admin authenticated user (Pitfall 9 / T-10-08)', async () => {
|
||||
const nonAdminId = await seedUser('non-admin', false);
|
||||
currentDevUserId = nonAdminId;
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: 'Forbidden' });
|
||||
});
|
||||
|
||||
it('returns 200 with member list for an admin user', async () => {
|
||||
const adminId = await seedUser('admin', true);
|
||||
const memberId = await seedUser('member', false);
|
||||
currentDevUserId = adminId;
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { members: unknown[] };
|
||||
expect(Array.isArray(body.members)).toBe(true);
|
||||
// Should include at least the two seeded users
|
||||
expect(body.members.length).toBeGreaterThanOrEqual(2);
|
||||
// Each member should have id, displayName, color, hasCredential
|
||||
const memberRow = (body.members as Array<{ id: number }>).find((m) => m.id === memberId);
|
||||
expect(memberRow).toBeDefined();
|
||||
expect(typeof (memberRow as { hasCredential: boolean }).hasCredential).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// POST /api/admin/credentials
|
||||
// ===========================================================================
|
||||
|
||||
describe('POST /api/admin/credentials', () => {
|
||||
it('returns 403 for a non-admin user (T-10-08)', async () => {
|
||||
const nonAdminId = await seedUser('non-admin-cred', false);
|
||||
const targetId = await seedUser('target', false);
|
||||
currentDevUserId = nonAdminId;
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/admin/credentials', {
|
||||
userId: targetId,
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'user@fastmail.com',
|
||||
appPassword: 'test-secret-pass',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 400 with no echoed password when PROPFIND/auth fails (Pitfall 7 / T-10-09)', async () => {
|
||||
const adminId = await seedUser('admin-cred-fail', true);
|
||||
const targetId = await seedUser('target-fail', false);
|
||||
currentDevUserId = adminId;
|
||||
|
||||
// Make fetchCalendars throw (simulates PROPFIND/auth failure)
|
||||
const submittedPassword = 'super-secret-app-password-12345';
|
||||
mockFetchCalendars = () => Promise.reject(new Error('Authentication failed'));
|
||||
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/admin/credentials', {
|
||||
userId: targetId,
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'user@fastmail.com',
|
||||
appPassword: submittedPassword,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const bodyText = await res.text();
|
||||
// Pitfall 7: submitted password MUST NOT appear in any 400 response
|
||||
expect(bodyText).not.toContain(submittedPassword);
|
||||
// No Zod error fields
|
||||
expect(bodyText).not.toContain('received');
|
||||
expect(bodyText).not.toContain('issues');
|
||||
// Should return generic error shape
|
||||
const body = JSON.parse(bodyText) as { error: string };
|
||||
expect(body.error).toBe('Invalid request');
|
||||
});
|
||||
|
||||
it('returns 400 when createFastmailClient itself throws (bad email / malformed input)', async () => {
|
||||
const adminId = await seedUser('admin-cred-throw', true);
|
||||
const targetId = await seedUser('target-throw', false);
|
||||
currentDevUserId = adminId;
|
||||
|
||||
const submittedPassword = 'bad-email-secret-pass-99999';
|
||||
mockCreateClientShouldThrow = true;
|
||||
mockCreateClientError = new Error('Invalid email format');
|
||||
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/admin/credentials', {
|
||||
userId: targetId,
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'user@fastmail.com',
|
||||
appPassword: submittedPassword,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const bodyText = await res.text();
|
||||
expect(bodyText).not.toContain(submittedPassword);
|
||||
const body = JSON.parse(bodyText) as { error: string };
|
||||
expect(body.error).toBe('Invalid request');
|
||||
});
|
||||
|
||||
it('returns 400 with no echo for a network error (all failures map to same generic 400)', async () => {
|
||||
const adminId = await seedUser('admin-network-err', true);
|
||||
const targetId = await seedUser('target-network', false);
|
||||
currentDevUserId = adminId;
|
||||
|
||||
const submittedPassword = 'network-error-secret-abc123';
|
||||
mockFetchCalendars = () => Promise.reject(new Error('Network connection refused'));
|
||||
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/admin/credentials', {
|
||||
userId: targetId,
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'user@fastmail.com',
|
||||
appPassword: submittedPassword,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const bodyText = await res.text();
|
||||
expect(bodyText).not.toContain(submittedPassword);
|
||||
const body = JSON.parse(bodyText) as { error: string };
|
||||
expect(body.error).toBe('Invalid request');
|
||||
});
|
||||
|
||||
it('returns 400 with no Zod echo when schema validation fails (e.g. missing fields)', async () => {
|
||||
const adminId = await seedUser('admin-schema-fail', true);
|
||||
currentDevUserId = adminId;
|
||||
|
||||
const submittedPassword = 'schema-fail-secret-zxcvbn';
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/admin/credentials', {
|
||||
// Missing userId — schema validation should fail
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'user@fastmail.com',
|
||||
appPassword: submittedPassword,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const bodyText = await res.text();
|
||||
// Pitfall 7: even schema validation failure must not echo the password
|
||||
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');
|
||||
});
|
||||
|
||||
it('returns 200 on valid credential and stores encrypted password (T-10-11)', async () => {
|
||||
const adminId = await seedUser('admin-valid', true);
|
||||
const targetId = await seedUser('target-valid', false);
|
||||
currentDevUserId = adminId;
|
||||
|
||||
const plainPassword = 'valid-app-password-abcxyz-9876';
|
||||
// Mock fetchCalendars to succeed (default)
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/admin/credentials', {
|
||||
userId: targetId,
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'target@fastmail.com',
|
||||
appPassword: plainPassword,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Verify stored credential is NOT the plaintext (encrypted)
|
||||
const [stored] = await db
|
||||
.select({ encryptedPassword: memberCredentials.encryptedPassword })
|
||||
.from(memberCredentials)
|
||||
.where(eq(memberCredentials.userId, targetId))
|
||||
.limit(1);
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored.encryptedPassword).not.toBe(plainPassword);
|
||||
// Should be the JSON-encoded AES-256-GCM structure
|
||||
const parsed = JSON.parse(stored.encryptedPassword) as Record<string, unknown>;
|
||||
expect(parsed.iv).toBeDefined();
|
||||
expect(parsed.ciphertext).toBeDefined();
|
||||
|
||||
// Response body must not contain the password
|
||||
const bodyText = JSON.stringify(await res.clone().json());
|
||||
expect(bodyText).not.toContain(plainPassword);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// GET /api/admin/calendars
|
||||
// ===========================================================================
|
||||
|
||||
describe('GET /api/admin/calendars', () => {
|
||||
it('returns 403 for non-admin', async () => {
|
||||
const nonAdminId = await seedUser('non-admin-cal', false);
|
||||
currentDevUserId = nonAdminId;
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(jsonRequest('GET', '/api/admin/calendars'));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 200 with calendar list for admin', async () => {
|
||||
const adminId = await seedUser('admin-cal-list', true);
|
||||
await seedCalendar(adminId, 'personal', false);
|
||||
await seedCalendar(adminId, 'family', true);
|
||||
currentDevUserId = adminId;
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(jsonRequest('GET', '/api/admin/calendars'));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { calendars: unknown[] };
|
||||
expect(Array.isArray(body.calendars)).toBe(true);
|
||||
// At least the two seeded calendars
|
||||
expect(body.calendars.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// PUT /api/admin/calendars/:id/shared (ADMIN-02, Pitfall 7-adjacent)
|
||||
// ===========================================================================
|
||||
|
||||
describe('PUT /api/admin/calendars/:id/shared', () => {
|
||||
it('returns 403 for non-admin', async () => {
|
||||
const nonAdminId = await seedUser('non-admin-shared', false);
|
||||
currentDevUserId = nonAdminId;
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(jsonRequest('PUT', '/api/admin/calendars/1/shared'));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('sets exactly one calendar to is_shared=1 and clears prior shared calendar (ADMIN-02)', async () => {
|
||||
const adminId = await seedUser('admin-shared', true);
|
||||
const calA = await seedCalendar(adminId, 'cal-a', true); // initially shared
|
||||
const calB = await seedCalendar(adminId, 'cal-b', false);
|
||||
currentDevUserId = adminId;
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(jsonRequest('PUT', `/api/admin/calendars/${calB}/shared`));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// calA should now be is_shared=false, calB should be is_shared=true
|
||||
const [rowA] = await db
|
||||
.select({ isShared: calendars.isShared })
|
||||
.from(calendars)
|
||||
.where(eq(calendars.id, calA))
|
||||
.limit(1);
|
||||
const [rowB] = await db
|
||||
.select({ isShared: calendars.isShared })
|
||||
.from(calendars)
|
||||
.where(eq(calendars.id, calB))
|
||||
.limit(1);
|
||||
|
||||
expect(rowA.isShared).toBe(false);
|
||||
expect(rowB.isShared).toBe(true);
|
||||
|
||||
// Verify exactly ONE calendar has is_shared=true after the update
|
||||
const sharedRows = await db
|
||||
.select({ id: calendars.id })
|
||||
.from(calendars)
|
||||
.where(eq(calendars.isShared, true));
|
||||
// Only calB should be shared (of the ones we seeded; other pre-existing rows excluded
|
||||
// by checking only our seeded IDs)
|
||||
const sharedIds = sharedRows.map((r) => r.id);
|
||||
expect(sharedIds).toContain(calB);
|
||||
expect(sharedIds).not.toContain(calA);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// POST /api/me/credential (Task 3 — member self-service, D-07, T-10-12)
|
||||
// ===========================================================================
|
||||
|
||||
describe('POST /api/me/credential', () => {
|
||||
it('ignores body userId and writes only to session user (Pitfall 6 / T-10-12)', async () => {
|
||||
const userA = await seedUser('self-service-a', false);
|
||||
const userB = await seedUser('self-service-b', false);
|
||||
currentDevUserId = userA; // logged in as userA
|
||||
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/me/credential', {
|
||||
userId: userB, // body says userB — must be IGNORED
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'usera@fastmail.com',
|
||||
appPassword: 'self-service-pass-abcxyz',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// userA should have a credential
|
||||
const [credA] = await db
|
||||
.select({ id: memberCredentials.id })
|
||||
.from(memberCredentials)
|
||||
.where(eq(memberCredentials.userId, userA))
|
||||
.limit(1);
|
||||
expect(credA).toBeDefined();
|
||||
|
||||
// userB should NOT have a credential
|
||||
const [credB] = await db
|
||||
.select({ id: memberCredentials.id })
|
||||
.from(memberCredentials)
|
||||
.where(eq(memberCredentials.userId, userB))
|
||||
.limit(1);
|
||||
expect(credB).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 200 and stores encrypted password for valid credential', async () => {
|
||||
const userId = await seedUser('self-service-valid', false);
|
||||
currentDevUserId = userId;
|
||||
|
||||
const plainPassword = 'self-service-valid-pass-qwerty9876';
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/me/credential', {
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'me@fastmail.com',
|
||||
appPassword: plainPassword,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const [stored] = await db
|
||||
.select({ encryptedPassword: memberCredentials.encryptedPassword })
|
||||
.from(memberCredentials)
|
||||
.where(eq(memberCredentials.userId, userId))
|
||||
.limit(1);
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored.encryptedPassword).not.toBe(plainPassword);
|
||||
const parsed = JSON.parse(stored.encryptedPassword) as Record<string, unknown>;
|
||||
expect(parsed.iv).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 400 with no echoed password when credential validation fails (Pitfall 7)', async () => {
|
||||
const userId = await seedUser('self-service-fail', false);
|
||||
currentDevUserId = userId;
|
||||
|
||||
const submittedPassword = 'self-service-bad-pass-xyz9999';
|
||||
mockFetchCalendars = () => Promise.reject(new Error('PROPFIND auth failure'));
|
||||
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/me/credential', {
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'me@fastmail.com',
|
||||
appPassword: submittedPassword,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const bodyText = await res.text();
|
||||
expect(bodyText).not.toContain(submittedPassword);
|
||||
const body = JSON.parse(bodyText) as { error: string };
|
||||
expect(body.error).toBe('Invalid request');
|
||||
});
|
||||
|
||||
it('does NOT require admin — a normal member can set their own credential', async () => {
|
||||
const userId = await seedUser('non-admin-self-service', false);
|
||||
currentDevUserId = userId;
|
||||
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/me/credential', {
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'member@fastmail.com',
|
||||
appPassword: 'member-pass-abcabc123',
|
||||
}),
|
||||
);
|
||||
// Should succeed (200) — no admin requirement on this endpoint
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user