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
785 lines
28 KiB
TypeScript
785 lines
28 KiB
TypeScript
import type { PluginManifest } from "@emdash-cms/plugin-types";
|
|
import { PublishingClient } from "@emdash-cms/registry-client";
|
|
import type { Did } from "@emdash-cms/registry-client";
|
|
import { NSID } from "@emdash-cms/registry-lexicons";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
PublishError,
|
|
publishRelease,
|
|
type ProfileBootstrap,
|
|
type PublishOptions,
|
|
} from "../src/publish/api.js";
|
|
import { MockPds } from "./mock-pds.js";
|
|
|
|
const TEST_DID: Did = "did:plc:test123";
|
|
|
|
function buildManifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
|
|
return {
|
|
id: "test-plugin",
|
|
version: "1.0.0",
|
|
capabilities: [],
|
|
allowedHosts: [],
|
|
storage: {},
|
|
hooks: [],
|
|
routes: [],
|
|
admin: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildPublisher(pds: MockPds): PublishingClient {
|
|
return PublishingClient.fromHandler({
|
|
handler: pds,
|
|
did: pds.did,
|
|
pds: "http://mock.test",
|
|
});
|
|
}
|
|
|
|
const validProfile: ProfileBootstrap = {
|
|
license: "MIT",
|
|
authorName: "Alice",
|
|
securityEmail: "security@example.com",
|
|
};
|
|
|
|
function buildOptions(pds: MockPds, overrides: Partial<PublishOptions> = {}): PublishOptions {
|
|
return {
|
|
publisher: buildPublisher(pds),
|
|
did: pds.did,
|
|
manifest: buildManifest(),
|
|
checksum: "bciqtestchecksum",
|
|
url: "https://example.com/test-plugin-1.0.0.tar.gz",
|
|
profile: validProfile,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("publishRelease", () => {
|
|
describe("first publish for a new slug", () => {
|
|
it("creates the profile record and the release record", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
const result = await publishRelease(buildOptions(pds));
|
|
|
|
expect(result.profileCreated).toBe(true);
|
|
expect(result.releaseOverwritten).toBe(false);
|
|
expect(result.slug).toBe("test-plugin");
|
|
expect(result.profileUri).toBe(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
|
expect(result.releaseUri).toBe(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
|
|
|
// Both records should be in the mock PDS.
|
|
expect(pds.records.size).toBe(2);
|
|
expect(pds.records.has(result.profileUri)).toBe(true);
|
|
expect(pds.records.has(result.releaseUri)).toBe(true);
|
|
});
|
|
|
|
it("commits both records in a single applyWrites batch (atomic)", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(buildOptions(pds));
|
|
|
|
// Atomicity contract: ONE applyWrites call, with both records as
|
|
// writes. Two separate putRecord calls would mean an interrupted
|
|
// publish could leave a profile without a release.
|
|
const applyWrites = pds.callsTo("com.atproto.repo.applyWrites");
|
|
expect(applyWrites).toHaveLength(1);
|
|
const body = applyWrites[0]!.body as { writes: unknown[] };
|
|
expect(body.writes).toHaveLength(2);
|
|
|
|
// putRecord must NOT be used for the publish path.
|
|
expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0);
|
|
});
|
|
|
|
it("populates the profile record from ProfileBootstrap fields", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(
|
|
buildOptions(pds, {
|
|
profile: {
|
|
license: "Apache-2.0",
|
|
authorName: "Acme",
|
|
authorUrl: "https://acme.example.com",
|
|
authorEmail: "hi@acme.example.com",
|
|
securityEmail: "security@acme.example.com",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const profile = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
|
expect(profile).toBeDefined();
|
|
const value = profile!.value as {
|
|
license: string;
|
|
authors: Array<{ name: string; url?: string; email?: string }>;
|
|
security: Array<{ email?: string; url?: string }>;
|
|
slug: string;
|
|
type: string;
|
|
};
|
|
expect(value.license).toBe("Apache-2.0");
|
|
expect(value.authors[0]).toMatchObject({
|
|
name: "Acme",
|
|
url: "https://acme.example.com",
|
|
email: "hi@acme.example.com",
|
|
});
|
|
expect(value.security[0]).toMatchObject({
|
|
email: "security@acme.example.com",
|
|
});
|
|
expect(value.slug).toBe("test-plugin");
|
|
expect(value.type).toBe("emdash-plugin");
|
|
});
|
|
|
|
it("populates the release record with the artifact URL and checksum", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(buildOptions(pds));
|
|
|
|
const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
|
expect(release).toBeDefined();
|
|
const value = release!.value as {
|
|
package: string;
|
|
version: string;
|
|
artifacts: { package: { url: string; checksum: string; contentType?: string } };
|
|
};
|
|
expect(value.package).toBe("test-plugin");
|
|
expect(value.version).toBe("1.0.0");
|
|
expect(value.artifacts.package.url).toBe("https://example.com/test-plugin-1.0.0.tar.gz");
|
|
expect(value.artifacts.package.checksum).toBe("bciqtestchecksum");
|
|
expect(value.artifacts.package.contentType).toBe("application/gzip");
|
|
});
|
|
|
|
it("writes release-level requires into the release record when provided", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(
|
|
buildOptions(pds, {
|
|
requires: { "env:emdash": ">=1.0.0", "env:astro": ">=4.16" },
|
|
}),
|
|
);
|
|
|
|
const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
|
const value = release!.value as { requires?: Record<string, string> };
|
|
expect(value.requires).toEqual({ "env:emdash": ">=1.0.0", "env:astro": ">=4.16" });
|
|
});
|
|
|
|
it("omits requires from the release record when empty or absent", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(buildOptions(pds, { requires: {} }));
|
|
|
|
const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
|
const value = release!.value as Record<string, unknown>;
|
|
expect("requires" in value).toBe(false);
|
|
});
|
|
|
|
it("hard-fails when license is missing, with no records written", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
const opts = buildOptions(pds, {
|
|
profile: { securityEmail: "security@example.com" },
|
|
});
|
|
await expect(publishRelease(opts)).rejects.toMatchObject({
|
|
name: "PublishError",
|
|
code: "PROFILE_BOOTSTRAP_MISSING_FIELD",
|
|
});
|
|
expect(pds.records.size).toBe(0);
|
|
// applyWrites must not have been called -- a partial write would be
|
|
// catastrophic for an atomic-publish promise.
|
|
expect(pds.callsTo("com.atproto.repo.applyWrites")).toHaveLength(0);
|
|
});
|
|
|
|
it("hard-fails when both securityEmail and securityUrl are missing", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
const opts = buildOptions(pds, { profile: { license: "MIT" } });
|
|
await expect(publishRelease(opts)).rejects.toMatchObject({
|
|
name: "PublishError",
|
|
code: "PROFILE_BOOTSTRAP_MISSING_FIELD",
|
|
});
|
|
expect(pds.records.size).toBe(0);
|
|
expect(pds.callsTo("com.atproto.repo.applyWrites")).toHaveLength(0);
|
|
});
|
|
|
|
it("accepts securityUrl as an alternative to securityEmail", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(
|
|
buildOptions(pds, {
|
|
profile: { license: "MIT", securityUrl: "https://example.com/security" },
|
|
}),
|
|
);
|
|
const profile = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
|
const value = profile!.value as { security: Array<{ url?: string }> };
|
|
expect(value.security[0]?.url).toBe("https://example.com/security");
|
|
});
|
|
});
|
|
|
|
describe("subsequent release for an existing slug", () => {
|
|
const wellShapedProfile = {
|
|
$type: NSID.packageProfile,
|
|
id: `at://${TEST_DID}/${NSID.packageProfile}/test-plugin`,
|
|
type: "emdash-plugin",
|
|
license: "GPL-3.0-only",
|
|
authors: [{ name: "Original Author" }],
|
|
security: [{ email: "old-security@example.com" }],
|
|
slug: "test-plugin",
|
|
lastUpdated: "2024-01-01T00:00:00.000Z",
|
|
};
|
|
|
|
it("preserves the existing profile's identity fields and bumps lastUpdated", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
pds.seedRecord(NSID.packageProfile, "test-plugin", wellShapedProfile);
|
|
|
|
const result = await publishRelease(
|
|
buildOptions(pds, {
|
|
manifest: buildManifest({ version: "1.1.0" }),
|
|
url: "https://example.com/test-plugin-1.1.0.tar.gz",
|
|
}),
|
|
);
|
|
|
|
expect(result.profileCreated).toBe(false);
|
|
expect(result.releaseOverwritten).toBe(false);
|
|
expect(result.releaseUri).toBe(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.1.0`);
|
|
|
|
// Identity fields preserved -- the existing profile owns these.
|
|
const profileNow = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
|
const value = profileNow!.value as Record<string, unknown> & { lastUpdated: string };
|
|
expect(value.license).toBe(wellShapedProfile.license);
|
|
expect(value.authors).toEqual(wellShapedProfile.authors);
|
|
expect(value.security).toEqual(wellShapedProfile.security);
|
|
// But lastUpdated has been bumped to a fresher timestamp.
|
|
expect(value.lastUpdated).not.toBe(wellShapedProfile.lastUpdated);
|
|
expect(new Date(value.lastUpdated).getTime()).toBeGreaterThan(
|
|
new Date(wellShapedProfile.lastUpdated).getTime(),
|
|
);
|
|
|
|
// applyWrites batch contains exactly TWO writes: profile update +
|
|
// release create.
|
|
const applyWrites = pds.callsTo("com.atproto.repo.applyWrites");
|
|
expect(applyWrites).toHaveLength(1);
|
|
const body = applyWrites[0]!.body as {
|
|
writes: Array<{ $type: string; collection: string }>;
|
|
};
|
|
expect(body.writes).toHaveLength(2);
|
|
const profileOp = body.writes.find((w) => w.collection === NSID.packageProfile);
|
|
expect(profileOp?.$type).toBe("com.atproto.repo.applyWrites#update");
|
|
});
|
|
|
|
it("does not touch a malformed existing profile (just writes the release)", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
// Existing profile is missing required fields. We refuse to update
|
|
// it (overwriting bad bytes with slightly-different bad bytes is
|
|
// worse than leaving it alone) and only write the release.
|
|
pds.seedRecord(NSID.packageProfile, "test-plugin", { incomplete: true });
|
|
|
|
const result = await publishRelease(buildOptions(pds));
|
|
expect(result.profileCreated).toBe(false);
|
|
|
|
const applyWrites = pds.callsTo("com.atproto.repo.applyWrites");
|
|
const body = applyWrites[0]!.body as {
|
|
writes: Array<{ collection: string }>;
|
|
};
|
|
expect(body.writes).toHaveLength(1);
|
|
expect(body.writes[0]?.collection).toBe(NSID.packageRelease);
|
|
});
|
|
|
|
it("reads existing profile and release in parallel before deciding", async () => {
|
|
// Verifies the API issues both lookups; the actual order is
|
|
// implementation detail (we use Promise.all). Two getRecord calls.
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
pds.seedRecord(NSID.packageProfile, "test-plugin", wellShapedProfile);
|
|
await publishRelease(buildOptions(pds));
|
|
const reads = pds.callsTo("com.atproto.repo.getRecord");
|
|
expect(reads).toHaveLength(2);
|
|
// Reads check both rkeys: slug (profile) and slug:version (release).
|
|
const rkeys = reads
|
|
.map((c) => new URL(c.pathname, "http://mock.test").searchParams.get("rkey"))
|
|
.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""));
|
|
expect(rkeys).toEqual(["test-plugin", "test-plugin:1.0.0"]);
|
|
});
|
|
|
|
it("reports profile fields that were ignored when reusing an existing profile", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
pds.seedRecord(NSID.packageProfile, "test-plugin", {});
|
|
|
|
const result = await publishRelease(
|
|
buildOptions(pds, {
|
|
profile: {
|
|
license: "Apache-2.0",
|
|
authorName: "New Name",
|
|
securityEmail: "new-security@example.com",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.profileCreated).toBe(false);
|
|
expect(result.ignoredProfileFields.toSorted()).toEqual([
|
|
"authorName",
|
|
"license",
|
|
"securityEmail",
|
|
]);
|
|
});
|
|
|
|
it("reports an empty ignoredProfileFields when profile is undefined", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
pds.seedRecord(NSID.packageProfile, "test-plugin", {});
|
|
|
|
const result = await publishRelease(buildOptions(pds, { profile: undefined }));
|
|
expect(result.profileCreated).toBe(false);
|
|
expect(result.ignoredProfileFields).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("re-publishing an existing version", () => {
|
|
it("refuses by default and preserves the original record bytes", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
pds.seedRecord(NSID.packageProfile, "test-plugin", {});
|
|
const original = pds.seedRecord(NSID.packageRelease, "test-plugin:1.0.0", {
|
|
artifacts: { package: { url: "https://old.example.com/old.tar.gz" } },
|
|
});
|
|
|
|
await expect(publishRelease(buildOptions(pds))).rejects.toMatchObject({
|
|
name: "PublishError",
|
|
code: "RELEASE_ALREADY_PUBLISHED",
|
|
});
|
|
|
|
// Original bytes preserved (content-derived CID is identical for
|
|
// identical bytes).
|
|
const releaseNow = pds.records.get(original.uri);
|
|
expect(releaseNow?.cid).toBe(original.cid);
|
|
expect(releaseNow?.value).toEqual(original.value);
|
|
// applyWrites must not have been called -- the refusal is upstream.
|
|
expect(pds.callsTo("com.atproto.repo.applyWrites")).toHaveLength(0);
|
|
});
|
|
|
|
it("includes slug and version in the error detail", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
pds.seedRecord(NSID.packageProfile, "test-plugin", {});
|
|
pds.seedRecord(NSID.packageRelease, "test-plugin:1.0.0", {});
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await publishRelease(buildOptions(pds));
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
expect(caught).toBeInstanceOf(PublishError);
|
|
expect((caught as PublishError).detail).toEqual({
|
|
slug: "test-plugin",
|
|
version: "1.0.0",
|
|
});
|
|
});
|
|
|
|
it("overwrites the release record when allowOverwrite is true", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
pds.seedRecord(NSID.packageProfile, "test-plugin", {});
|
|
const original = pds.seedRecord(NSID.packageRelease, "test-plugin:1.0.0", {
|
|
artifacts: { package: { url: "https://old.example.com/old.tar.gz" } },
|
|
});
|
|
|
|
const result = await publishRelease(
|
|
buildOptions(pds, {
|
|
allowOverwrite: true,
|
|
url: "https://example.com/new.tar.gz",
|
|
}),
|
|
);
|
|
|
|
expect(result.releaseOverwritten).toBe(true);
|
|
|
|
// Compare content directly. Mock CIDs are content-derived, so we
|
|
// don't lean on counter increments.
|
|
const releaseNow = pds.records.get(original.uri);
|
|
expect(releaseNow?.value).not.toEqual(original.value);
|
|
const value = releaseNow!.value as { artifacts: { package: { url: string } } };
|
|
expect(value.artifacts.package.url).toBe("https://example.com/new.tar.gz");
|
|
});
|
|
|
|
it("issues an update operation (not create) when overwriting", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
pds.seedRecord(NSID.packageProfile, "test-plugin", {});
|
|
pds.seedRecord(NSID.packageRelease, "test-plugin:1.0.0", {});
|
|
|
|
await publishRelease(buildOptions(pds, { allowOverwrite: true }));
|
|
|
|
const applyWrites = pds.callsTo("com.atproto.repo.applyWrites");
|
|
const body = applyWrites[0]!.body as {
|
|
writes: Array<{ $type: string; collection: string }>;
|
|
};
|
|
const releaseOp = body.writes.find((w) => w.collection === NSID.packageRelease);
|
|
expect(releaseOp?.$type).toBe("com.atproto.repo.applyWrites#update");
|
|
});
|
|
});
|
|
|
|
describe("synchronous validation runs before any network round-trip", () => {
|
|
it("hard-fails on deprecated capabilities", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
const opts = buildOptions(pds, {
|
|
manifest: buildManifest({
|
|
capabilities: ["network:fetch", "read:content"],
|
|
}),
|
|
});
|
|
|
|
await expect(publishRelease(opts)).rejects.toMatchObject({
|
|
name: "PublishError",
|
|
code: "DEPRECATED_CAPABILITY",
|
|
});
|
|
|
|
expect(pds.calls).toHaveLength(0);
|
|
});
|
|
|
|
it("hard-fails on a slug that doesn't match the lexicon constraint", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
const opts = buildOptions(pds, {
|
|
manifest: buildManifest({ id: "Bad Plugin Name" }),
|
|
});
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await publishRelease(opts);
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
expect(caught).toBeInstanceOf(PublishError);
|
|
expect((caught as PublishError).code).toBe("INVALID_SLUG");
|
|
expect(pds.calls).toHaveLength(0);
|
|
});
|
|
|
|
it("hard-fails on a version with build-metadata suffix", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
const opts = buildOptions(pds, {
|
|
manifest: buildManifest({ version: "1.0.0+build.1" }),
|
|
});
|
|
|
|
await expect(publishRelease(opts)).rejects.toMatchObject({
|
|
name: "PublishError",
|
|
code: "INVALID_VERSION",
|
|
});
|
|
expect(pds.calls).toHaveLength(0);
|
|
});
|
|
|
|
it("hard-fails on a version with path-traversal characters", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
const opts = buildOptions(pds, {
|
|
manifest: buildManifest({ version: "../etc/passwd" }),
|
|
});
|
|
|
|
await expect(publishRelease(opts)).rejects.toMatchObject({
|
|
name: "PublishError",
|
|
code: "INVALID_VERSION",
|
|
});
|
|
expect(pds.calls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("release extension declaredAccess (install-consent contract)", () => {
|
|
function getDeclaredAccess(pds: MockPds): Record<string, unknown> {
|
|
const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
|
const ext = (
|
|
release!.value as {
|
|
extensions: Record<string, { declaredAccess: Record<string, unknown> }>;
|
|
}
|
|
).extensions[NSID.packageReleaseExtension];
|
|
return ext!.declaredAccess;
|
|
}
|
|
|
|
it("carries every facet, including hook registrations, when derived from a legacy bundle", async () => {
|
|
// A bundle with no declaredAccess (a pre-migration tarball) whose hook
|
|
// capabilities must still reach the record so the consent dialog can
|
|
// show them.
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(
|
|
buildOptions(pds, {
|
|
manifest: buildManifest({
|
|
capabilities: [
|
|
"hooks.email-transport:register",
|
|
"network:request",
|
|
"hooks.email-events:register",
|
|
],
|
|
allowedHosts: ["api.cloudflare.com"],
|
|
}),
|
|
}),
|
|
);
|
|
expect(getDeclaredAccess(pds)).toEqual({
|
|
network: { request: { allowedHosts: ["api.cloudflare.com"] } },
|
|
email: { transport: {}, events: {} },
|
|
});
|
|
});
|
|
|
|
it("carries the bundle manifest's declaredAccess verbatim when present", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
const declaredAccess = { content: { read: {} }, email: { transport: {} } };
|
|
await publishRelease(
|
|
buildOptions(pds, {
|
|
manifest: buildManifest({
|
|
capabilities: ["content:read", "hooks.email-transport:register"],
|
|
allowedHosts: [],
|
|
declaredAccess,
|
|
}),
|
|
}),
|
|
);
|
|
expect(getDeclaredAccess(pds)).toEqual(declaredAccess);
|
|
});
|
|
});
|
|
|
|
describe("slug derivation", () => {
|
|
it("strips a leading @ and replaces / with - for scoped npm names", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
const result = await publishRelease(
|
|
buildOptions(pds, { manifest: buildManifest({ id: "@acme/plugin" }) }),
|
|
);
|
|
expect(result.slug).toBe("acme-plugin");
|
|
expect(result.releaseUri).toContain("/acme-plugin:");
|
|
});
|
|
|
|
it("rejects scoped names whose translated slug starts with a non-letter", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
const opts = buildOptions(pds, {
|
|
// `@/plugin` translates to `-plugin`, which doesn't start with a letter.
|
|
manifest: buildManifest({ id: "@/plugin" }),
|
|
});
|
|
await expect(publishRelease(opts)).rejects.toMatchObject({
|
|
name: "PublishError",
|
|
code: "INVALID_SLUG",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("structured profileInput (manifest package block)", () => {
|
|
it("writes name, description, keywords, multi-author and multi-security on first publish", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(
|
|
buildOptions(pds, {
|
|
profile: undefined,
|
|
profileInput: {
|
|
license: "Apache-2.0",
|
|
name: "Acme Forms",
|
|
description: "Contact forms for EmDash.",
|
|
keywords: ["forms", "contact"],
|
|
authors: [
|
|
{ name: "Acme Co.", url: "https://acme.example" },
|
|
{ name: "Jane Doe", email: "jane@acme.example" },
|
|
],
|
|
security: [
|
|
{ email: "security@acme.example" },
|
|
{ url: "https://acme.example/security" },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const profile = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
|
const value = profile!.value as {
|
|
license: string;
|
|
name?: string;
|
|
description?: string;
|
|
keywords?: string[];
|
|
authors: Array<{ name: string; url?: string; email?: string }>;
|
|
security: Array<{ url?: string; email?: string }>;
|
|
};
|
|
expect(value.license).toBe("Apache-2.0");
|
|
expect(value.name).toBe("Acme Forms");
|
|
expect(value.description).toBe("Contact forms for EmDash.");
|
|
expect(value.keywords).toEqual(["forms", "contact"]);
|
|
expect(value.authors).toHaveLength(2);
|
|
expect(value.authors[1]).toMatchObject({ name: "Jane Doe", email: "jane@acme.example" });
|
|
expect(value.security).toHaveLength(2);
|
|
expect(value.security[1]).toMatchObject({ url: "https://acme.example/security" });
|
|
});
|
|
|
|
it("omits name, description and keywords when not provided", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(
|
|
buildOptions(pds, {
|
|
profile: undefined,
|
|
profileInput: {
|
|
license: "MIT",
|
|
authors: [{ name: "Solo" }],
|
|
security: [{ email: "s@example.com" }],
|
|
},
|
|
}),
|
|
);
|
|
const profile = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
|
const value = profile!.value as Record<string, unknown>;
|
|
expect("name" in value).toBe(false);
|
|
expect("description" in value).toBe(false);
|
|
expect("keywords" in value).toBe(false);
|
|
});
|
|
|
|
it("writes resolved sections into the profile record on first publish", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(
|
|
buildOptions(pds, {
|
|
profile: undefined,
|
|
profileInput: {
|
|
license: "MIT",
|
|
authors: [{ name: "Solo" }],
|
|
security: [{ email: "s@example.com" }],
|
|
sections: {
|
|
description: "# About\n\nA great plugin.",
|
|
installation: "Run `pnpm add`.",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const profile = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
|
const value = profile!.value as { sections?: Record<string, string> };
|
|
expect(value.sections).toEqual({
|
|
description: "# About\n\nA great plugin.",
|
|
installation: "Run `pnpm add`.",
|
|
});
|
|
});
|
|
|
|
it("omits sections when none are provided or the map is empty", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(
|
|
buildOptions(pds, {
|
|
profile: undefined,
|
|
profileInput: {
|
|
license: "MIT",
|
|
authors: [{ name: "Solo" }],
|
|
security: [{ email: "s@example.com" }],
|
|
sections: {},
|
|
},
|
|
}),
|
|
);
|
|
const profile = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
|
const value = profile!.value as Record<string, unknown>;
|
|
expect("sections" in value).toBe(false);
|
|
});
|
|
|
|
it("hard-fails when no security contact is provided", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await expect(
|
|
publishRelease(
|
|
buildOptions(pds, {
|
|
profile: undefined,
|
|
profileInput: { license: "MIT", authors: [{ name: "A" }], security: [] },
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ name: "PublishError", code: "PROFILE_BOOTSTRAP_MISSING_FIELD" });
|
|
expect(pds.records.size).toBe(0);
|
|
});
|
|
|
|
it("reports structured field names as ignored on a subsequent publish", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
pds.seedRecord(NSID.packageProfile, "test-plugin", {});
|
|
const result = await publishRelease(
|
|
buildOptions(pds, {
|
|
profile: undefined,
|
|
profileInput: {
|
|
license: "MIT",
|
|
name: "Renamed",
|
|
authors: [{ name: "A" }],
|
|
security: [{ email: "s@example.com" }],
|
|
},
|
|
}),
|
|
);
|
|
expect(result.profileCreated).toBe(false);
|
|
expect(result.ignoredProfileFields.toSorted()).toEqual([
|
|
"authors",
|
|
"license",
|
|
"name",
|
|
"security",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("release repo", () => {
|
|
it("writes the repo URL into the release record when provided", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(
|
|
buildOptions(pds, { repo: "https://github.com/acme/emdash-forms/tree/v1.0.0" }),
|
|
);
|
|
const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
|
const value = release!.value as { repo?: string };
|
|
expect(value.repo).toBe("https://github.com/acme/emdash-forms/tree/v1.0.0");
|
|
});
|
|
|
|
it("omits repo from the release record when not provided", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(buildOptions(pds));
|
|
const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
|
expect("repo" in (release!.value as Record<string, unknown>)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("release artifacts", () => {
|
|
const icon = {
|
|
url: "https://cdn.example.com/test-plugin/1.0.0/icon.png",
|
|
checksum: "bciqiconchecksum",
|
|
contentType: "image/png",
|
|
width: 256,
|
|
height: 256,
|
|
};
|
|
const banner = {
|
|
url: "https://cdn.example.com/test-plugin/1.0.0/banner.png",
|
|
checksum: "bciqbannerchecksum",
|
|
contentType: "image/png",
|
|
width: 1280,
|
|
height: 320,
|
|
};
|
|
|
|
interface ReleaseArtifactsMap {
|
|
package?: { url: string; checksum: string };
|
|
icon?: { url: string; checksum: string; width?: number; height?: number };
|
|
banner?: { url: string; checksum: string; width?: number; height?: number };
|
|
screenshots?: Array<{ url: string; checksum: string; width?: number; height?: number }>;
|
|
}
|
|
|
|
function readArtifacts(pds: MockPds): ReleaseArtifactsMap {
|
|
const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
|
return (release!.value as { artifacts: ReleaseArtifactsMap }).artifacts;
|
|
}
|
|
|
|
it("writes icon and banner artifacts into the release record", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(buildOptions(pds, { artifacts: { icon, banner } }));
|
|
const artifacts = readArtifacts(pds);
|
|
expect(artifacts.icon).toMatchObject({
|
|
url: icon.url,
|
|
checksum: icon.checksum,
|
|
contentType: "image/png",
|
|
width: 256,
|
|
height: 256,
|
|
});
|
|
expect(artifacts.banner).toMatchObject({ url: banner.url, width: 1280, height: 320 });
|
|
});
|
|
|
|
it("writes a single screenshot as a one-element screenshots array", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
const shot = {
|
|
url: "https://cdn.example.com/test-plugin/1.0.0/s1.png",
|
|
checksum: "bciqs1",
|
|
contentType: "image/png",
|
|
width: 800,
|
|
height: 600,
|
|
};
|
|
await publishRelease(buildOptions(pds, { artifacts: { screenshots: [shot] } }));
|
|
const artifacts = readArtifacts(pds);
|
|
expect(artifacts.screenshots).toHaveLength(1);
|
|
expect(artifacts.screenshots?.[0]).toMatchObject({
|
|
url: shot.url,
|
|
width: 800,
|
|
height: 600,
|
|
});
|
|
});
|
|
|
|
it("writes the full screenshot gallery as an ordered array", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
const shots = [0, 1, 2].map((i) => ({
|
|
url: `https://cdn.example.com/test-plugin/1.0.0/s${i}.png`,
|
|
checksum: `bciqs${i}`,
|
|
contentType: "image/png",
|
|
width: 800,
|
|
height: 600,
|
|
}));
|
|
await publishRelease(buildOptions(pds, { artifacts: { screenshots: shots } }));
|
|
const artifacts = readArtifacts(pds);
|
|
expect(artifacts.screenshots?.map((s) => s.url)).toEqual(shots.map((s) => s.url));
|
|
});
|
|
|
|
it("keeps the package artifact when media artifacts are present", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(buildOptions(pds, { artifacts: { icon } }));
|
|
const artifacts = readArtifacts(pds);
|
|
expect(artifacts.package?.url).toBe("https://example.com/test-plugin-1.0.0.tar.gz");
|
|
});
|
|
|
|
it("leaves the artifacts map at just the package when none are supplied", async () => {
|
|
const pds = new MockPds({ did: TEST_DID });
|
|
await publishRelease(buildOptions(pds));
|
|
const artifacts = readArtifacts(pds);
|
|
expect(Object.keys(artifacts)).toEqual(["package"]);
|
|
});
|
|
});
|
|
});
|