chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CAPABILITY_RENAMES,
|
||||
capabilitiesToDeclaredAccess,
|
||||
declaredAccessToCapabilities,
|
||||
isDeprecatedCapability,
|
||||
normalizeCapabilities,
|
||||
normalizeCapability,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("isDeprecatedCapability", () => {
|
||||
it("recognises every key of CAPABILITY_RENAMES as deprecated", () => {
|
||||
for (const legacy of Object.keys(CAPABILITY_RENAMES)) {
|
||||
expect(isDeprecatedCapability(legacy)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not flag any rename target as deprecated (renames must be terminal)", () => {
|
||||
// If a rename target ended up as another deprecated name, normalization
|
||||
// would never settle. Check the closure stops in one step.
|
||||
for (const target of Object.values(CAPABILITY_RENAMES)) {
|
||||
expect(isDeprecatedCapability(target)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not flag prototype property names as deprecated", () => {
|
||||
// Object.hasOwn guard: prototype keys must not slip through.
|
||||
expect(isDeprecatedCapability("toString")).toBe(false);
|
||||
expect(isDeprecatedCapability("constructor")).toBe(false);
|
||||
expect(isDeprecatedCapability("__proto__")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeCapability", () => {
|
||||
it("rewrites every legacy name to its replacement", () => {
|
||||
for (const [legacy, replacement] of Object.entries(CAPABILITY_RENAMES)) {
|
||||
expect(normalizeCapability(legacy)).toBe(replacement);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes current names through unchanged", () => {
|
||||
expect(normalizeCapability("network:request")).toBe("network:request");
|
||||
expect(normalizeCapability("content:read")).toBe("content:read");
|
||||
});
|
||||
|
||||
it("passes unknown strings through unchanged for downstream validators", () => {
|
||||
expect(normalizeCapability("not:a:real:cap")).toBe("not:a:real:cap");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeCapabilities", () => {
|
||||
it("preserves order of first appearance", () => {
|
||||
expect(normalizeCapabilities(["content:read", "network:request", "media:read"])).toEqual([
|
||||
"content:read",
|
||||
"network:request",
|
||||
"media:read",
|
||||
]);
|
||||
});
|
||||
|
||||
it("collapses a legacy name into its canonical equivalent", () => {
|
||||
expect(normalizeCapabilities(["read:content"])).toEqual(["content:read"]);
|
||||
});
|
||||
|
||||
it("deduplicates when a manifest declares both the legacy and canonical forms", () => {
|
||||
// Both `network:fetch` and `network:request` are present -- after
|
||||
// normalization both become `network:request`, and the second occurrence
|
||||
// is dropped.
|
||||
expect(normalizeCapabilities(["network:fetch", "network:request"])).toEqual([
|
||||
"network:request",
|
||||
]);
|
||||
expect(normalizeCapabilities(["network:request", "network:fetch"])).toEqual([
|
||||
"network:request",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles an empty array", () => {
|
||||
expect(normalizeCapabilities([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("declaredAccess facet mapping", () => {
|
||||
it("maps each hook-registration capability to its participation facet", () => {
|
||||
expect(capabilitiesToDeclaredAccess(["hooks.email-transport:register"], [])).toEqual({
|
||||
email: { transport: {} },
|
||||
});
|
||||
expect(capabilitiesToDeclaredAccess(["hooks.email-events:register"], [])).toEqual({
|
||||
email: { events: {} },
|
||||
});
|
||||
expect(capabilitiesToDeclaredAccess(["hooks.page-fragments:register"], [])).toEqual({
|
||||
page: { fragments: {} },
|
||||
});
|
||||
expect(capabilitiesToDeclaredAccess(["users:read"], [])).toEqual({ users: { read: {} } });
|
||||
expect(capabilitiesToDeclaredAccess(["taxonomies:read"], [])).toEqual({
|
||||
taxonomies: { read: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes host-restricted from unrestricted network", () => {
|
||||
expect(capabilitiesToDeclaredAccess(["network:request"], ["api.example.com"])).toEqual({
|
||||
network: { request: { allowedHosts: ["api.example.com"] } },
|
||||
});
|
||||
// An empty constraint object is the lexicon's spelling of "unrestricted".
|
||||
expect(
|
||||
capabilitiesToDeclaredAccess(["network:request:unrestricted", "network:request"], []),
|
||||
).toEqual({ network: { request: {} } });
|
||||
});
|
||||
|
||||
it("never widens an empty allowedHosts (deny-all) to unrestricted", () => {
|
||||
// An empty allowedHosts is the most-restrictive spelling (deny-all); it must
|
||||
// never decode to unrestricted, or the tightest declaration grants the most.
|
||||
expect(capabilitiesToDeclaredAccess(["network:request"], [])).toEqual({
|
||||
network: { request: { allowedHosts: [] } },
|
||||
});
|
||||
const decoded = declaredAccessToCapabilities({ network: { request: { allowedHosts: [] } } });
|
||||
expect(decoded.capabilities).not.toContain("network:request:unrestricted");
|
||||
expect(decoded).toEqual({ capabilities: ["network:request"], allowedHosts: [] });
|
||||
});
|
||||
|
||||
it("carries every facet of an email transport that also calls out and observes events", () => {
|
||||
// declaredAccess must carry all three facets so the consent list matches
|
||||
// the capability set the runtime enforces.
|
||||
const da = capabilitiesToDeclaredAccess(
|
||||
["hooks.email-transport:register", "network:request", "hooks.email-events:register"],
|
||||
["api.cloudflare.com"],
|
||||
);
|
||||
expect(da).toEqual({
|
||||
network: { request: { allowedHosts: ["api.cloudflare.com"] } },
|
||||
email: { transport: {}, events: {} },
|
||||
});
|
||||
expect(new Set(declaredAccessToCapabilities(da).capabilities)).toEqual(
|
||||
new Set(["hooks.email-transport:register", "network:request", "hooks.email-events:register"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("declaredAccess <-> capabilities round-trip (total over the vocabulary)", () => {
|
||||
// The full enumeration of implication-closed, valid capability states.
|
||||
// definePlugin closes write->read and unrestricted->request, and publish
|
||||
// rejects network:request with no hosts, so these are the only states that
|
||||
// can reach a published manifest. Every one must round-trip to identity --
|
||||
// the guard that the two representations are isomorphic, so the consent list
|
||||
// always equals the capability set the runtime enforces.
|
||||
const contentChoices = [[], ["content:read"], ["content:read", "content:write"]];
|
||||
const mediaChoices = [[], ["media:read"], ["media:read", "media:write"]];
|
||||
const networkChoices: { caps: string[]; hosts: string[] }[] = [
|
||||
{ caps: [], hosts: [] },
|
||||
{ caps: ["network:request", "network:request:unrestricted"], hosts: [] },
|
||||
// Host-restricted with an empty allow-list = deny-all. Must round-trip
|
||||
// as restricted, never widening to unrestricted.
|
||||
{ caps: ["network:request"], hosts: [] },
|
||||
{ caps: ["network:request"], hosts: ["api.example.com"] },
|
||||
{ caps: ["network:request"], hosts: ["api.example.com", "*.cdn.example.com"] },
|
||||
];
|
||||
const singletonFacets = [
|
||||
"email:send",
|
||||
"hooks.email-events:register",
|
||||
"hooks.email-transport:register",
|
||||
"hooks.page-fragments:register",
|
||||
"users:read",
|
||||
"taxonomies:read",
|
||||
];
|
||||
|
||||
function* states() {
|
||||
for (const content of contentChoices) {
|
||||
for (const media of mediaChoices) {
|
||||
for (const network of networkChoices) {
|
||||
for (let mask = 0; mask < 1 << singletonFacets.length; mask++) {
|
||||
const extra = singletonFacets.filter((_, i) => mask & (1 << i));
|
||||
yield {
|
||||
capabilities: [...content, ...media, ...network.caps, ...extra],
|
||||
allowedHosts: network.hosts,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("recovers every implication-closed valid state exactly", () => {
|
||||
let count = 0;
|
||||
for (const input of states()) {
|
||||
const back = declaredAccessToCapabilities(
|
||||
capabilitiesToDeclaredAccess(input.capabilities, input.allowedHosts),
|
||||
);
|
||||
expect(new Set(back.capabilities)).toEqual(new Set(input.capabilities));
|
||||
expect(new Set(back.allowedHosts)).toEqual(new Set(input.allowedHosts));
|
||||
count++;
|
||||
}
|
||||
// 3 content x 3 media x 5 network x 2^6 singleton subsets.
|
||||
expect(count).toBe(2880);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
deriveSlugFromId,
|
||||
isPluginSlug,
|
||||
isPluginVersion,
|
||||
PLUGIN_SLUG_MAX_LENGTH,
|
||||
PLUGIN_VERSION_MAX_LENGTH,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("deriveSlugFromId", () => {
|
||||
it("strips a leading @ and replaces / with -", () => {
|
||||
expect(deriveSlugFromId("@emdash-cms/gallery")).toBe("emdash-cms-gallery");
|
||||
});
|
||||
|
||||
it("passes through a slug-shaped id unchanged", () => {
|
||||
expect(deriveSlugFromId("gallery")).toBe("gallery");
|
||||
});
|
||||
|
||||
it("does not normalise case (caller must follow up with isPluginSlug)", () => {
|
||||
// `Gallery` is not a valid slug; this helper only does the mechanical
|
||||
// scoped-name translation. Callers must reject the result if it fails
|
||||
// `isPluginSlug`.
|
||||
expect(deriveSlugFromId("@Acme/Gallery")).toBe("Acme-Gallery");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPluginSlug", () => {
|
||||
it("accepts canonical slugs", () => {
|
||||
expect(isPluginSlug("gallery")).toBe(true);
|
||||
expect(isPluginSlug("emdash-cms-gallery")).toBe(true);
|
||||
expect(isPluginSlug("a")).toBe(true);
|
||||
expect(isPluginSlug("a1_2-3")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty strings", () => {
|
||||
expect(isPluginSlug("")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects slugs that don't start with a lowercase letter", () => {
|
||||
expect(isPluginSlug("1plugin")).toBe(false);
|
||||
expect(isPluginSlug("-plugin")).toBe(false);
|
||||
expect(isPluginSlug("_plugin")).toBe(false);
|
||||
expect(isPluginSlug("Plugin")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects slugs containing forbidden characters", () => {
|
||||
expect(isPluginSlug("Plugin/Foo")).toBe(false);
|
||||
expect(isPluginSlug("foo bar")).toBe(false);
|
||||
expect(isPluginSlug("foo.bar")).toBe(false);
|
||||
expect(isPluginSlug("foo:bar")).toBe(false);
|
||||
expect(isPluginSlug("foo@bar")).toBe(false);
|
||||
expect(isPluginSlug("🦀plugin")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects slugs over the max length", () => {
|
||||
expect(isPluginSlug("a".repeat(PLUGIN_SLUG_MAX_LENGTH))).toBe(true);
|
||||
expect(isPluginSlug("a".repeat(PLUGIN_SLUG_MAX_LENGTH + 1))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPluginVersion", () => {
|
||||
it("accepts canonical semver versions", () => {
|
||||
expect(isPluginVersion("1.0.0")).toBe(true);
|
||||
expect(isPluginVersion("0.0.1-alpha.0")).toBe(true);
|
||||
expect(isPluginVersion("10.20.30")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts canonical pre-release identifiers per semver 2.0", () => {
|
||||
expect(isPluginVersion("1.0.0-rc.1")).toBe(true);
|
||||
expect(isPluginVersion("1.0.0-alpha")).toBe(true);
|
||||
expect(isPluginVersion("1.0.0-alpha.1")).toBe(true);
|
||||
expect(isPluginVersion("1.0.0-0.3.7")).toBe(true);
|
||||
expect(isPluginVersion("1.0.0-x.7.z.92")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects build-metadata suffixes (semver `+` is disallowed)", () => {
|
||||
expect(isPluginVersion("1.0.0+build")).toBe(false);
|
||||
expect(isPluginVersion("1.0.0+build.1")).toBe(false);
|
||||
expect(isPluginVersion("1.0.0-rc.0+build")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects leading-zero numeric components (per semver)", () => {
|
||||
expect(isPluginVersion("01.0.0")).toBe(false);
|
||||
expect(isPluginVersion("1.02.0")).toBe(false);
|
||||
expect(isPluginVersion("1.0.03")).toBe(false);
|
||||
// Pre-release numerics also disallow leading zeros.
|
||||
expect(isPluginVersion("1.0.0-01")).toBe(false);
|
||||
expect(isPluginVersion("1.0.0-rc.01")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed versions the previous loose regex accepted", () => {
|
||||
expect(isPluginVersion(".1.0.0")).toBe(false);
|
||||
expect(isPluginVersion("1.0.0-")).toBe(false);
|
||||
expect(isPluginVersion("-")).toBe(false);
|
||||
expect(isPluginVersion(".")).toBe(false);
|
||||
expect(isPluginVersion("foo")).toBe(false);
|
||||
expect(isPluginVersion("1.0")).toBe(false); // missing patch
|
||||
expect(isPluginVersion("1")).toBe(false);
|
||||
// Note: `1.0.0--rc` is technically valid semver — `-rc` is an
|
||||
// alphanumeric identifier with leading hyphen — even though it looks
|
||||
// suspicious. We accept it.
|
||||
expect(isPluginVersion("1.0.0--rc")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects path-traversal and shell-control characters", () => {
|
||||
expect(isPluginVersion("../etc/passwd")).toBe(false);
|
||||
expect(isPluginVersion("1.0.0; rm -rf /")).toBe(false);
|
||||
expect(isPluginVersion("1.0.0:tag")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects underscore and tilde even though atproto rkeys would accept them", () => {
|
||||
expect(isPluginVersion("1_0_0")).toBe(false);
|
||||
expect(isPluginVersion("1~0~0")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty strings", () => {
|
||||
expect(isPluginVersion("")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects versions over the max length", () => {
|
||||
// Construct exactly-max-length: "1." repeated, capped to keep semver shape.
|
||||
// Use a prerelease tail to inflate length within the format.
|
||||
const shortValid = "1.0.0";
|
||||
expect(isPluginVersion(shortValid)).toBe(true);
|
||||
// Synthesize a max-length valid: "1.0.0-" + identifier of length (max-6).
|
||||
const tail = "a".repeat(PLUGIN_VERSION_MAX_LENGTH - 6);
|
||||
const exactlyMax = `1.0.0-${tail}`;
|
||||
expect(exactlyMax.length).toBe(PLUGIN_VERSION_MAX_LENGTH);
|
||||
expect(isPluginVersion(exactlyMax)).toBe(true);
|
||||
expect(isPluginVersion(`${exactlyMax}b`)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user