mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-13 02:39:42 +02:00
Merge pull request #574: feat(uploads): write prompt uploads to a hidden, self-ignoring .codeman-uploads/ folder
Conflicts resolved against the release branch: docs/api-reference.md keeps both new sections (codeman agent CLI, then Prompt uploads); test/test-ports-guard.test.ts takes the release side (#570 already removed the legacy list); test/paste-image-dir-shared.test.ts keeps #570's ephemeral port and this PR's bounded-path-probe mock. Also at merge: the Docker sentence in the route comment and the API reference is narrowed to owned cases, since an adopted container mounts nothing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { ImageWatcher } from '../src/image-watcher.js';
|
||||
import { watch } from 'chokidar';
|
||||
import { statSync } from 'node:fs';
|
||||
|
||||
describe('ImageWatcher', () => {
|
||||
@@ -92,6 +93,14 @@ describe('ImageWatcher', () => {
|
||||
expect(watcher.getWatchedSessions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores Codeman's own upload folders, so a pdf the user handed over is not a detected artifact", () => {
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const [, opts] = vi.mocked(watch).mock.calls.at(-1) as unknown as [string, { ignored: (p: string) => boolean }];
|
||||
expect(opts.ignored('/home/user/project/.codeman-uploads/paste-1-ab.pdf')).toBe(true);
|
||||
expect(opts.ignored('/home/user/project/.claude-images/paste-1-ab.png')).toBe(true);
|
||||
expect(opts.ignored('/home/user/project/docs/report.pdf')).toBe(false);
|
||||
});
|
||||
|
||||
it('should replace watcher when working directory changes', () => {
|
||||
watcher.watchSession('session-1', '/home/user/project-a');
|
||||
watcher.watchSession('session-1', '/home/user/project-b');
|
||||
|
||||
@@ -124,11 +124,11 @@ describe('terminal link-provider regexes (shipped source)', () => {
|
||||
});
|
||||
|
||||
it('the file-path pattern links pasted image/PDF/media attachment paths', () => {
|
||||
// `.claude-images/paste-*.png` is what Codeman writes for a pasted screenshot;
|
||||
// `.codeman-uploads/paste-*.png` is what Codeman writes for a pasted screenshot;
|
||||
// without image extensions the path rendered as plain, unclickable text.
|
||||
const ext = shippedPattern('FILE_PATH_LINK_PATTERN');
|
||||
const cases = [
|
||||
'/home/arkon/default/claudeman/.claude-images/paste-1785164958410-d11eb7d0.png',
|
||||
'/home/arkon/default/claudeman/.codeman-uploads/paste-1785164958410-d11eb7d0.png',
|
||||
'/tmp/shot.jpeg',
|
||||
'/opt/app/report.pdf',
|
||||
'/home/a/diagram.svg',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* @fileoverview Deleting a session keeps `.claude-images` while a sibling in the
|
||||
* @fileoverview Deleting a session keeps the upload dirs while a sibling in the
|
||||
* same working directory is still live (Ark0N/Codeman#446).
|
||||
*
|
||||
* `cleanupSession()` removes `{workingDir}/.claude-images` recursively. That
|
||||
* `cleanupSession()` removes `{workingDir}/.codeman-uploads` (and the pre-move
|
||||
* `.claude-images`) recursively. That
|
||||
* dir belongs to the working directory, not to the session, and several
|
||||
* sessions routinely share one case directory, so closing one used to delete
|
||||
* the pasted images a live sibling still referred to. The exited-agent sweep
|
||||
@@ -11,12 +12,18 @@
|
||||
*
|
||||
* Port: ephemeral
|
||||
*/
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { pasteImageDirInUseByOtherSession } from '../src/web/paste-image-gc.js';
|
||||
import { probePathKind } from '../src/utils/bounded-path-probe.js';
|
||||
|
||||
vi.mock('../src/utils/bounded-path-probe.js', async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import('../src/utils/bounded-path-probe.js')>();
|
||||
return { ...real, probePathKind: vi.fn(real.probePathKind) };
|
||||
});
|
||||
|
||||
describe('pasteImageDirInUseByOtherSession', () => {
|
||||
const none = new Set<string>();
|
||||
@@ -132,18 +139,48 @@ describe('deleting a session that shares its working directory', () => {
|
||||
|
||||
const remove = (id: string) => fetch(`${base}/api/sessions/${id}`, { method: 'DELETE' });
|
||||
|
||||
it('keeps the images while a sibling is live, and removes them with the last session', async () => {
|
||||
it('keeps the uploads while a sibling is live, and removes them with the last session', async () => {
|
||||
const first = await create();
|
||||
const second = await create();
|
||||
const imageDir = join(workingDir, '.claude-images');
|
||||
mkdirSync(imageDir, { recursive: true });
|
||||
writeFileSync(join(imageDir, 'paste-1.png'), 'x');
|
||||
// Both the current dir and the pre-move one, which a case in use back then still carries.
|
||||
const uploadDir = join(workingDir, '.codeman-uploads');
|
||||
const legacyDir = join(workingDir, '.claude-images');
|
||||
mkdirSync(uploadDir, { recursive: true });
|
||||
mkdirSync(legacyDir, { recursive: true });
|
||||
writeFileSync(join(uploadDir, 'paste-1.png'), 'x');
|
||||
writeFileSync(join(legacyDir, 'paste-0.png'), 'x');
|
||||
|
||||
expect((await remove(first)).status).toBe(200);
|
||||
expect(existsSync(join(imageDir, 'paste-1.png'))).toBe(true);
|
||||
expect(existsSync(join(uploadDir, 'paste-1.png'))).toBe(true);
|
||||
expect(existsSync(join(legacyDir, 'paste-0.png'))).toBe(true);
|
||||
|
||||
// The delete acts on one path at the user's request, so its probe goes past
|
||||
// the bulk stall cap (#516); the hourly sweep keeps the cap. Cleared first:
|
||||
// the create path probes the same workspace past the cap too.
|
||||
vi.mocked(probePathKind).mockClear();
|
||||
expect((await remove(second)).status).toBe(200);
|
||||
expect(existsSync(imageDir)).toBe(false);
|
||||
expect(existsSync(uploadDir)).toBe(false);
|
||||
expect(existsSync(legacyDir)).toBe(false);
|
||||
expect(probePathKind).toHaveBeenCalledWith(workingDir, { pastCap: true });
|
||||
});
|
||||
|
||||
it('does not follow a planted symlink at the upload dir into another case when the last session closes', async () => {
|
||||
const other = mkdtempSync(join(tmpdir(), 'codeman-paste-other-'));
|
||||
const otherUploads = join(other, '.codeman-uploads');
|
||||
mkdirSync(otherUploads);
|
||||
writeFileSync(join(otherUploads, 'paste-7.png'), 'x');
|
||||
// Planted by a workspace script. uploadDirs() never lists a link, so the delete
|
||||
// removes nothing here, and the link itself stays: it is not Codeman's to remove.
|
||||
symlinkSync(otherUploads, join(workingDir, '.codeman-uploads'));
|
||||
try {
|
||||
const only = await create();
|
||||
expect((await remove(only)).status).toBe(200);
|
||||
expect(existsSync(join(otherUploads, 'paste-7.png'))).toBe(true);
|
||||
expect(lstatSync(join(workingDir, '.codeman-uploads')).isSymbolicLink()).toBe(true);
|
||||
} finally {
|
||||
rmSync(join(workingDir, '.codeman-uploads'), { force: true });
|
||||
rmSync(other, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the images while a sibling is only detached, since it still runs in tmux', async () => {
|
||||
@@ -153,7 +190,7 @@ describe('deleting a session that shares its working directory', () => {
|
||||
// write, so wait for the record a long-running session would already have.
|
||||
const store = (server as unknown as { store: { getSession: (id: string) => unknown } }).store;
|
||||
await vi.waitFor(() => expect(store.getSession(detached)).toBeTruthy(), { timeout: 10_000 });
|
||||
const imageDir = join(workingDir, '.claude-images');
|
||||
const imageDir = join(workingDir, '.codeman-uploads');
|
||||
mkdirSync(imageDir, { recursive: true });
|
||||
writeFileSync(join(imageDir, 'paste-2.png'), 'x');
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @fileoverview The hourly upload sweep reads BOTH upload dirs of a live session:
|
||||
* `.codeman-uploads` and the `.claude-images` a case in use before the move still
|
||||
* carries. Only `paste-*` regular files past the age cap go. `uploadDirs()` never
|
||||
* lists a planted symlink, a directory that is or contains the data dir, anything
|
||||
* under a workspace whose bounded probe did not answer, or anything for a remote
|
||||
* session, since the sweep and the delete cleanup both act on what it returns.
|
||||
*/
|
||||
import { existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { LEGACY_UPLOADS_DIR, UPLOADS_DIR, sweepPasteImagesOnce, uploadDirs } from '../src/web/paste-image-gc.js';
|
||||
import { probePathKind } from '../src/utils/bounded-path-probe.js';
|
||||
|
||||
vi.mock('../src/utils/bounded-path-probe.js', async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import('../src/utils/bounded-path-probe.js')>();
|
||||
return { ...real, probePathKind: vi.fn(real.probePathKind) };
|
||||
});
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const sessionsOf = (workingDir: string, remote?: unknown) => ({
|
||||
sessions: new Map([['s1', { workingDir, remote } as never]]) as never,
|
||||
});
|
||||
|
||||
describe('sweepPasteImagesOnce', () => {
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('ages paste-* files out of both the current and the legacy upload dir', async () => {
|
||||
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-'));
|
||||
dirs.push(workingDir);
|
||||
const now = Date.now();
|
||||
const current = join(workingDir, UPLOADS_DIR);
|
||||
const legacy = join(workingDir, LEGACY_UPLOADS_DIR);
|
||||
mkdirSync(current);
|
||||
mkdirSync(legacy);
|
||||
expect(await uploadDirs({ workingDir })).toEqual([current, legacy]);
|
||||
const old = (now - 8 * DAY_MS) / 1000;
|
||||
const fresh = (now - 1 * DAY_MS) / 1000;
|
||||
const plant = (dir: string, name: string, mtime: number) => {
|
||||
writeFileSync(join(dir, name), 'x');
|
||||
utimesSync(join(dir, name), mtime, mtime);
|
||||
};
|
||||
plant(current, 'paste-1-aa.pdf', old);
|
||||
plant(current, 'paste-2-bb.png', fresh);
|
||||
plant(current, 'keep.png', old); // not an upload of ours
|
||||
plant(legacy, 'paste-0-cc.png', old);
|
||||
symlinkSync(join(legacy, 'paste-0-cc.png'), join(current, 'paste-3-dd.png'));
|
||||
utimesSync(join(current, 'paste-3-dd.png'), old, old);
|
||||
|
||||
const result = await sweepPasteImagesOnce(sessionsOf(workingDir), now);
|
||||
|
||||
expect(result.deleted).toBe(2);
|
||||
expect(existsSync(join(current, 'paste-1-aa.pdf'))).toBe(false);
|
||||
expect(existsSync(join(legacy, 'paste-0-cc.png'))).toBe(false);
|
||||
expect(existsSync(join(current, 'paste-2-bb.png'))).toBe(true);
|
||||
expect(existsSync(join(current, 'keep.png'))).toBe(true);
|
||||
// A planted symlink is skipped (lstat, never followed), so the sweep leaves the link itself.
|
||||
expect(lstatSync(join(current, 'paste-3-dd.png')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('never reads through a planted symlink at the upload dir', async () => {
|
||||
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-'));
|
||||
const other = mkdtempSync(join(tmpdir(), 'codeman-gc-other-'));
|
||||
dirs.push(workingDir, other);
|
||||
const now = Date.now();
|
||||
const old = (now - 8 * DAY_MS) / 1000;
|
||||
mkdirSync(join(other, UPLOADS_DIR));
|
||||
writeFileSync(join(other, UPLOADS_DIR, 'paste-9-zz.png'), 'x');
|
||||
utimesSync(join(other, UPLOADS_DIR, 'paste-9-zz.png'), old, old);
|
||||
// readdir follows a link to a directory, so a listed link would let the sweep
|
||||
// age out the other case's uploads through it.
|
||||
symlinkSync(join(other, UPLOADS_DIR), join(workingDir, UPLOADS_DIR));
|
||||
|
||||
expect(await uploadDirs({ workingDir })).toEqual([]);
|
||||
expect(await sweepPasteImagesOnce(sessionsOf(workingDir), now)).toEqual({ scanned: 0, deleted: 0 });
|
||||
expect(existsSync(join(other, UPLOADS_DIR, 'paste-9-zz.png'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadDirs', () => {
|
||||
const dirs: string[] = [];
|
||||
const savedDataDir = process.env.CODEMAN_DATA_DIR;
|
||||
afterEach(() => {
|
||||
if (savedDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
|
||||
else process.env.CODEMAN_DATA_DIR = savedDataDir;
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('lists the upload dir of a home workspace, which sits beside the data dir, not inside it', async () => {
|
||||
// The home-as-workspace case the flat name exists for: `<home>/.codeman-uploads`
|
||||
// is a sibling of `<home>/.codeman`, so a prefix test without the separator would
|
||||
// wrongly refuse it.
|
||||
const home = mkdtempSync(join(tmpdir(), 'codeman-gc-home-'));
|
||||
dirs.push(home);
|
||||
process.env.CODEMAN_DATA_DIR = join(home, '.codeman');
|
||||
const dir = join(home, UPLOADS_DIR);
|
||||
mkdirSync(dir);
|
||||
expect(await uploadDirs({ workingDir: home })).toEqual([dir]);
|
||||
});
|
||||
|
||||
it('refuses an upload dir that is the data dir or contains it, and lists one inside it', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeman-gc-data-'));
|
||||
const parent = mkdtempSync(join(tmpdir(), 'codeman-gc-link-'));
|
||||
dirs.push(root, parent);
|
||||
const uploads = join(root, UPLOADS_DIR);
|
||||
mkdirSync(join(uploads, 'state'), { recursive: true });
|
||||
// `CODEMAN_INSTANCE=uploads` on a home workspace: the upload dir IS the data dir.
|
||||
process.env.CODEMAN_DATA_DIR = uploads;
|
||||
expect(await uploadDirs({ workingDir: root })).toEqual([]);
|
||||
// A data dir pointed inside the upload dir: the recursive delete would take it.
|
||||
process.env.CODEMAN_DATA_DIR = join(uploads, 'state');
|
||||
expect(await uploadDirs({ workingDir: root })).toEqual([]);
|
||||
// An upload dir strictly inside the data dir only ever holds uploads (install.sh
|
||||
// clones the app to ~/.codeman/app), so it is listed, directly and through a
|
||||
// symlinked working directory.
|
||||
process.env.CODEMAN_DATA_DIR = root;
|
||||
expect(await uploadDirs({ workingDir: root })).toEqual([uploads]);
|
||||
symlinkSync(root, join(parent, 'ws'));
|
||||
expect(await uploadDirs({ workingDir: join(parent, 'ws') })).toEqual([join(parent, 'ws', UPLOADS_DIR)]);
|
||||
});
|
||||
|
||||
it('skips a workspace whose bounded probe does not answer, without touching it', async () => {
|
||||
// A linked case on a mount that stopped answering reads `unknown` (#516): the
|
||||
// sweep must not lstat under it, which would hold a threadpool worker.
|
||||
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-stalled-'));
|
||||
dirs.push(workingDir);
|
||||
mkdirSync(join(workingDir, UPLOADS_DIR));
|
||||
vi.mocked(probePathKind).mockResolvedValueOnce('unknown');
|
||||
const lstat = vi.spyOn(fsp, 'lstat');
|
||||
try {
|
||||
expect(await uploadDirs({ workingDir })).toEqual([]);
|
||||
expect(lstat).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
lstat.mockRestore();
|
||||
}
|
||||
expect(probePathKind).toHaveBeenCalledWith(workingDir, {});
|
||||
});
|
||||
|
||||
it('lists nothing for a remote session, whose workingDir is the remote path', async () => {
|
||||
// The same path on THIS host belongs to whoever has a local case there.
|
||||
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-remote-'));
|
||||
dirs.push(workingDir);
|
||||
mkdirSync(join(workingDir, UPLOADS_DIR));
|
||||
writeFileSync(join(workingDir, UPLOADS_DIR, 'paste-1-aa.png'), 'x');
|
||||
const old = (Date.now() - 8 * DAY_MS) / 1000;
|
||||
utimesSync(join(workingDir, UPLOADS_DIR, 'paste-1-aa.png'), old, old);
|
||||
const remote = { hostId: 'gpu-box' };
|
||||
expect(await uploadDirs({ workingDir, remote })).toEqual([]);
|
||||
expect(await sweepPasteImagesOnce(sessionsOf(workingDir, remote))).toEqual({ scanned: 0, deleted: 0 });
|
||||
expect(existsSync(join(workingDir, UPLOADS_DIR, 'paste-1-aa.png'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -144,7 +144,7 @@ describe('a rebuild that fails after the session is registered', () => {
|
||||
expect(ctx.sessions.has('a')).toBe(false);
|
||||
// NOT the user-initiated delete: that would bank this session's historical
|
||||
// tokens into the lifetime totals, demote a pinned record to `stopped`, and
|
||||
// delete the workspace's .claude-images.
|
||||
// delete the workspace's .codeman-uploads.
|
||||
expect(ctx.cleanupSession).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -18,9 +18,9 @@ import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js';
|
||||
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
|
||||
import fs, { mkdtemp, rm, mkdir, writeFile, readFile, readdir, symlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
@@ -285,7 +285,7 @@ describe('session-routes', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/);
|
||||
expect(body.data.path).toMatch(/\/\.codeman-uploads\/paste-\d+-[a-f0-9]{8}\.jpg$/);
|
||||
expect(heicConvert).toHaveBeenCalledWith(heic);
|
||||
});
|
||||
|
||||
@@ -314,10 +314,93 @@ describe('session-routes', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/);
|
||||
expect(body.data.path).toMatch(/\/\.codeman-uploads\/paste-\d+-[a-f0-9]{8}\.jpg$/);
|
||||
expect(heicConvert).toHaveBeenCalledWith(heic);
|
||||
});
|
||||
|
||||
const jpeg = Buffer.from('ffd8ffe000104a46494600010100', 'hex');
|
||||
const upload = (name: string, mime: string, bytes: Buffer) =>
|
||||
harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
|
||||
headers: {
|
||||
host: 'codeman.test',
|
||||
origin: 'http://codeman.test',
|
||||
'content-type': 'multipart/form-data; boundary=codeman-test-boundary',
|
||||
},
|
||||
payload: imageUploadBody('codeman-test-boundary', name, mime, bytes),
|
||||
});
|
||||
|
||||
it('writes into .codeman-uploads with a self-ignoring .gitignore, written once and never over a file already there', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-uploads-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
try {
|
||||
const first = await upload('shot.jpg', 'image/jpeg', jpeg);
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(JSON.parse(first.body).data.path).toMatch(/\/\.codeman-uploads\/paste-\d+-[a-f0-9]{8}\.jpg$/);
|
||||
expect(await readdir(workDir)).toEqual(['.codeman-uploads']);
|
||||
expect(await readFile(join(workDir, '.codeman-uploads', '.gitignore'), 'utf8')).toBe('*\n');
|
||||
|
||||
await writeFile(join(workDir, '.codeman-uploads', '.gitignore'), 'theirs\n');
|
||||
expect((await upload('shot.jpg', 'image/jpeg', jpeg)).statusCode).toBe(200);
|
||||
expect(await readFile(join(workDir, '.codeman-uploads', '.gitignore'), 'utf8')).toBe('theirs\n');
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('takes back an ignore file whose write failed, so the next upload writes a real one', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-uploads-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
const ignoreFile = join(workDir, '.codeman-uploads', '.gitignore');
|
||||
// The exclusive create succeeds and the two-byte write fails, as on a full disk.
|
||||
const spy = vi.spyOn(fs, 'writeFile').mockImplementationOnce(async (path) => {
|
||||
await writeFile(path as string, '');
|
||||
throw Object.assign(new Error('no space left on device'), { code: 'ENOSPC' });
|
||||
});
|
||||
try {
|
||||
expect((await upload('shot.jpg', 'image/jpeg', jpeg)).statusCode).toBe(500);
|
||||
expect(existsSync(ignoreFile)).toBe(false);
|
||||
expect((await upload('shot.jpg', 'image/jpeg', jpeg)).statusCode).toBe(200);
|
||||
expect(await readFile(ignoreFile, 'utf8')).toBe('*\n');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
await rm(workDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a planted symlink at .codeman-uploads and writes nothing through it', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-uploads-'));
|
||||
const elsewhere = await mkdtemp(join(tmpdir(), 'codeman-elsewhere-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
await symlink(elsewhere, join(workDir, '.codeman-uploads'));
|
||||
try {
|
||||
const res = await upload('shot.jpg', 'image/jpeg', jpeg);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res.body).error).toBe('.codeman-uploads is not a regular directory');
|
||||
expect(await readdir(elsewhere)).toEqual([]);
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true });
|
||||
await rm(elsewhere, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses an upload for a remote session before touching disk', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-uploads-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
// A remote session's workingDir is the REMOTE path: a file written here is unreadable there.
|
||||
(harness.ctx._session as unknown as { remote: unknown }).remote = { hostId: 'gpu-box' };
|
||||
try {
|
||||
const res = await upload('shot.jpg', 'image/jpeg', jpeg);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toMatch(/remote/);
|
||||
expect(await readdir(workDir)).toEqual([]);
|
||||
} finally {
|
||||
(harness.ctx._session as unknown as { remote: unknown }).remote = undefined;
|
||||
await rm(workDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 415 with the error envelope when HEIC conversion fails', async () => {
|
||||
heicConvert.mockClear();
|
||||
heicConvert.mockRejectedValueOnce(new Error('HEIC dimensions 30000x30000 exceed the 64MP decode limit'));
|
||||
|
||||
Reference in New Issue
Block a user