Files
familienarchiv/frontend/src/routes/admin/invites/page.server.spec.ts
Marcel d1c6ae67c1
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m29s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 59s
fix(admin): address PR #623 review feedback
- Add load() unit tests for admin/users/[id] (permission gate, 404, success)
- Rename .test.ts → .spec.ts for consistency with rest of suite
- Add @Schema(requiredMode=REQUIRED) to InviteListItem.shareableUrl
- Add client-side allowlist for invite status query param

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 10:39:10 +02:00

258 lines
8.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { API_INTERNAL_URL: 'http://localhost:8080' }
}));
import { load, actions } from './+page.server';
import type { UserGroup } from './+page.server';
// PageServerLoad annotates the return as `void | (...)`. This explicit shape avoids
// the void and the Record<string, any> from the generic constraint.
type LoadData = {
invites: unknown[];
status: string;
loadError: string | null;
groups: UserGroup[];
groupsLoadError: string | null;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyFetch = (...args: any[]) => any;
function mockResponse(ok: boolean, body: unknown, status = 200) {
return {
ok,
status,
json: async () => body,
text: async () => JSON.stringify(body),
headers: new Headers({ 'content-type': 'application/json' })
} as unknown as Response;
}
describe('admin/invites load()', () => {
const mockFetch = vi.fn<AnyFetch>();
beforeEach(() => mockFetch.mockReset());
function event(status = 'active') {
const url = new URL(`http://localhost/admin/invites?status=${status}`);
return {
url,
request: new Request(url),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
it('returns groups array alongside invites when both succeed', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
mockResponse(true, [
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
])
);
const result = (await load(event())) as LoadData;
expect(result.groups).toHaveLength(2);
expect(result.groupsLoadError).toBeNull();
});
it('returns groups sorted alphabetically by name', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
mockResponse(true, [
{ id: 'g-1', name: 'Zebra', permissions: [] },
{ id: 'g-2', name: 'Alfa', permissions: [] },
{ id: 'g-3', name: 'Mitte', permissions: [] }
])
);
const result = (await load(event())) as LoadData;
expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']);
});
it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(true, []))
.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
const result = (await load(event())) as LoadData;
expect(result.groups).toEqual([]);
expect(result.groupsLoadError).toBe('FORBIDDEN');
});
it('falls back to INTERNAL_ERROR when groups error body has no code', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(true, []))
.mockResolvedValueOnce(mockResponse(false, null, 500));
const result = (await load(event())) as LoadData;
expect(result.groupsLoadError).toBe('INTERNAL_ERROR');
});
it('fetches invites and groups in parallel (both URLs called)', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(true, []))
.mockResolvedValueOnce(mockResponse(true, []));
await load(event());
expect(mockFetch).toHaveBeenCalledTimes(2);
// createApiClient calls fetch(Request, {}), not fetch(string, init)
const urls = mockFetch.mock.calls.map((call) => (call[0] as Request).url);
expect(urls).toEqual(
expect.arrayContaining([
expect.stringContaining('/api/invites'),
expect.stringContaining('/api/groups')
])
);
});
});
describe('admin/invites create action', () => {
const mockFetch = vi.fn<AnyFetch>();
beforeEach(() => mockFetch.mockReset());
const successBody = {
id: 'inv-1',
code: 'ABCDE12345',
displayCode: 'ABCDE-12345',
status: 'active',
revoked: false,
useCount: 0,
createdAt: '2026-01-01T00:00:00Z',
shareableUrl: 'http://localhost/register?code=ABCDE12345'
};
it('includes groupIds array in POST body when checkboxes are checked', async () => {
const fd = new FormData();
fd.append('groupIds', 'g-1');
fd.append('groupIds', 'g-2');
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
await actions.create({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
// createApiClient calls fetch(Request, {}), not fetch(string, init)
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
expect(req).toBeInstanceOf(Request);
expect(req.url).toContain('/api/invites');
const sent = await req.json();
expect(sent.groupIds).toEqual(['g-1', 'g-2']);
});
it('sends groupIds: [] when no checkboxes are checked', async () => {
const fd = new FormData();
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
await actions.create({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
expect(req).toBeInstanceOf(Request);
const sent = await req.json();
expect(sent.groupIds).toEqual([]);
});
it('returns created invite on success', async () => {
const fd = new FormData();
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
const result = await actions.create({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result).toMatchObject({ created: expect.objectContaining({ id: 'inv-1' }) });
});
it('returns fail with backend error code when create returns non-OK', async () => {
const fd = new FormData();
mockFetch.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
const result = await actions.create({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result).toMatchObject({ status: 403, data: { createError: 'FORBIDDEN' } });
});
it('falls back to INTERNAL_ERROR when create error body has no code', async () => {
const fd = new FormData();
mockFetch.mockResolvedValueOnce(mockResponse(false, null, 500));
const result = await actions.create({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result).toMatchObject({ status: 500, data: { createError: 'INTERNAL_ERROR' } });
});
});
describe('admin/invites revoke action', () => {
const mockFetch = vi.fn<AnyFetch>();
beforeEach(() => mockFetch.mockReset());
it('calls DELETE /api/invites/{id} via createApiClient', async () => {
const fd = new FormData();
fd.append('id', 'inv-abc');
mockFetch.mockResolvedValueOnce(mockResponse(true, null, 200));
await actions.revoke({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
expect(req).toBeInstanceOf(Request);
expect(req.url).toContain('/api/invites/inv-abc');
expect(req.method).toBe('DELETE');
});
it('returns revoked id on success', async () => {
const fd = new FormData();
fd.append('id', 'inv-abc');
mockFetch.mockResolvedValueOnce(mockResponse(true, null, 200));
const result = await actions.revoke({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result).toEqual({ revoked: 'inv-abc' });
});
it('returns fail with backend error code when revoke returns non-OK', async () => {
const fd = new FormData();
fd.append('id', 'inv-abc');
mockFetch.mockResolvedValueOnce(mockResponse(false, { code: 'NOT_FOUND' }, 404));
const result = await actions.revoke({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result).toMatchObject({ status: 404, data: { revokeError: 'NOT_FOUND' } });
});
});