Files
jackwener--opencli/clis/slock/attachment-upload.test.js
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

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/);
});
});