mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Download registered artifacts through authenticated attachment transport
This commit is contained in:
1 parent
12130d11b6
commit
cbb6593441
2 files changed
+32
-12
No files matched your search
@@ -1,23 +1,23 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { pollUntil, type RunnerApi } from "./api.js";
|
||||
import { pollUntil, RunnerApi } from "./api.js";
|
||||
import { readRegisteredArtifacts, RegisteredArtifactNotFoundError } from "./registered-artifact.js";
|
||||
import { evaluateMatcher } from "./matchers.js";
|
||||
|
||||
function fixture() {
|
||||
const bytes = Buffer.from("verified\n");
|
||||
const product = { id: "product", issueId: "issue", createdByRunId: "run", type: "artifact", status: "active", title: "proof.txt", metadata: { attachmentId: "attachment" } };
|
||||
const attachment = { id: "attachment", issueId: "issue", originatingRunId: "run", originalFilename: "proof.txt", contentType: "text/plain", byteSize: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") };
|
||||
const download = vi.fn().mockResolvedValue({ ok: () => true, body: async () => bytes });
|
||||
const product = { id: "product", issueId: "issue", createdByRunId: "run", type: "artifact", status: "active", title: "proof.txt", metadata: { attachmentId: "4d6f7d24-bc94-42e5-9c47-26d18f212d8e" } };
|
||||
const attachment = { id: "4d6f7d24-bc94-42e5-9c47-26d18f212d8e", issueId: "issue", originatingRunId: "run", originalFilename: "proof.txt", contentType: "text/plain", byteSize: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") };
|
||||
const download = vi.fn().mockResolvedValue(bytes);
|
||||
const get = vi.fn().mockImplementation(async (path: string) => path.endsWith("work-products") ? [product] : [attachment]);
|
||||
return { product, attachment, download, get, api: { get, request: { get: download } } as unknown as RunnerApi };
|
||||
return { product, attachment, download, get, api: { get, getAttachmentContent: download, request: { get: () => { throw new Error("Unauthenticated transport forbidden"); } } } as unknown as RunnerApi };
|
||||
}
|
||||
|
||||
it.each(["active", "ready_for_review", "approved"])("downloads the exact registered run-attributed %s output through its public path", async status => {
|
||||
const { api, product, download } = fixture();
|
||||
product.status = status;
|
||||
const artifacts = await readRegisteredArtifacts(api, "issue", "run", ["proof.txt"]);
|
||||
expect(download).toHaveBeenCalledWith("/api/attachments/attachment/content?download=1");
|
||||
expect(download).toHaveBeenCalledWith("4d6f7d24-bc94-42e5-9c47-26d18f212d8e");
|
||||
expect((await evaluateMatcher({ kind: "artifact_exact", name: "proof.txt", expected: "verified\n", mimeType: "text/plain" }, { artifacts })).passed).toBe(true);
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ it.each(["unregistered", "duplicate", "old-run", "unattributed", "wrong-issue",
|
||||
if (reason === "excessive-size") attachment.byteSize = 262_145;
|
||||
if (reason === "size-mismatch") attachment.byteSize += 1;
|
||||
if (reason === "hash-mismatch") attachment.sha256 = "incorrect";
|
||||
if (reason === "download-failure") download.mockResolvedValue({ ok: () => false, status: () => 404 });
|
||||
if (reason === "download-failure") download.mockRejectedValue(new Error("Authenticated download returned 404"));
|
||||
await expect(readRegisteredArtifacts(api, "issue", "run", ["proof.txt"])).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ it("distinguishes a checkpoint not yet registered from corrupt or unauthorized e
|
||||
get.mockImplementation(async (path: string) => path.endsWith("work-products") ? [product, { ...product, id: "duplicate" }] : [attachment]);
|
||||
await expect(readRegisteredArtifacts(api, "issue", "run", ["proof.txt"])).rejects.not.toBeInstanceOf(RegisteredArtifactNotFoundError);
|
||||
get.mockImplementation(async (path: string) => path.endsWith("work-products") ? [product] : [attachment]);
|
||||
download.mockResolvedValue({ ok: () => false, status: () => 403 });
|
||||
download.mockRejectedValue(new Error("Authenticated download returned 403"));
|
||||
await expect(readRegisteredArtifacts(api, "issue", "run", ["proof.txt"])).rejects.not.toBeInstanceOf(RegisteredArtifactNotFoundError);
|
||||
get.mockRejectedValue(new Error("read access revoked"));
|
||||
await expect(readRegisteredArtifacts(api, "issue", "run", ["proof.txt"])).rejects.toThrow("read access revoked");
|
||||
@@ -81,7 +81,7 @@ it.each(["duplicate", "corrupt", "revoked"])("strict checkpoint polling retains
|
||||
if (reads === 1 && reason === "revoked") throw new Error("read access revoked");
|
||||
return reads === 1 && reason === "duplicate" ? [product, { ...product, id: "duplicate" }] : [product];
|
||||
});
|
||||
if (reason === "corrupt") download.mockResolvedValueOnce({ ok: () => true, body: async () => Buffer.from("bad bytes") });
|
||||
if (reason === "corrupt") download.mockResolvedValueOnce(Buffer.from("bad bytes"));
|
||||
await expect(pollUntil({ label: "strict checkpoint", deadlineAt: Date.now() + 1000, intervalMs: 1,
|
||||
retryOnError: () => false,
|
||||
load: () => readRegisteredArtifacts(api, "issue", "run", ["proof.txt"]).catch(error => {
|
||||
@@ -90,3 +90,25 @@ it.each(["duplicate", "corrupt", "revoked"])("strict checkpoint polling retains
|
||||
}), accept: rows => rows.length === 1 })).rejects.toThrow();
|
||||
expect(reads).toBe(1);
|
||||
});
|
||||
|
||||
it("uses the actual human-session authenticated transport for metadata and exact attachment bytes", async () => {
|
||||
const { product, attachment } = fixture(), bytes = Buffer.from("verified\n");
|
||||
vi.stubEnv("PAPERCLIP_RUNNER_E2E_PORT", "31991");
|
||||
const raw = vi.fn(() => { throw new Error("Raw request loses human session"); });
|
||||
const fetcher = vi.fn(async (url: URL | string, options: RequestInit) => {
|
||||
expect(options.headers).toMatchObject({ Cookie: "synthetic-owner-session", Origin: "http://127.0.0.1:31991" });
|
||||
const endpoint = new URL(url).pathname;
|
||||
if (endpoint.endsWith("work-products")) return Response.json([product]);
|
||||
if (endpoint.endsWith("attachments")) return Response.json([attachment]);
|
||||
expect(endpoint).toBe(`/api/attachments/${attachment.id}/content`);
|
||||
return new Response(bytes);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
try {
|
||||
const api = new RunnerApi({ get: raw } as unknown as ConstructorParameters<typeof RunnerApi>[0]);
|
||||
api.setBrowserSession("synthetic-owner-session");
|
||||
const [artifact] = await readRegisteredArtifacts(api, "issue", "run", ["proof.txt"]);
|
||||
expect(artifact).toMatchObject({ contentVerified: true, sha256: attachment.sha256, content: "verified\n" });
|
||||
expect(raw).not.toHaveBeenCalled(); expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
} finally { vi.unstubAllGlobals(); vi.unstubAllEnvs(); }
|
||||
});
|
||||
@@ -35,9 +35,7 @@ export async function readRegisteredArtifacts(api: RunnerApi, issueId: string, r
|
||||
if (!Number.isSafeInteger(attachment.byteSize) || attachment.byteSize < 0 || attachment.byteSize > 262_144) {
|
||||
throw new Error(`Registered artifact ${name} has an invalid or excessive size`);
|
||||
}
|
||||
const response = await api.request.get(`/api/attachments/${encodeURIComponent(attachment.id)}/content?download=1`);
|
||||
if (!response.ok()) throw new Error(`Registered artifact ${name} download returned ${response.status()}`);
|
||||
const bytes = await response.body();
|
||||
const bytes = await api.getAttachmentContent(attachment.id);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
if (bytes.length !== attachment.byteSize || sha256 !== attachment.sha256) {
|
||||
throw new Error(`Downloaded registered artifact ${name} disagrees with stored bytes`);
|
||||
|
||||
Reference in new issue
Block a user