9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
132 lines
6.2 KiB
JavaScript
132 lines
6.2 KiB
JavaScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
import './attachment-upload.js';
|
|
|
|
function makePage(envelope) {
|
|
return { goto: vi.fn(), evaluate: vi.fn().mockResolvedValue(envelope) };
|
|
}
|
|
|
|
function tmpfile(bytes) {
|
|
const p = path.join(os.tmpdir(), `slock-upload-test-${process.pid}-${Math.random().toString(36).slice(2)}.bin`);
|
|
fs.writeFileSync(p, bytes);
|
|
return p;
|
|
}
|
|
|
|
const UUID_CHAN = '11111111-1111-1111-1111-111111111111';
|
|
|
|
describe('slock attachment-upload', () => {
|
|
const command = getRegistry().get('slock/attachment-upload');
|
|
const files = [];
|
|
afterEach(() => { for (const f of files) try { fs.unlinkSync(f); } catch {} files.length = 0; });
|
|
|
|
it('refuses a missing file before navigation', async () => {
|
|
const page = makePage({ kind: 'ok', rows: [] });
|
|
await expect(command.func(page, { file: '/no/such/file/here.bin', channel: UUID_CHAN }))
|
|
.rejects.toThrow(/not readable/);
|
|
expect(page.goto).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses a missing channel before navigation (F4: server returns 400 without channelId)', async () => {
|
|
const f = tmpfile(Buffer.from('x')); files.push(f);
|
|
const page = makePage({ kind: 'ok', rows: [] });
|
|
await expect(command.func(page, { file: f })).rejects.toThrow(/channel required/);
|
|
expect(page.goto).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses an empty file before navigation', async () => {
|
|
const f = tmpfile(Buffer.alloc(0)); files.push(f);
|
|
const page = makePage({ kind: 'ok', rows: [] });
|
|
await expect(command.func(page, { file: f, channel: UUID_CHAN })).rejects.toThrow(/empty/);
|
|
expect(page.goto).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses a file larger than the 50 MiB server cap before navigation', async () => {
|
|
// We don't actually allocate 51 MiB; stub statSync to claim the size.
|
|
const f = tmpfile(Buffer.from('small')); files.push(f);
|
|
const orig = fs.statSync;
|
|
const spy = vi.spyOn(fs, 'statSync').mockImplementation((p) => {
|
|
if (p === path.resolve(f)) return { isFile: () => true, size: 51 * 1024 * 1024 };
|
|
return orig.call(fs, p);
|
|
});
|
|
try {
|
|
const page = makePage({ kind: 'ok', rows: [] });
|
|
await expect(command.func(page, { file: f, channel: UUID_CHAN })).rejects.toThrow(/exceeds server limit/);
|
|
expect(page.goto).not.toHaveBeenCalled();
|
|
} finally { spy.mockRestore(); }
|
|
});
|
|
|
|
it('sends a multipart upload with field name "files" (Bugen contract) and the uploadHeaders invariants', async () => {
|
|
const f = tmpfile(Buffer.from('hello world')); files.push(f);
|
|
const page = makePage({
|
|
kind: 'ok',
|
|
rows: [{ id: '550e8400-e29b-41d4-a716-446655440000', filename: path.basename(f), mimeType: 'application/octet-stream', sizeBytes: 11 }],
|
|
});
|
|
const rows = await command.func(page, { file: f, channel: UUID_CHAN });
|
|
expect(page.evaluate).toHaveBeenCalledOnce();
|
|
const snippet = page.evaluate.mock.calls[0][0];
|
|
|
|
// multipart field name MUST be 'files' — multer.array('files', 5). If
|
|
// someone changes it to 'file' the server returns 400; this catches drift
|
|
// before it ships.
|
|
expect(snippet).toContain("fd.append('files'");
|
|
// F4 — channelId multipart text part. qatester live-flagged: without
|
|
// this field server returns 400 "channelId is required". Pin so a
|
|
// future refactor that drops the line is caught pre-network.
|
|
expect(snippet).toContain("fd.append('channelId', channelId)");
|
|
// Endpoint stays put.
|
|
expect(snippet).toContain('/api/attachments/upload');
|
|
|
|
// --- The from-scratch `uploadHeaders` invariants (托瓦茲 #1 fix-now). ---
|
|
//
|
|
// The whole point of building uploadHeaders from scratch is: forward auth,
|
|
// drop content-type so the browser sets its own multipart boundary. If a
|
|
// future refactor either (a) drops Bearer / X-Server-Id, or (b) re-adds
|
|
// content-type, multipart auth or the boundary breaks.
|
|
|
|
// POSITIVE — Bearer MUST be copied into uploadHeaders. Drift catch: if
|
|
// someone writes `const uploadHeaders = { accept: headers.accept }` and
|
|
// forgets authorization, requests go out unauthenticated → 401 in prod.
|
|
expect(snippet).toMatch(/uploadHeaders\s*=\s*\{[^}]*\bauthorization\s*:\s*headers\.authorization/);
|
|
|
|
// POSITIVE — X-Server-Id MUST be conditionally forwarded. Drift catch:
|
|
// dropping this line means server-scoped uploads fall back to no-server
|
|
// selection and 400/403.
|
|
expect(snippet).toMatch(/uploadHeaders\[['"]x-server-id['"]\]\s*=\s*headers\[['"]x-server-id['"]\]/);
|
|
|
|
// NEGATIVE — content-type MUST NOT be set on uploadHeaders, in any form
|
|
// (bracket, dot, camelCase, quoted, unquoted). If anyone adds one,
|
|
// the browser stops generating the multipart boundary and the server
|
|
// 400s with "unexpected end of form".
|
|
expect(snippet).not.toMatch(/uploadHeaders\s*[\[\.][^=]*content[-_]?type/i);
|
|
|
|
// POSITIVE — fetch's headers field is uploadHeaders (not the raw `headers`
|
|
// which still carries content-type: application/json). Drift catch: a
|
|
// refactor that passes `headers` instead would silently break multipart.
|
|
expect(snippet).toMatch(/fetch\([^)]*?,[\s\S]*?headers:\s*uploadHeaders/);
|
|
expect(snippet).toMatch(/body:\s*fd[\s,]/);
|
|
|
|
expect(rows[0]).toMatchObject({
|
|
attachmentId: '550e8400-e29b-41d4-a716-446655440000',
|
|
filename: path.basename(f),
|
|
sizeBytes: 11,
|
|
});
|
|
});
|
|
|
|
it('passes server override through authHeadersFragment', async () => {
|
|
const f = tmpfile(Buffer.from('x')); files.push(f);
|
|
const page = makePage({ kind: 'ok', rows: [{ id: '550e8400-e29b-41d4-a716-446655440000' }] });
|
|
await command.func(page, { file: f, channel: UUID_CHAN, server: 'jackyland' });
|
|
const snippet = page.evaluate.mock.calls[0][0];
|
|
expect(snippet).toContain('"jackyland"');
|
|
});
|
|
|
|
it('413 response surfaces as a clean http error mentioning maxBytes', async () => {
|
|
const f = tmpfile(Buffer.from('x')); files.push(f);
|
|
const page = makePage({ kind: 'http', status: 413, where: '/attachments/upload (file too large; maxBytes=52428800)' });
|
|
await expect(command.func(page, { file: f, channel: UUID_CHAN })).rejects.toThrow(/HTTP 413.*maxBytes=52428800/);
|
|
});
|
|
});
|