Golden-master tests pinning the CURRENT behavior of high-value modules that had no coverage — integration seams, security helpers, two API route handlers, and complex pure utils. 111 tests across 12 files. New tests: - auth-crypto / session-cookie: AES-256-GCM session encryption roundtrip, tamper/version/missing-secret handling; cookie-slot naming. - unified-mailbox: multi-account fan-out, sort, totals, per-account error isolation, personal-vs-shared JMAP target resolution, counts/roles. - account-state-manager: snapshot/restore across the six real Zustand stores; clearAllStores reset shape; evict. - mdn: RFC 5322 MDN assembly (CRLF, RFC2047, base64 wrap, headers). - tnef: winmail.dat binary parsing from hand-built fixtures. - download-filename / subject-prefix / birthday-calendar / eml-import: filename templating, multilingual prefix stripping, birthday event generation, .eml/.zip import. - webdav / caldav-discover route handlers: auth guards, path validation, upstream URL construction, candidate probing. - helpers/factories.ts: shared makeEmail/makeMailbox/makeFakeJmapClient. Tests follow the repo's existing patterns (route-import, fake IJMAPClient, fetch spy, real store singletons). Where current behavior looks buggy it is pinned and flagged with a // CHARACTERISATION: comment (see PR for the suspected-bugs list); no production code is changed.
134 lines
5.5 KiB
TypeScript
134 lines
5.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
|
|
|
// ── module mocks (hoisted) ───────────────────────────────────────────────────
|
|
vi.mock('next/server', () => {
|
|
class NextResponse {
|
|
body: unknown;
|
|
status: number;
|
|
headers: Headers;
|
|
constructor(body: unknown, init?: { status?: number; headers?: Headers }) {
|
|
this.body = body;
|
|
this.status = init?.status ?? 200;
|
|
this.headers = init?.headers ?? new Headers();
|
|
}
|
|
static json(data: unknown, init?: { status?: number }) {
|
|
return { status: init?.status ?? 200, headers: new Headers(), json: async () => data };
|
|
}
|
|
}
|
|
return { NextResponse, NextRequest: class {} };
|
|
});
|
|
vi.mock('@/lib/logger', () => ({ logger: { error: () => {}, debug: () => {} } }));
|
|
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn() }));
|
|
|
|
import { POST } from '@/app/api/webdav/route';
|
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
|
|
const mockCreds = getStalwartCredentials as unknown as Mock;
|
|
const CREDS = { serverUrl: 'https://mail.example.com', username: 'user@example.com', authHeader: 'Basic abc' };
|
|
|
|
type Resp = { status: number; headers: Headers; body?: unknown; text?: () => Promise<string> };
|
|
let fetchSpy: Mock;
|
|
|
|
function makeReq(headers: Record<string, string> = {}, body: unknown = null): Parameters<typeof POST>[0] {
|
|
const lc: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(headers)) lc[k.toLowerCase()] = v;
|
|
return {
|
|
headers: { get: (n: string) => lc[n.toLowerCase()] ?? null },
|
|
arrayBuffer: async () => new ArrayBuffer(0),
|
|
body,
|
|
} as unknown as Parameters<typeof POST>[0];
|
|
}
|
|
|
|
// The route returns either our mocked NextResponse instance or NextResponse.json's object.
|
|
function read(res: unknown): { status: number; headers?: Headers; json?: () => Promise<unknown>; body?: unknown } {
|
|
return res as { status: number; headers?: Headers; json?: () => Promise<unknown>; body?: unknown };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockCreds.mockResolvedValue(CREDS);
|
|
fetchSpy = vi.fn(async (): Promise<Resp> => ({
|
|
status: 207,
|
|
headers: new Headers({ 'Content-Type': 'text/plain' }),
|
|
body: 'UPSTREAM-BODY',
|
|
text: async () => '<xml/>',
|
|
}));
|
|
vi.stubGlobal('fetch', fetchSpy);
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('POST /api/webdav — guards', () => {
|
|
it('401 when there are no credentials', async () => {
|
|
mockCreds.mockResolvedValue(null);
|
|
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'GET' })));
|
|
expect(res.status).toBe(401);
|
|
await expect(res.json!()).resolves.toEqual({ error: 'Not authenticated' });
|
|
});
|
|
|
|
it('400 for a missing or disallowed method', async () => {
|
|
expect(read(await POST(makeReq({}))).status).toBe(400);
|
|
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'BOGUS' })));
|
|
expect(res.status).toBe(400);
|
|
await expect(res.json!()).resolves.toEqual({ error: 'Invalid WebDAV method' });
|
|
});
|
|
|
|
it('400 on a path-traversal segment', async () => {
|
|
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'PROPFIND', 'X-WebDAV-Path': '../etc' })));
|
|
expect(res.status).toBe(400);
|
|
await expect(res.json!()).resolves.toEqual({ error: 'Invalid WebDAV path segment' });
|
|
});
|
|
|
|
it('400 on bad percent-encoding in the path', async () => {
|
|
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'PUT', 'X-WebDAV-Path': '%zz' })));
|
|
expect(res.status).toBe(400);
|
|
await expect(res.json!()).resolves.toEqual({ error: 'Invalid WebDAV path encoding' });
|
|
});
|
|
});
|
|
|
|
describe('POST /api/webdav — proxying', () => {
|
|
it('GET builds the upstream URL, forwards auth, and streams the body back', async () => {
|
|
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'get', 'X-WebDAV-Path': 'file.txt' })));
|
|
const target = 'https://mail.example.com/dav/file/user%40example.com/file.txt';
|
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
target,
|
|
expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ Authorization: 'Basic abc' }) }),
|
|
);
|
|
expect(res.status).toBe(207);
|
|
expect(res.body).toBe('UPSTREAM-BODY');
|
|
expect(res.headers!.get('Content-Type')).toBe('text/plain');
|
|
expect(res.headers!.get('X-WebDAV-Request-URI')).toBe(target);
|
|
});
|
|
|
|
it('PROPFIND forwards Depth and returns XML', async () => {
|
|
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'PROPFIND', 'X-WebDAV-Path': 'dir', Depth: '1' })));
|
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
'https://mail.example.com/dav/file/user%40example.com/dir',
|
|
expect.objectContaining({ method: 'PROPFIND', headers: expect.objectContaining({ Depth: '1' }) }),
|
|
);
|
|
expect(res.status).toBe(207);
|
|
expect(res.body).toBe('<xml/>');
|
|
expect(res.headers!.get('Content-Type')).toBe('application/xml; charset=utf-8');
|
|
});
|
|
|
|
it('MOVE rebuilds the Destination URL and forwards Overwrite', async () => {
|
|
await POST(makeReq({
|
|
'X-WebDAV-Method': 'MOVE',
|
|
'X-WebDAV-Path': 'old.txt',
|
|
'X-WebDAV-Destination': 'sub/new.txt',
|
|
Overwrite: 'F',
|
|
}));
|
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
'https://mail.example.com/dav/file/user%40example.com/old.txt',
|
|
expect.objectContaining({
|
|
method: 'MOVE',
|
|
headers: expect.objectContaining({
|
|
Destination: 'https://mail.example.com/dav/file/user%40example.com/sub/new.txt',
|
|
Overwrite: 'F',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|