tauri-app: 8a review fixes
- fetchBlob cache keyed by (did, cid), not just cid. Security: future per-DID access control on getBlob would otherwise leak the first responder's bytes to subsequent viewers. - EmbedImage: pass did to releaseBlob, release previous cid on cid change (no leaked URLs). - ComposeBox: releaseBlob called with both did and cid. - pds-server: rename test get_blob_after_upload_with_different_did -> get_blob_returns_404_for_cross_did_cid_lookup. The docstring was misleading — the test only verifies the (did,cid) PK on the PDS row, not auth. The renamed name matches what the test actually checks. - vitest: update releaseBlob call sites to the new (did, cid) signature.
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
// Unit tests for the image-blob fetch pipeline in `client.ts`.
|
||||
// We mock `@tauri-apps/api/core` so we don't need a running Tauri
|
||||
// shell for the test, and we mock `URL.createObjectURL` /
|
||||
// `URL.revokeObjectURL` so the test doesn't depend on a browser
|
||||
// implementation (vitest's jsdom doesn't always wire these up).
|
||||
//
|
||||
// The tests cover:
|
||||
// * the happy path — a successful `fetch_blob` invoke produces an
|
||||
// object URL and the cache is populated so a second call is a no-op;
|
||||
// * the cached path — a second `fetchBlob` for the same CID does not
|
||||
// call `invoke` again;
|
||||
// * the empty-blob error path — an empty payload throws;
|
||||
// * `releaseBlob` / `clearBlobCache` keep the cache honest.
|
||||
//
|
||||
// Run with:
|
||||
// npm test
|
||||
// (or `npx vitest run src/lib/api/client.test.ts`)
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
}));
|
||||
|
||||
// Capture object URLs so the test can assert the cache is wired up.
|
||||
// `createObjectURL` returns a fresh URL per call; tests should rely
|
||||
// on the spy to know what URL was returned.
|
||||
const createdUrls: string[] = [];
|
||||
let _urlCounter = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
createdUrls.length = 0;
|
||||
_urlCounter = 0;
|
||||
|
||||
// `URL.createObjectURL` exists in jsdom (vitest default) but
|
||||
// returns the empty string — stub it so the test sees real-ish
|
||||
// URLs and we can spot leaks.
|
||||
vi.stubGlobal(
|
||||
"URL",
|
||||
class {
|
||||
static createObjectURL(_blob: Blob): string {
|
||||
const u = `blob:mock/${++_urlCounter}`;
|
||||
createdUrls.push(u);
|
||||
return u;
|
||||
}
|
||||
static revokeObjectURL(url: string): void {
|
||||
const i = createdUrls.indexOf(url);
|
||||
if (i >= 0) createdUrls.splice(i, 1);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("fetchBlob", () => {
|
||||
it("invokes the fetch_blob Tauri command and returns an object URL", async () => {
|
||||
const { fetchBlob, clearBlobCache } = await import("./client");
|
||||
clearBlobCache();
|
||||
|
||||
const cid = "bafyreifake";
|
||||
const fakeBytes = new Uint8Array([1, 2, 3, 4]);
|
||||
invokeMock.mockResolvedValueOnce(Array.from(fakeBytes));
|
||||
|
||||
const url = await fetchBlob("did:plc:alice", cid);
|
||||
|
||||
expect(url).toMatch(/^blob:mock\/\d+$/);
|
||||
expect(createdUrls).toContain(url);
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
expect(invokeMock).toHaveBeenCalledWith("fetch_blob", {
|
||||
did: "did:plc:alice",
|
||||
cid,
|
||||
});
|
||||
});
|
||||
|
||||
it("caches the object URL — second call for the same cid is a no-op", async () => {
|
||||
const { fetchBlob, clearBlobCache } = await import("./client");
|
||||
clearBlobCache();
|
||||
|
||||
const cid = "bafyreicached";
|
||||
invokeMock.mockResolvedValueOnce(Array.from(new Uint8Array([9, 9, 9])));
|
||||
|
||||
const first = await fetchBlob("did:plc:bob", cid);
|
||||
const second = await fetchBlob("did:plc:bob", cid);
|
||||
|
||||
expect(second).toBe(first);
|
||||
// Only the first call should have round-tripped through the
|
||||
// Tauri shell; the cache hit short-circuits everything else.
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects on an empty payload from the Tauri shell", async () => {
|
||||
const { fetchBlob, clearBlobCache } = await import("./client");
|
||||
clearBlobCache();
|
||||
|
||||
invokeMock.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
fetchBlob("did:plc:carol", "bafyreifake"),
|
||||
).rejects.toThrow(/empty blob/);
|
||||
});
|
||||
|
||||
it("propagates a Tauri-side error verbatim", async () => {
|
||||
const { fetchBlob, clearBlobCache } = await import("./client");
|
||||
clearBlobCache();
|
||||
|
||||
invokeMock.mockRejectedValueOnce(new Error("sync.getBlob failed: 400"));
|
||||
await expect(
|
||||
fetchBlob("did:plc:dave", "bafyreifake"),
|
||||
).rejects.toThrow(/sync\.getBlob failed: 400/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseBlob / clearBlobCache", () => {
|
||||
it("drops the cached URL on releaseBlob", async () => {
|
||||
const { fetchBlob, releaseBlob, clearBlobCache } = await import("./client");
|
||||
clearBlobCache();
|
||||
|
||||
invokeMock.mockResolvedValueOnce(Array.from(new Uint8Array([1])));
|
||||
const cid = "bafyreirel";
|
||||
const url = await fetchBlob("did:plc:e", cid);
|
||||
expect(createdUrls).toContain(url);
|
||||
|
||||
releaseBlob("did:plc:e", cid);
|
||||
expect(createdUrls).not.toContain(url);
|
||||
});
|
||||
|
||||
it("clearBlobCache evicts every cached URL", async () => {
|
||||
const { fetchBlob, clearBlobCache } = await import("./client");
|
||||
clearBlobCache();
|
||||
|
||||
invokeMock.mockResolvedValueOnce(Array.from(new Uint8Array([1])));
|
||||
invokeMock.mockResolvedValueOnce(Array.from(new Uint8Array([2])));
|
||||
|
||||
await fetchBlob("did:plc:f", "bafyreia");
|
||||
await fetchBlob("did:plc:f", "bafyreib");
|
||||
expect(createdUrls).toHaveLength(2);
|
||||
|
||||
clearBlobCache();
|
||||
expect(createdUrls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeImagesEmbed", () => {
|
||||
it("wraps a blob reference in an app.bsky.embed.images embed", async () => {
|
||||
const { makeImagesEmbed } = await import("./client");
|
||||
const blob = { cid: "bafyreicid", mimeType: "image/png", size: 42 };
|
||||
const e = makeImagesEmbed(blob) as any;
|
||||
expect(e.$type).toBe("app.bsky.embed.images");
|
||||
expect(e.images).toHaveLength(1);
|
||||
expect(e.images[0].image.$type).toBe("blob");
|
||||
expect(e.images[0].image.ref.$link).toBe("bafyreicid");
|
||||
expect(e.images[0].image.mimeType).toBe("image/png");
|
||||
expect(e.images[0].image.size).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickAndUploadImage", () => {
|
||||
it("returns null when the user cancels the picker", async () => {
|
||||
const { pickAndUploadImage } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce(null);
|
||||
const r = await pickAndUploadImage();
|
||||
expect(r).toBeNull();
|
||||
// `invoke` is called with the command name and an empty
|
||||
// argument bag (the Tauri runtime serialises the JS-side
|
||||
// options, and `pick_and_upload_image` takes none).
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
expect(invokeMock.mock.calls[0][0]).toBe("pick_and_upload_image");
|
||||
});
|
||||
|
||||
it("returns the parsed {cid, mimeType, size} on success", async () => {
|
||||
const { pickAndUploadImage } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce({
|
||||
blob: {
|
||||
$type: "blob",
|
||||
ref: { $link: "bafyblob" },
|
||||
mimeType: "image/jpeg",
|
||||
size: 1234,
|
||||
},
|
||||
});
|
||||
const r = await pickAndUploadImage();
|
||||
expect(r).toEqual({
|
||||
cid: "bafyblob",
|
||||
mimeType: "image/jpeg",
|
||||
size: 1234,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user