Repository Public
1import { createHash, randomBytes } from 'node:crypto';2import type { FastifyInstance } from 'fastify';3import { createInvite } from '../src/auth.ts';4import { config } from '../src/config.ts';5import { migrate, sql } from '../src/db.ts';6import { drain } from '../src/jobs.ts';7import { buildServer } from '../src/server.ts';8import { handlers } from '../src/worker.ts';9import { SoftAuthenticator } from './authenticator.ts';1011let app: FastifyInstance | undefined;12export async function server(): Promise<FastifyInstance> {13 if (!app) { await migrate(); app = await buildServer({ logger: false }); await app.ready(); }14 return app;15}16export async function closeAll(): Promise<void> { await app?.close(); app = undefined; await sql.end({ timeout: 5 }); }17/** Run every queued job to completion (scans, previews, submissions, garbage collection), including delayed ones. */18export async function work(): Promise<void> {19 await sql`update jobs set run_at = now() where status = 'queued'`; // jobs waiting for a retry get one more try20 for (let i = 0; i < 50; i++) {21 if ((await drain(handlers)) === 0) return;22 await sql`update jobs set run_at = now() where status = 'queued' and attempts = 0`; // jobs created by the jobs that just ran23 }24}2526export interface Res<T = any> { status: number; body: T; headers: Record<string, any>; raw: Buffer }27/** An HTTP client with its own cookie jar, talking to the in-process server. */28export class Client {29 cookies = new Map<string, string>();30 ip = '10.0.0.1';31 constructor(public auth?: SoftAuthenticator) {}32 async call<T = any>(method: string, url: string, body?: unknown, headers: Record<string, string> = {}): Promise<Res<T>> {33 const a = await server();34 const isRaw = Buffer.isBuffer(body);35 const res = await a.inject({36 method: method as 'GET', url, remoteAddress: this.ip,37 headers: { ...(method !== 'GET' && method !== 'HEAD' ? { 'x-spd-csrf': '1', origin: config.origin } : {}), ...(this.cookies.size ? { cookie: [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ') } : {}),38 ...(isRaw ? { 'content-type': 'application/octet-stream' } : body !== undefined ? { 'content-type': 'application/json' } : {}), ...headers },39 payload: isRaw ? (body as Buffer) : body !== undefined ? JSON.stringify(body) : undefined,40 });41 for (const c of res.cookies) { if (c.value === '' || (c.expires && c.expires.getTime() < Date.now()) || c.maxAge === 0) this.cookies.delete(c.name); else this.cookies.set(c.name, c.value); }42 let parsed: any = undefined;43 if (String(res.headers['content-type'] ?? '').includes('json')) { try { parsed = res.json(); } catch { /* not json */ } }44 return { status: res.statusCode, body: parsed as T, headers: res.headers, raw: res.rawPayload };45 }46 get = <T = any>(url: string, headers?: Record<string, string>) => this.call<T>('GET', url, undefined, headers);47 post = <T = any>(url: string, body: unknown = {}, headers?: Record<string, string>) => this.call<T>('POST', url, body, headers);48 patch = <T = any>(url: string, body: unknown = {}, headers?: Record<string, string>) => this.call<T>('PATCH', url, body, headers);49 del = <T = any>(url: string) => this.call<T>('DELETE', url);5051 /** Re-authenticate with the passkey (sensitive operations). */52 async reauth(): Promise<void> {53 const o = await this.post('/api/v1/auth/reauth/options');54 const r = await this.post('/api/v1/auth/reauth/verify', { challengeId: o.body.challengeId, response: this.auth!.get(o.body.options) });55 if (r.status !== 200) throw new Error(`reauth failed: ${JSON.stringify(r.body)}`);56 }57 async login(): Promise<Res> {58 const o = await this.post('/api/v1/auth/login/options');59 return this.post('/api/v1/auth/login/verify', { challengeId: o.body.challengeId, response: this.auth!.get(o.body.options) });60 }61 /** Upload bytes through the resumable protocol and wait for nothing: returns the completed upload answer. */62 async upload(folderId: string, name: string, data: Buffer, o: { onConflict?: string; relativePath?: string; fileId?: string; chunk?: number } = {}): Promise<Res> {63 const created = await this.post('/api/v1/uploads', { folderId, name, size: data.length, onConflict: o.onConflict, relativePath: o.relativePath, fileId: o.fileId });64 if (created.status !== 200) return created;65 const step = o.chunk ?? 4 * 1024 * 1024;66 for (let off = 0; off < data.length; off += step) {67 const part = data.subarray(off, off + step);68 const r = await this.patch(`/api/v1/uploads/${created.body.id}?offset=${off}`, part, { 'x-chunk-sha256': sha(part) });69 if (r.status !== 200) return r;70 }71 return this.post(`/api/v1/uploads/${created.body.id}/complete`, { sha256: sha(data) });72 }73}74export const sha = (b: Buffer) => createHash('sha256').update(b).digest('hex');75let ipSeq = 0;76/** An anonymous visitor on its own address (the per-address rate limits stay active in tests, visitors just do not share one). */77export const anon = () => { const c = new Client(); ipSeq++; c.ip = `10.${100 + Math.floor(ipSeq / 60000)}.${Math.floor(ipSeq / 250) % 250}.${1 + (ipSeq % 250)}`; return c; };7879let n = 0;80/** Create an account through the real invitation + passkey registration flow. */81export async function signup(o: { role?: 'admin' | 'user'; quotaBytes?: number } = {}): Promise<{ client: Client; email: string; userId: string; recoveryCodes: string[]; rootId: string }> {82 await server();83 const email = `user${++n}-${randomBytes(3).toString('hex')}@example.test`;84 const inv = await createInvite(sql, { email, role: o.role ?? 'user', quotaBytes: o.quotaBytes });85 const client = new Client(new SoftAuthenticator(config.origin, config.rpId));86 const opt = await client.post('/api/v1/auth/register/options', { invite: inv.token, displayName: `Test ${n}` });87 if (opt.status !== 200) throw new Error(`register options: ${JSON.stringify(opt.body)}`);88 const ver = await client.post('/api/v1/auth/register/verify', { invite: inv.token, challengeId: opt.body.challengeId, response: client.auth!.create(opt.body.options), label: 'Test key' });89 if (ver.status !== 200) throw new Error(`register verify: ${JSON.stringify(ver.body)}`);90 const state = await client.get('/api/v1/auth/state');91 const root = await client.get('/api/v1/folders/root');92 return { client, email, userId: state.body.user.id, recoveryCodes: ver.body.recoveryCodes, rootId: root.body.folder.id };93}94