b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import { ClientResponseError } from "@atcute/client";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { DiscoveryClient } from "../src/discovery/index.js";
|
|
|
|
/**
|
|
* Builds a fetch stub that records every call and returns canned responses.
|
|
*/
|
|
function buildFetchStub(responses: Record<string, { status: number; body: unknown }>): {
|
|
fetch: typeof fetch;
|
|
calls: Array<{ url: string; init: RequestInit | undefined }>;
|
|
} {
|
|
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
|
|
const fetchStub: typeof fetch = vi.fn(async (input, init) => {
|
|
const url = typeof input === "string" || input instanceof URL ? input.toString() : input.url;
|
|
calls.push({ url, init });
|
|
const path = new URL(url).pathname;
|
|
const match = responses[path];
|
|
if (!match) {
|
|
return new Response(JSON.stringify({ error: "TestNotConfigured" }), {
|
|
status: 500,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify(match.body), {
|
|
status: match.status,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
});
|
|
return { fetch: fetchStub, calls };
|
|
}
|
|
|
|
describe("DiscoveryClient", () => {
|
|
const aggregator = "https://aggregator.test";
|
|
// atcute validates the response envelope; `cid` must be a real DASL CID
|
|
// (`/^baf[ky]rei[a-z2-7]{52}$/`, 59 chars). Content is irrelevant to these
|
|
// tests — only the shape is.
|
|
const CID = `bafyrei${"a".repeat(52)}`;
|
|
|
|
it("hits the searchPackages XRPC endpoint with the right query string", async () => {
|
|
const { fetch, calls } = buildFetchStub({
|
|
"/xrpc/com.emdashcms.experimental.aggregator.searchPackages": {
|
|
status: 200,
|
|
body: { packages: [], cursor: undefined },
|
|
},
|
|
});
|
|
|
|
const client = new DiscoveryClient({ aggregatorUrl: aggregator, fetch });
|
|
const result = await client.searchPackages({ q: "gallery", limit: 5 });
|
|
|
|
expect(result.packages).toEqual([]);
|
|
expect(calls).toHaveLength(1);
|
|
const callUrl = new URL(calls[0]!.url);
|
|
expect(callUrl.pathname).toBe("/xrpc/com.emdashcms.experimental.aggregator.searchPackages");
|
|
expect(callUrl.searchParams.get("q")).toBe("gallery");
|
|
expect(callUrl.searchParams.get("limit")).toBe("5");
|
|
});
|
|
|
|
it("forwards the atproto-accept-labelers header when configured", async () => {
|
|
const { fetch, calls } = buildFetchStub({
|
|
"/xrpc/com.emdashcms.experimental.aggregator.searchPackages": {
|
|
status: 200,
|
|
body: { packages: [] },
|
|
},
|
|
});
|
|
|
|
const client = new DiscoveryClient({
|
|
aggregatorUrl: aggregator,
|
|
acceptLabelers: "did:plc:labeller-a, did:plc:labeller-b",
|
|
fetch,
|
|
});
|
|
await client.searchPackages({ q: "x" });
|
|
|
|
const headers = new Headers(calls[0]!.init?.headers);
|
|
expect(headers.get("atproto-accept-labelers")).toBe("did:plc:labeller-a, did:plc:labeller-b");
|
|
});
|
|
|
|
it("does not set atproto-accept-labelers when the option is omitted", async () => {
|
|
const { fetch, calls } = buildFetchStub({
|
|
"/xrpc/com.emdashcms.experimental.aggregator.searchPackages": {
|
|
status: 200,
|
|
body: { packages: [] },
|
|
},
|
|
});
|
|
|
|
const client = new DiscoveryClient({ aggregatorUrl: aggregator, fetch });
|
|
await client.searchPackages({ q: "x" });
|
|
|
|
const headers = new Headers(calls[0]!.init?.headers);
|
|
expect(headers.get("atproto-accept-labelers")).toBeNull();
|
|
});
|
|
|
|
it("throws ClientResponseError on non-2xx responses with the structured payload", async () => {
|
|
const { fetch } = buildFetchStub({
|
|
"/xrpc/com.emdashcms.experimental.aggregator.getPackage": {
|
|
status: 404,
|
|
body: { error: "PackageNotFound", message: "no such package" },
|
|
},
|
|
});
|
|
|
|
const client = new DiscoveryClient({ aggregatorUrl: aggregator, fetch });
|
|
try {
|
|
await client.getPackage({ did: "did:plc:xyz", slug: "missing" });
|
|
expect.fail("expected ClientResponseError");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(ClientResponseError);
|
|
const e = err as ClientResponseError;
|
|
expect(e.status).toBe(404);
|
|
expect(e.error).toBe("PackageNotFound");
|
|
expect(e.description).toBe("no such package");
|
|
}
|
|
});
|
|
|
|
it("hits each XRPC endpoint at the right path", async () => {
|
|
const { fetch, calls } = buildFetchStub({
|
|
"/xrpc/com.emdashcms.experimental.aggregator.getPackage": {
|
|
status: 200,
|
|
body: {
|
|
uri: "at://did:plc:abc/com.emdashcms.experimental.package.profile/gallery",
|
|
cid: CID,
|
|
did: "did:plc:abc",
|
|
slug: "gallery",
|
|
indexedAt: "2026-04-01T00:00:00Z",
|
|
profile: {},
|
|
},
|
|
},
|
|
"/xrpc/com.emdashcms.experimental.aggregator.resolvePackage": {
|
|
status: 200,
|
|
body: {
|
|
uri: "at://did:plc:abc/com.emdashcms.experimental.package.profile/gallery",
|
|
cid: CID,
|
|
did: "did:plc:abc",
|
|
slug: "gallery",
|
|
indexedAt: "2026-04-01T00:00:00Z",
|
|
profile: {},
|
|
},
|
|
},
|
|
"/xrpc/com.emdashcms.experimental.aggregator.listReleases": {
|
|
status: 200,
|
|
body: { releases: [], cursor: undefined },
|
|
},
|
|
"/xrpc/com.emdashcms.experimental.aggregator.getLatestRelease": {
|
|
status: 200,
|
|
body: {
|
|
uri: "at://did:plc:abc/com.emdashcms.experimental.package.release/gallery:1.0.0",
|
|
cid: CID,
|
|
did: "did:plc:abc",
|
|
package: "gallery",
|
|
version: "1.0.0",
|
|
indexedAt: "2026-04-01T00:00:00Z",
|
|
release: {},
|
|
},
|
|
},
|
|
});
|
|
|
|
const client = new DiscoveryClient({ aggregatorUrl: aggregator, fetch });
|
|
|
|
await client.getPackage({ did: "did:plc:abc", slug: "gallery" });
|
|
await client.resolvePackage({ handle: "alice.example.com", slug: "gallery" });
|
|
await client.listReleases({ did: "did:plc:abc", package: "gallery" });
|
|
await client.getLatestRelease({ did: "did:plc:abc", package: "gallery" });
|
|
|
|
const paths = calls.map((c) => new URL(c.url).pathname);
|
|
expect(paths).toEqual([
|
|
"/xrpc/com.emdashcms.experimental.aggregator.getPackage",
|
|
"/xrpc/com.emdashcms.experimental.aggregator.resolvePackage",
|
|
"/xrpc/com.emdashcms.experimental.aggregator.listReleases",
|
|
"/xrpc/com.emdashcms.experimental.aggregator.getLatestRelease",
|
|
]);
|
|
});
|
|
|
|
describe("record validation at the trust boundary", () => {
|
|
const validProfile = {
|
|
$type: "com.emdashcms.experimental.package.profile",
|
|
id: "at://did:plc:abc/com.emdashcms.experimental.package.profile/gallery",
|
|
type: "emdash-plugin",
|
|
license: "MIT",
|
|
authors: [{ name: "Alice", url: "https://alice.example" }],
|
|
security: [{ email: "security@example.com" }],
|
|
name: "Gallery",
|
|
description: "An image gallery.",
|
|
keywords: ["images"],
|
|
};
|
|
const validRelease = {
|
|
$type: "com.emdashcms.experimental.package.release",
|
|
package: "gallery",
|
|
version: "1.0.0",
|
|
artifacts: {
|
|
package: { url: "https://cdn.example/gallery-1.0.0.tgz", checksum: "bciqtest" },
|
|
},
|
|
};
|
|
|
|
it("returns the typed record for a conforming profile; extra keys pass through (non-stripping)", async () => {
|
|
const { fetch } = buildFetchStub({
|
|
"/xrpc/com.emdashcms.experimental.aggregator.getPackage": {
|
|
status: 200,
|
|
body: {
|
|
uri: "at://did:plc:abc/com.emdashcms.experimental.package.profile/gallery",
|
|
cid: CID,
|
|
did: "did:plc:abc",
|
|
slug: "gallery",
|
|
indexedAt: "2026-04-01T00:00:00Z",
|
|
profile: { ...validProfile, somethingTheAggregatorMadeUp: "ignored" },
|
|
},
|
|
},
|
|
});
|
|
const client = new DiscoveryClient({ aggregatorUrl: aggregator, fetch });
|
|
const result = await client.getPackage({ did: "did:plc:abc", slug: "gallery" });
|
|
|
|
expect(result.profile).not.toBeNull();
|
|
expect(result.profile?.name).toBe("Gallery");
|
|
expect(result.profile?.authors?.[0]?.name).toBe("Alice");
|
|
// Contract: atcute validation is non-stripping (the lexicon objects
|
|
// are open). Unrecognised keys are NOT removed — they pass through
|
|
// inert because consumers only read the typed lexicon fields. This
|
|
// asserts the boundary's actual behaviour so a future change to it
|
|
// is a deliberate, visible decision.
|
|
expect(result.profile).toHaveProperty("somethingTheAggregatorMadeUp");
|
|
});
|
|
|
|
it("returns null for a profile that does not conform to the lexicon", async () => {
|
|
const { fetch } = buildFetchStub({
|
|
"/xrpc/com.emdashcms.experimental.aggregator.getPackage": {
|
|
status: 200,
|
|
body: {
|
|
uri: "at://did:plc:abc/com.emdashcms.experimental.package.profile/x",
|
|
cid: CID,
|
|
did: "did:plc:abc",
|
|
slug: "x",
|
|
indexedAt: "2026-04-01T00:00:00Z",
|
|
// Missing every required field (id/type/license/authors/security).
|
|
profile: { name: "Looks fine but isn't" },
|
|
},
|
|
},
|
|
});
|
|
const client = new DiscoveryClient({ aggregatorUrl: aggregator, fetch });
|
|
const result = await client.getPackage({ did: "did:plc:abc", slug: "x" });
|
|
expect(result.profile).toBeNull();
|
|
});
|
|
|
|
it("returns null for a non-conforming release record (fail closed)", async () => {
|
|
// The envelope is valid and `release` is an object (lexicon
|
|
// `unknown` ≈ open object — a non-object is rejected earlier by
|
|
// envelope validation), but the record itself is missing every
|
|
// required package-release field, so `validateRelease` → null.
|
|
const { fetch } = buildFetchStub({
|
|
"/xrpc/com.emdashcms.experimental.aggregator.getLatestRelease": {
|
|
status: 200,
|
|
body: {
|
|
uri: "at://did:plc:abc/com.emdashcms.experimental.package.release/x:1.0.0",
|
|
cid: CID,
|
|
did: "did:plc:abc",
|
|
package: "x",
|
|
version: "1.0.0",
|
|
indexedAt: "2026-04-01T00:00:00Z",
|
|
release: { not: "a valid release record" },
|
|
},
|
|
},
|
|
});
|
|
const client = new DiscoveryClient({ aggregatorUrl: aggregator, fetch });
|
|
const result = await client.getLatestRelease({ did: "did:plc:abc", package: "x" });
|
|
expect(result.release).toBeNull();
|
|
});
|
|
|
|
it("does NOT sanitise URL schemes — a javascript: author url passes lexicon validation", async () => {
|
|
// Documents the boundary's contract: it validates *structure*, not
|
|
// URL safety (atproto's `uri` format permits any scheme). Consumers
|
|
// rendering these URLs MUST apply their own scheme allow-list.
|
|
const { fetch } = buildFetchStub({
|
|
"/xrpc/com.emdashcms.experimental.aggregator.getPackage": {
|
|
status: 200,
|
|
body: {
|
|
uri: "at://did:plc:abc/com.emdashcms.experimental.package.profile/gallery",
|
|
cid: CID,
|
|
did: "did:plc:abc",
|
|
slug: "gallery",
|
|
indexedAt: "2026-04-01T00:00:00Z",
|
|
profile: {
|
|
...validProfile,
|
|
authors: [{ name: "Mallory", url: "javascript:alert(document.cookie)" }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const client = new DiscoveryClient({ aggregatorUrl: aggregator, fetch });
|
|
const result = await client.getPackage({ did: "did:plc:abc", slug: "gallery" });
|
|
expect(result.profile).not.toBeNull();
|
|
expect(result.profile?.authors?.[0]?.url).toBe("javascript:alert(document.cookie)");
|
|
});
|
|
|
|
it("validates each release in a listReleases page", async () => {
|
|
const { fetch } = buildFetchStub({
|
|
"/xrpc/com.emdashcms.experimental.aggregator.listReleases": {
|
|
status: 200,
|
|
body: {
|
|
releases: [
|
|
{
|
|
uri: "at://did:plc:abc/com.emdashcms.experimental.package.release/gallery:1.0.0",
|
|
cid: CID,
|
|
did: "did:plc:abc",
|
|
package: "gallery",
|
|
version: "1.0.0",
|
|
indexedAt: "2026-04-01T00:00:00Z",
|
|
release: validRelease,
|
|
},
|
|
{
|
|
uri: "at://did:plc:abc/com.emdashcms.experimental.package.release/gallery:0.9.0",
|
|
cid: CID,
|
|
did: "did:plc:abc",
|
|
package: "gallery",
|
|
version: "0.9.0",
|
|
indexedAt: "2026-03-01T00:00:00Z",
|
|
release: { garbage: true },
|
|
},
|
|
],
|
|
cursor: undefined,
|
|
},
|
|
},
|
|
});
|
|
const client = new DiscoveryClient({ aggregatorUrl: aggregator, fetch });
|
|
const result = await client.listReleases({ did: "did:plc:abc", package: "gallery" });
|
|
expect(result.releases[0]?.release?.version).toBe("1.0.0");
|
|
expect(result.releases[1]?.release).toBeNull();
|
|
});
|
|
});
|
|
});
|