Files
PaperClipAI/server/src/services/remote-workspace-snapshot-storage.test.ts
T
DottaandPaperclip 5e4cc01073 feat(workspaces): keep remote files off the webserver disk (#18403)
Keep native sandbox workspace files independent of the webserver disk. Use encrypted saved versions and Postgres ownership so a replacement server can recover files and continue work. Enable the path by default for new eligible workspaces when durable storage is configured.

Preserve explicit opt-outs and existing host workspaces. Include safe snapshot repair, Files access, import, migration and retention. Bound saved-Files resources, preserve imported Git recovery, reject unfinished Git operations, publish immutable concurrent captures, and verify permanent object-version deletion.

Validated 471 targeted local controls, build/typechecks, all 40 CI partitions, fresh Greptile 5/5 and security review. The PR records original failed attempts and scoped retries.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-31 14:54:01 -05:00

148 lines
11 KiB
TypeScript

import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { applyPendingMigrations, closeRegisteredClients, createDb, executionWorkspaceSnapshots } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
import { createStorageService } from "../storage/service.js";
import type { StorageService } from "../storage/types.js";
import { captureRemoteWorkspaceDirectory, restoreRemoteWorkspaceArchive } from "./remote-workspace-snapshot-archive.js";
import { beginWorkspaceSnapshot, getWorkspaceSnapshot, loadWorkspaceSnapshotArchive, uploadWorkspaceSnapshot } from "./remote-workspace-snapshot-storage.js";
const externalDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL;
if (externalDatabaseUrl && !/^remote_workspace_[a-z_]*test$/.test(new URL(externalDatabaseUrl).pathname.slice(1))) {
throw new Error("Workspace storage tests require a disposable remote_workspace_*test database");
}
const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
const suite = support.supported ? describe : describe.skip;
if (!support.supported) console.warn(`Remote workspace snapshot DB tests unavailable: ${support.reason}`);
suite("remote workspace snapshot storage", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>, db: ReturnType<typeof createDb>;
let root: string, storage: StorageService;
beforeAll(async () => {
if (externalDatabaseUrl) { await applyPendingMigrations(externalDatabaseUrl); db = createDb(externalDatabaseUrl); }
else { database = await startEmbeddedPostgresTestDatabase("workspace-snapshot-"); db = createDb(database.connectionString); }
}, 90_000);
afterAll(async () => { if (externalDatabaseUrl) await closeRegisteredClients(externalDatabaseUrl); else await database?.cleanup(); });
beforeEach(async () => {
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "41".repeat(32));
root = await fs.mkdtemp(path.join(os.tmpdir(), "pc-workspace-store-test-"));
storage = createStorageService(createLocalDiskStorageProvider(path.join(root, "objects")));
});
afterEach(async () => { vi.unstubAllEnvs(); await db.delete(executionWorkspaceSnapshots); await fs.rm(root, { recursive: true, force: true }); });
async function fixture() {
const companyId = randomUUID(), workspaceId = randomUUID(), operationId = randomUUID();
const cwd = path.join(root, "source"), archivePath = path.join(root, "archive.pcws"); await fs.mkdir(cwd);
await fs.writeFile(path.join(cwd, "saved.txt"), "acknowledged workspace content\n");
const captured = await captureRemoteWorkspaceDirectory({ cwd, archivePath });
const row = await beginWorkspaceSnapshot(db, { companyId, workspaceId, operationId, generation: 1, epoch: 3 });
const upload = { db, storage, snapshotId: row.id, companyId, workspaceId, archivePath, manifestDigest: captured.manifestDigest };
return { companyId, workspaceId, operationId, cwd, archivePath, captured, row, upload };
}
it("streams encrypted saved data through the real ledger and filesystem object backend", async () => {
const f = await fixture(), ref = await uploadWorkspaceSnapshot(f.upload);
expect(ref.byteSize).toBeGreaterThan(f.captured.byteSize);
const object = await storage.getObject(f.companyId, ref.objectKey); const parts: Buffer[] = [];
for await (const part of object.stream) parts.push(part);
expect(Buffer.concat(parts).includes(Buffer.from("acknowledged workspace content"))).toBe(false);
await fs.rm(f.cwd, { recursive: true }); await fs.rm(f.archivePath);
const restoredArchive = path.join(root, "restored.pcws"), targetDir = path.join(root, "restored");
await loadWorkspaceSnapshotArchive({ storage, reference: ref, targetPath: restoredArchive });
await restoreRemoteWorkspaceArchive({ archivePath: restoredArchive, targetDir, expectedManifestDigest: ref.manifestDigest });
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("acknowledged workspace content\n");
const [row] = await db.select().from(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.id, ref.id));
expect(row?.status).toBe("uploaded"); // The owning service still controls atomic publication.
});
it("reconciles an upload whose reply was lost after object persistence, without the old scratch file", async () => {
const f = await fixture(); const put = storage.putFile.bind(storage); let loseReply = true;
storage.putFile = async input => { const result = await put(input); if (loseReply) { loseReply = false; throw new Error("lost upload reply"); } return result; };
await expect(uploadWorkspaceSnapshot(f.upload)).rejects.toThrow("lost upload reply");
await fs.rm(f.archivePath);
const ref = await uploadWorkspaceSnapshot(f.upload);
expect(ref.manifestDigest).toBe(f.captured.manifestDigest);
expect(await getWorkspaceSnapshot(db, { ...f, snapshotId: ref.id })).toEqual(ref);
const [row] = await db.select().from(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.id, ref.id));
expect((row?.metadata?.uploads as unknown[]).length).toBe(1);
});
it("retains an unsuccessful operation and rejects a content-changing retry", async () => {
const f = await fixture(); storage.putFile = async () => { throw new Error("storage unavailable"); };
await expect(uploadWorkspaceSnapshot(f.upload)).rejects.toThrow("storage unavailable");
const [row] = await db.select().from(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.id, f.row.id));
expect(row?.status).toBe("pending"); expect((row?.metadata?.uploads as unknown[]).length).toBe(1);
await expect(uploadWorkspaceSnapshot({ ...f.upload, manifestDigest: "b".repeat(64) })).rejects.toThrow("operation_content_changed");
});
it("keeps operation identity and company/workspace scope exact", async () => {
const f = await fixture();
expect((await beginWorkspaceSnapshot(db, { ...f, generation: 1, epoch: 3 })).id).toBe(f.row.id);
await expect(beginWorkspaceSnapshot(db, { ...f, generation: 1, epoch: 4 })).rejects.toThrow("operation_binding_changed");
await uploadWorkspaceSnapshot(f.upload);
expect(await getWorkspaceSnapshot(db, { ...f, snapshotId: f.row.id, companyId: randomUUID() })).toBeNull();
expect(await getWorkspaceSnapshot(db, { ...f, snapshotId: f.row.id, workspaceId: randomUUID() })).toBeNull();
});
it("rejects object corruption and leaves the requested plaintext path unpublished", async () => {
const f = await fixture(), ref = await uploadWorkspaceSnapshot(f.upload);
const objectPath = path.join(root, "objects", ref.objectKey), object = await fs.open(objectPath, "r+");
try { await object.write(Buffer.from("X"), 0, 1, 15); } finally { await object.close(); }
const targetPath = path.join(root, "corrupted.pcws");
await expect(loadWorkspaceSnapshotArchive({ storage, reference: ref, targetPath })).rejects.toThrow("object_hash_mismatch");
await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("rejects a foreign wrapped key even when ciphertext metadata is unchanged", async () => {
const f = await fixture(), ref = await uploadWorkspaceSnapshot(f.upload);
const foreignId = randomUUID();
await expect(loadWorkspaceSnapshotArchive({ storage, reference: { ...ref, id: foreignId, objectKey: ref.objectKey.replace(ref.id, foreignId) },
targetPath: path.join(root, "foreign.pcws") })).rejects.toThrow("key_binding_invalid");
});
it("does not overwrite an existing caller file", async () => {
const f = await fixture(), ref = await uploadWorkspaceSnapshot(f.upload), targetPath = path.join(root, "existing.pcws");
await fs.writeFile(targetPath, "existing");
await expect(loadWorkspaceSnapshotArchive({ storage, reference: ref, targetPath })).rejects.toMatchObject({ code: "EEXIST" });
expect(await fs.readFile(targetPath, "utf8")).toBe("existing");
});
it("does not start object retrieval for an already canceled snapshot load", async () => {
const f = await fixture(), ref = await uploadWorkspaceSnapshot(f.upload);
const getObject = vi.spyOn(storage, "getObject");
const controller = new AbortController(), reason = new Error("request already canceled");
controller.abort(reason);
const targetPath = path.join(root, "canceled.pcws");
const error = await loadWorkspaceSnapshotArchive({ storage, reference: ref, targetPath, signal: controller.signal }).catch(error => error);
expect(getObject).not.toHaveBeenCalled();
expect(error).toBe(reason);
await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("cancels an active object body and removes private decryption scratch before rejecting", async () => {
const f = await fixture(), ref = await uploadWorkspaceSnapshot(f.upload);
const ciphertext = await fs.readFile(path.join(root, "objects", ref.objectKey));
const controller = new AbortController();
let produced = 0, closed = false;
const body = new Readable({
highWaterMark: 1,
read() {
if (produced === 2) { controller.abort(new Error("client disconnected")); return; }
this.push(ciphertext.subarray(produced, ++produced));
},
destroy(error, callback) { closed = true; callback(error); },
});
storage.getObject = vi.fn(async (_companyId, _objectKey, options) => {
expect(options?.signal).toBe(controller.signal);
return { stream: body, contentLength: ciphertext.length };
});
const scratchRoot = path.join(root, "private-scratch"); await fs.mkdir(scratchRoot);
const temp = vi.spyOn(os, "tmpdir").mockReturnValue(scratchRoot);
const targetPath = path.join(root, "interrupted.pcws");
try {
await expect(loadWorkspaceSnapshotArchive({ storage, reference: ref, targetPath, signal: controller.signal }))
.rejects.toMatchObject({ name: "AbortError" });
expect(closed).toBe(true); expect(body.destroyed).toBe(true);
expect(produced).toBeGreaterThan(0); expect(produced).toBeLessThan(ciphertext.length);
const atRejection = produced; await new Promise(resolve => setImmediate(resolve)); expect(produced).toBe(atRejection);
expect(await fs.readdir(scratchRoot)).toEqual([]);
await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" });
} finally { temp.mockRestore(); }
});
});