mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-11-10 23:41:03 +00:00
feat(runtime): implement Phases 4 and 5 — stores, runtime, commands
Stores (Phase 4): admin, trust graph, reports, trusted mutes, policy, and overrides. Each owns one slice of state and emits only when effective state changes, so a consumer can treat every emission as a real invalidation. - report storage is keyed target -> reporter -> category, making deduplication structural: relays redeliver constantly and a redelivery must not inflate a count - contribution lists replace by (actor, kind) and ignore out-of-order older documents, so a late delivery cannot resurrect removed entries - mute lists prune against an injected clock - viewer switching clears blocks, mutes, and overrides but keeps operator seeds, which are configuration rather than viewer data Runtime: event routing, chunked report subscriptions (200 per filter, not one per target), viewer lifecycle, evaluation, diagnostics, idempotent teardown. Transport, signer, and storage are injected; in-memory doubles ship for tests. Commands (Phase 5): every mutation checks capability locally before the signer is invoked, so an unauthorized actor cannot produce a signed event at all. Protected actors are refused at the command boundary as well as during reduction. Partial relay acceptance succeeds while keeping failures visible. Also fixes the type build: every package's "types" field pointed at a path the build never produced, so consumers got no types at all. Declarations now emit per package and resolve. 103 runtime tests; 505 total.
This commit is contained in:
1 parent
5e0fffb32e
commit
4d460c12fe
19 files changed
+2909
-19
No files matched your search
@@ -0,0 +1,335 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createPolicyDefinition } from "@nostr-governance/core";
|
||||
import { REPORT_KIND, encodeRoles } from "@nostr-governance/nostr";
|
||||
|
||||
import { ERROR_CODES, createCommands } from "../src/commands.js";
|
||||
import { createGovernanceRuntime } from "../src/runtime.js";
|
||||
import { createMemoryTransport } from "../src/interfaces.js";
|
||||
|
||||
const ROOT = "a1".repeat(32);
|
||||
const MODERATOR = "b2".repeat(32);
|
||||
const CURATOR = "c3".repeat(32);
|
||||
const CREATOR = "d4".repeat(32);
|
||||
const STRANGER = "f6".repeat(32);
|
||||
const EVENT_ID = "1b".repeat(32);
|
||||
const NOW = 1_750_000_000;
|
||||
|
||||
const POLICY = createPolicyDefinition({
|
||||
id: "app",
|
||||
version: "1.0.0",
|
||||
profiles: { feed: { name: "feed" } },
|
||||
});
|
||||
|
||||
/**
|
||||
* A signer for a given pubkey. Signing simply stamps an id, which is enough for
|
||||
* transport assertions and keeps crypto out of the test.
|
||||
* @param {string} pubkey
|
||||
*/
|
||||
function signerFor(pubkey) {
|
||||
return {
|
||||
async getPublicKey() {
|
||||
return pubkey;
|
||||
},
|
||||
async signEvent(template) {
|
||||
return { ...template, id: "ff".repeat(32), pubkey, sig: "00".repeat(64) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.actor]
|
||||
* @param {any} [options.signer]
|
||||
* @param {any} [options.transport]
|
||||
*/
|
||||
function setup({ actor = MODERATOR, signer, transport } = {}) {
|
||||
const memory = transport ?? createMemoryTransport();
|
||||
const runtime = createGovernanceRuntime({
|
||||
applicationId: "test",
|
||||
namespace: "app",
|
||||
transport: memory,
|
||||
signer: signer ?? signerFor(actor),
|
||||
policy: POLICY,
|
||||
now: () => NOW,
|
||||
});
|
||||
|
||||
runtime.admin.setRoles({
|
||||
root: ROOT,
|
||||
actors: {
|
||||
[ROOT]: ["super_admin"],
|
||||
[MODERATOR]: ["moderator"],
|
||||
[CURATOR]: ["curator"],
|
||||
},
|
||||
});
|
||||
|
||||
return { runtime, commands: createCommands(runtime), transport: memory };
|
||||
}
|
||||
|
||||
describe("authority enforcement", () => {
|
||||
it("permits a moderator to deny a user", async () => {
|
||||
const { commands, transport } = setup();
|
||||
const result = await commands.denyUser(CREATOR);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(transport.published).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refuses an actor with no roles", async () => {
|
||||
const { commands, transport } = setup({ actor: STRANGER });
|
||||
const result = await commands.denyUser(CREATOR);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.code).toBe(ERROR_CODES.NOT_AUTHORIZED);
|
||||
expect(transport.published).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses a curator denying an event", async () => {
|
||||
const { commands } = setup({ actor: CURATOR });
|
||||
const result = await commands.denyEvent(EVENT_ID);
|
||||
expect(result.code).toBe(ERROR_CODES.NOT_AUTHORIZED);
|
||||
});
|
||||
|
||||
it("permits a curator denying a user", async () => {
|
||||
const { commands } = setup({ actor: CURATOR });
|
||||
expect((await commands.denyUser(CREATOR)).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("never invokes the signer for an unauthorized command", async () => {
|
||||
const signer = signerFor(STRANGER);
|
||||
const signSpy = vi.spyOn(signer, "signEvent");
|
||||
const { commands } = setup({ signer });
|
||||
|
||||
await commands.denyUser(CREATOR);
|
||||
expect(signSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops accepting commands after a role is revoked", async () => {
|
||||
const { runtime, commands } = setup();
|
||||
expect((await commands.denyUser(CREATOR)).ok).toBe(true);
|
||||
|
||||
runtime.admin.setRoles({ root: ROOT, actors: { [ROOT]: ["super_admin"] } });
|
||||
expect((await commands.denyUser(CREATOR)).code).toBe(ERROR_CODES.NOT_AUTHORIZED);
|
||||
});
|
||||
|
||||
it("refuses role management to a moderator", async () => {
|
||||
const { commands } = setup();
|
||||
expect((await commands.setRoles({ actors: {} })).code).toBe(ERROR_CODES.NOT_AUTHORIZED);
|
||||
});
|
||||
|
||||
it("permits role management to the root", async () => {
|
||||
const { commands } = setup({ actor: ROOT });
|
||||
expect((await commands.setRoles({ actors: { [MODERATOR]: ["moderator"] } })).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses policy management to a moderator", async () => {
|
||||
const { commands } = setup();
|
||||
expect((await commands.setPolicy(POLICY)).code).toBe(ERROR_CODES.NOT_AUTHORIZED);
|
||||
});
|
||||
|
||||
it("refuses community-source management to a curator", async () => {
|
||||
const { commands } = setup({ actor: CURATOR });
|
||||
const result = await commands.setCommunitySources([
|
||||
{ curator: CURATOR, identifier: "list", kind: 30078 },
|
||||
]);
|
||||
expect(result.code).toBe(ERROR_CODES.NOT_AUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("protected targets", () => {
|
||||
it("refuses to deny the root", async () => {
|
||||
const { commands } = setup();
|
||||
const result = await commands.denyUser(ROOT);
|
||||
expect(result.code).toBe(ERROR_CODES.PROTECTED_TARGET);
|
||||
});
|
||||
|
||||
it("does not publish a refused denial", async () => {
|
||||
const { commands, transport } = setup();
|
||||
await commands.denyUser(ROOT);
|
||||
expect(transport.published).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("argument validation", () => {
|
||||
it("rejects an unknown contribution kind", async () => {
|
||||
const { commands } = setup();
|
||||
const result = await commands.contribute(/** @type {any} */ ("nonsense"), [
|
||||
{ type: "user", pubkey: CREATOR },
|
||||
]);
|
||||
expect(result.code).toBe(ERROR_CODES.INVALID_ARGUMENT);
|
||||
});
|
||||
|
||||
it("rejects an empty target list", async () => {
|
||||
const { commands } = setup();
|
||||
expect((await commands.contribute("user-deny", [])).code).toBe(ERROR_CODES.INVALID_ARGUMENT);
|
||||
});
|
||||
|
||||
it("rejects a malformed target", async () => {
|
||||
const { commands } = setup();
|
||||
expect((await commands.denyUser("not-a-pubkey")).code).toBe(ERROR_CODES.INVALID_TARGET);
|
||||
});
|
||||
|
||||
it("rejects a non-object policy", async () => {
|
||||
const { commands } = setup({ actor: ROOT });
|
||||
expect((await commands.setPolicy(/** @type {any} */ (null))).code).toBe(
|
||||
ERROR_CODES.INVALID_ARGUMENT,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signer failures", () => {
|
||||
it("reports a missing signer", async () => {
|
||||
const runtime = createGovernanceRuntime({
|
||||
applicationId: "test",
|
||||
namespace: "app",
|
||||
transport: createMemoryTransport(),
|
||||
policy: POLICY,
|
||||
now: () => NOW,
|
||||
});
|
||||
const commands = createCommands(runtime);
|
||||
const result = await commands.denyUser(CREATOR);
|
||||
expect(result.code).toBe(ERROR_CODES.NO_SIGNER);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishing", () => {
|
||||
it("succeeds on partial relay acceptance and keeps failures visible", async () => {
|
||||
const transport = createMemoryTransport();
|
||||
transport.publish = async (event) => ({
|
||||
ok: true,
|
||||
accepted: ["wss://a"],
|
||||
failed: [{ relay: "wss://b", error: "rejected" }],
|
||||
});
|
||||
const { commands } = setup({ transport });
|
||||
|
||||
const result = await commands.denyUser(CREATOR);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.accepted).toEqual(["wss://a"]);
|
||||
expect(result.failed).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fails when no relay accepts", async () => {
|
||||
const transport = createMemoryTransport();
|
||||
transport.publish = async () => ({
|
||||
ok: false,
|
||||
accepted: [],
|
||||
failed: [{ relay: "wss://b", error: "rejected" }],
|
||||
});
|
||||
const { commands } = setup({ transport });
|
||||
|
||||
const result = await commands.denyUser(CREATOR);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.code).toBe(ERROR_CODES.PUBLISH_FAILED);
|
||||
});
|
||||
|
||||
it("reports a transport that throws", async () => {
|
||||
const transport = createMemoryTransport();
|
||||
transport.publish = async () => {
|
||||
throw new Error("network down");
|
||||
};
|
||||
const { commands } = setup({ transport });
|
||||
|
||||
const result = await commands.denyUser(CREATOR);
|
||||
expect(result.code).toBe(ERROR_CODES.PUBLISH_FAILED);
|
||||
expect(result.event).toBeDefined();
|
||||
});
|
||||
|
||||
it("stamps the injected clock onto signed events", async () => {
|
||||
const { commands, transport } = setup();
|
||||
await commands.denyUser(CREATOR);
|
||||
expect(transport.published[0].created_at).toBe(NOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contribution round-trip", () => {
|
||||
it("produces an event the runtime can ingest back into state", async () => {
|
||||
const { runtime, commands, transport } = setup();
|
||||
await commands.denyUser(CREATOR);
|
||||
|
||||
const fresh = createGovernanceRuntime({
|
||||
applicationId: "test",
|
||||
namespace: "app",
|
||||
transport: createMemoryTransport(),
|
||||
policy: POLICY,
|
||||
now: () => NOW,
|
||||
});
|
||||
fresh.admin.setRoles({
|
||||
root: ROOT,
|
||||
actors: { [ROOT]: ["super_admin"], [MODERATOR]: ["moderator"] },
|
||||
});
|
||||
fresh.ingestEvent(transport.published[0]);
|
||||
|
||||
expect(fresh.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true);
|
||||
});
|
||||
|
||||
it("marks a community source on the published event", async () => {
|
||||
const { commands, transport } = setup({ actor: CURATOR });
|
||||
await commands.denyUser(CREATOR, { source: "list-a" });
|
||||
expect(transport.published[0].tags).toContainEqual(["source", "list-a"]);
|
||||
});
|
||||
|
||||
it("denies an address coordinate", async () => {
|
||||
const { commands, transport } = setup();
|
||||
const result = await commands.denyAddress("30078", CREATOR, "sku-001");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(transport.published[0].tags).toContainEqual(["a", `30078:${CREATOR}:sku-001`]);
|
||||
});
|
||||
|
||||
it("adds trust seeds", async () => {
|
||||
const { commands } = setup();
|
||||
expect((await commands.addTrustSeeds([CREATOR])).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a user when the actor holds the capability", async () => {
|
||||
const { commands } = setup({ actor: ROOT });
|
||||
expect((await commands.allowUser(CREATOR)).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses user-allow to a moderator", async () => {
|
||||
const { commands } = setup();
|
||||
expect((await commands.allowUser(CREATOR)).code).toBe(ERROR_CODES.NOT_AUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reporting", () => {
|
||||
it("needs no capability", async () => {
|
||||
const { commands, transport } = setup({ actor: STRANGER });
|
||||
const result = await commands.report({ type: "event", id: EVENT_ID }, "spam");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(transport.published[0].kind).toBe(REPORT_KIND);
|
||||
});
|
||||
|
||||
it("rejects a malformed target", async () => {
|
||||
const { commands } = setup();
|
||||
const result = await commands.report(/** @type {any} */ ({ type: "event", id: "x" }), "spam");
|
||||
expect(result.code).toBe(ERROR_CODES.INVALID_TARGET);
|
||||
});
|
||||
|
||||
it("rejects an empty category", async () => {
|
||||
const { commands } = setup();
|
||||
const result = await commands.report({ type: "event", id: EVENT_ID }, " ");
|
||||
expect(result.code).toBe(ERROR_CODES.INVALID_ARGUMENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewer overrides", () => {
|
||||
it("sets an override locally without publishing", () => {
|
||||
const { runtime, commands, transport } = setup();
|
||||
const result = commands.setOverride({ type: "user", pubkey: CREATOR }, "allow");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(transport.published).toHaveLength(0);
|
||||
expect(runtime.overrides.toMap().has(`user:${CREATOR}`)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears an override", () => {
|
||||
const { runtime, commands } = setup();
|
||||
commands.setOverride({ type: "user", pubkey: CREATOR }, "allow");
|
||||
commands.clearOverride({ type: "user", pubkey: CREATOR });
|
||||
expect(runtime.overrides.toMap().size).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a malformed target", () => {
|
||||
const { commands } = setup();
|
||||
expect(commands.setOverride(/** @type {any} */ ({ type: "user", pubkey: "x" }), "allow").code).toBe(
|
||||
ERROR_CODES.INVALID_TARGET,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createPolicyDefinition } from "@nostr-governance/core";
|
||||
import {
|
||||
CANONICAL_KIND,
|
||||
MUTE_LIST_KIND,
|
||||
REPORT_KIND,
|
||||
canonicalIdentifier,
|
||||
encodeContribution,
|
||||
encodeRoles,
|
||||
} from "@nostr-governance/nostr";
|
||||
|
||||
import { GovernanceRuntime, chunk, createGovernanceRuntime } from "../src/runtime.js";
|
||||
import { createMemoryTransport, storageKey } from "../src/interfaces.js";
|
||||
|
||||
const ROOT = "a1".repeat(32);
|
||||
const MODERATOR = "b2".repeat(32);
|
||||
const CREATOR = "d4".repeat(32);
|
||||
const VIEWER = "e5".repeat(32);
|
||||
const TRUSTED = "01".repeat(32);
|
||||
const EVENT_ID = "1b".repeat(32);
|
||||
const NOW = 1_750_000_000;
|
||||
|
||||
const POLICY = createPolicyDefinition({
|
||||
id: "app",
|
||||
version: "1.0.0",
|
||||
defaultProfile: "feed",
|
||||
profiles: {
|
||||
feed: {
|
||||
name: "feed",
|
||||
administrativeDeny: { visibility: "hide", interaction: "deny" },
|
||||
viewerBlock: { visibility: "hide", interaction: "deny" },
|
||||
reports: { default: { restrict: 1 } },
|
||||
mutes: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {Partial<import('@nostr-governance/nostr').NostrEvent>} parts
|
||||
* @returns {import('@nostr-governance/nostr').NostrEvent}
|
||||
*/
|
||||
const event = (parts) => ({
|
||||
id: "00".repeat(32),
|
||||
pubkey: MODERATOR,
|
||||
kind: CANONICAL_KIND,
|
||||
created_at: NOW,
|
||||
tags: [],
|
||||
content: "",
|
||||
...parts,
|
||||
});
|
||||
|
||||
const rolesEvent = () =>
|
||||
event({
|
||||
pubkey: ROOT,
|
||||
id: "aa".repeat(32),
|
||||
...encodeRoles({ actors: { [MODERATOR]: ["moderator"] }, protectedActors: [] }, "app"),
|
||||
});
|
||||
|
||||
/** @param {import('@nostr-governance/core').GovernanceTarget} [target] */
|
||||
const denyEvent = (target = { type: "user", pubkey: CREATOR }) =>
|
||||
event({
|
||||
id: "bb".repeat(32),
|
||||
...encodeContribution({ actor: MODERATOR, kind: "user-deny", targets: [target] }, "app"),
|
||||
});
|
||||
|
||||
function makeRuntime(events = []) {
|
||||
const transport = createMemoryTransport(events);
|
||||
const runtime = createGovernanceRuntime({
|
||||
applicationId: "test-app",
|
||||
namespace: "app",
|
||||
transport,
|
||||
policy: POLICY,
|
||||
now: () => NOW,
|
||||
});
|
||||
return { runtime, transport };
|
||||
}
|
||||
|
||||
describe("chunk", () => {
|
||||
it("splits into bounded pieces", () => {
|
||||
expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
|
||||
});
|
||||
|
||||
it("returns one chunk for a non-positive size", () => {
|
||||
expect(chunk([1, 2], 0)).toEqual([[1, 2]]);
|
||||
});
|
||||
|
||||
it("handles an empty list", () => {
|
||||
expect(chunk([], 10)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storageKey", () => {
|
||||
it("namespaces by deployment and schema", () => {
|
||||
expect(
|
||||
storageKey({
|
||||
applicationId: "bitroad",
|
||||
namespace: "bitroad",
|
||||
rootFingerprint: "abcd",
|
||||
schemaVersion: "v1",
|
||||
scope: "admin",
|
||||
}),
|
||||
).toBe("nostr-governance:bitroad:bitroad:abcd:v1:admin");
|
||||
});
|
||||
|
||||
it("appends the viewer for viewer-scoped state", () => {
|
||||
const key = storageKey({
|
||||
applicationId: "a",
|
||||
namespace: "n",
|
||||
rootFingerprint: "f",
|
||||
schemaVersion: "v1",
|
||||
scope: "overrides",
|
||||
viewerPubkey: VIEWER,
|
||||
});
|
||||
expect(key.endsWith(VIEWER)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("construction", () => {
|
||||
it("requires an application id and namespace", () => {
|
||||
expect(() =>
|
||||
new GovernanceRuntime(/** @type {any} */ ({ transport: createMemoryTransport() })),
|
||||
).toThrow(/applicationId and namespace/);
|
||||
});
|
||||
|
||||
it("requires a transport", () => {
|
||||
expect(() =>
|
||||
new GovernanceRuntime(/** @type {any} */ ({ applicationId: "a", namespace: "n" })),
|
||||
).toThrow(/transport adapter/);
|
||||
});
|
||||
|
||||
it("changes its storage key when the root authority changes", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
const before = runtime.storageKeyFor("admin");
|
||||
runtime.admin.setRoles({ root: ROOT, actors: {} });
|
||||
expect(runtime.storageKeyFor("admin")).not.toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ingestEvent", () => {
|
||||
it("routes a roles document", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
expect(runtime.ingestEvent(rolesEvent())).toBe(true);
|
||||
expect(runtime.admin.authority.root).toBe(ROOT);
|
||||
});
|
||||
|
||||
it("routes a contribution", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
runtime.ingestEvent(rolesEvent());
|
||||
expect(runtime.ingestEvent(denyEvent())).toBe(true);
|
||||
expect(runtime.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true);
|
||||
});
|
||||
|
||||
it("routes a report", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
const report = event({
|
||||
kind: REPORT_KIND,
|
||||
pubkey: TRUSTED,
|
||||
tags: [["e", EVENT_ID, "spam"]],
|
||||
});
|
||||
expect(runtime.ingestEvent(report)).toBe(true);
|
||||
expect(runtime.reports.recordsFor(`event:${EVENT_ID}`)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("routes a mute list", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
const list = event({ kind: MUTE_LIST_KIND, pubkey: TRUSTED, tags: [["p", CREATOR]] });
|
||||
expect(runtime.ingestEvent(list)).toBe(true);
|
||||
expect(runtime.mutes.toRecordMap().get(`user:${CREATOR}`)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emits a policy document rather than applying it blindly", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
const handler = vi.fn();
|
||||
runtime.on("policy-document", handler);
|
||||
runtime.ingestEvent(
|
||||
event({
|
||||
tags: [["d", canonicalIdentifier("app", "policy")]],
|
||||
content: JSON.stringify({ id: "x" }),
|
||||
}),
|
||||
);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("counts unrecognized events instead of throwing", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
expect(runtime.ingestEvent(event({ kind: 1 }))).toBe(false);
|
||||
expect(runtime.diagnostics.unknownEvents).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores malformed input", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
expect(runtime.ingestEvent(/** @type {any} */ (null))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAdministrativeState", () => {
|
||||
it("ingests only the newest document per coordinate", async () => {
|
||||
const older = event({
|
||||
id: "11".repeat(32),
|
||||
created_at: NOW - 100,
|
||||
...encodeContribution(
|
||||
{ actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] },
|
||||
"app",
|
||||
),
|
||||
});
|
||||
const newer = event({
|
||||
id: "22".repeat(32),
|
||||
created_at: NOW,
|
||||
...encodeContribution({ actor: MODERATOR, kind: "user-deny", targets: [] }, "app"),
|
||||
});
|
||||
|
||||
const { runtime } = makeRuntime([rolesEvent(), older, newer]);
|
||||
await runtime.loadAdministrativeState();
|
||||
expect(runtime.admin.state.userDeny.size).toBe(0);
|
||||
});
|
||||
|
||||
it("can restrict the query to known authors", async () => {
|
||||
const transport = createMemoryTransport([rolesEvent(), denyEvent()]);
|
||||
const listSpy = vi.spyOn(transport, "list");
|
||||
const runtime = createGovernanceRuntime({
|
||||
applicationId: "a",
|
||||
namespace: "app",
|
||||
transport,
|
||||
policy: POLICY,
|
||||
now: () => NOW,
|
||||
});
|
||||
|
||||
await runtime.loadAdministrativeState({ authors: [MODERATOR] });
|
||||
expect(listSpy.mock.calls[0][0][0]).toMatchObject({ authors: [MODERATOR] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("active targets and subscriptions", () => {
|
||||
it("chunks report subscriptions rather than opening one per target", () => {
|
||||
const transport = createMemoryTransport();
|
||||
const subscribeSpy = vi.spyOn(transport, "subscribe");
|
||||
const runtime = createGovernanceRuntime({
|
||||
applicationId: "a",
|
||||
namespace: "app",
|
||||
transport,
|
||||
policy: POLICY,
|
||||
now: () => NOW,
|
||||
chunkSize: 2,
|
||||
});
|
||||
|
||||
runtime.setActiveTargets([
|
||||
{ type: "event", id: "01".repeat(32) },
|
||||
{ type: "event", id: "02".repeat(32) },
|
||||
{ type: "event", id: "03".repeat(32) },
|
||||
]);
|
||||
runtime.subscribeToActiveTargetReports();
|
||||
|
||||
expect(subscribeSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("separates event and user filters", () => {
|
||||
const transport = createMemoryTransport();
|
||||
const subscribeSpy = vi.spyOn(transport, "subscribe");
|
||||
const runtime = createGovernanceRuntime({
|
||||
applicationId: "a",
|
||||
namespace: "app",
|
||||
transport,
|
||||
policy: POLICY,
|
||||
now: () => NOW,
|
||||
});
|
||||
|
||||
runtime.setActiveTargets([
|
||||
{ type: "event", id: EVENT_ID },
|
||||
{ type: "user", pubkey: CREATOR },
|
||||
]);
|
||||
runtime.subscribeToActiveTargetReports();
|
||||
|
||||
const filters = subscribeSpy.mock.calls.map((call) => call[0][0]);
|
||||
expect(filters.some((filter) => "#e" in filter)).toBe(true);
|
||||
expect(filters.some((filter) => "#p" in filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("ingests reports delivered to a live subscription", () => {
|
||||
const { runtime, transport } = makeRuntime();
|
||||
runtime.setActiveTargets([{ type: "event", id: EVENT_ID }]);
|
||||
runtime.subscribeToActiveTargetReports();
|
||||
|
||||
transport.deliver(
|
||||
event({ kind: REPORT_KIND, pubkey: TRUSTED, tags: [["e", EVENT_ID, "spam"]] }),
|
||||
);
|
||||
expect(runtime.reports.recordsFor(`event:${EVENT_ID}`)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips invalid targets", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
runtime.setActiveTargets([
|
||||
{ type: "user", pubkey: CREATOR },
|
||||
/** @type {any} */ ({ type: "user", pubkey: "bad" }),
|
||||
]);
|
||||
expect(runtime.activeTargets.size).toBe(1);
|
||||
});
|
||||
|
||||
it("closes subscriptions on close", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
runtime.setActiveTargets([{ type: "event", id: EVENT_ID }]);
|
||||
const handle = runtime.subscribeToActiveTargetReports();
|
||||
expect(runtime.subscriptions.size).toBe(1);
|
||||
handle.close();
|
||||
expect(runtime.subscriptions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewer lifecycle", () => {
|
||||
it("clears viewer-specific state on switch", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
runtime.setViewer(VIEWER);
|
||||
runtime.trust.setBlocks([CREATOR]);
|
||||
runtime.overrides.set(`user:${CREATOR}`, { visibility: "allow" });
|
||||
|
||||
runtime.setViewer(TRUSTED);
|
||||
expect(runtime.trust.blocks.size).toBe(0);
|
||||
expect(runtime.overrides.toMap().size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps operator seeds across a viewer switch", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
runtime.trust.setSeeds([TRUSTED]);
|
||||
runtime.setViewer(VIEWER);
|
||||
expect(runtime.trust.seeds.has(TRUSTED)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not churn when the viewer is unchanged", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
runtime.setViewer(VIEWER);
|
||||
const handler = vi.fn();
|
||||
runtime.on("viewer", handler);
|
||||
runtime.setViewer(VIEWER);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluation", () => {
|
||||
it("evaluates against live store state", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
runtime.ingestEvent(rolesEvent());
|
||||
runtime.ingestEvent(denyEvent());
|
||||
|
||||
const decision = runtime.evaluate({ type: "user", pubkey: CREATOR }, { profile: "feed" });
|
||||
expect(decision.visibility.effect).toBe("hide");
|
||||
});
|
||||
|
||||
it("reflects a report ingested afterwards", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
runtime.trust.setContacts([TRUSTED]);
|
||||
runtime.ingestEvent(
|
||||
event({ kind: REPORT_KIND, pubkey: TRUSTED, tags: [["e", EVENT_ID, "spam"]] }),
|
||||
);
|
||||
|
||||
const decision = runtime.evaluate({ type: "event", id: EVENT_ID }, { profile: "feed" });
|
||||
expect(decision.visibility.effect).toBe("restrict");
|
||||
});
|
||||
|
||||
it("applies viewer blocks", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
runtime.trust.setBlocks([CREATOR]);
|
||||
expect(
|
||||
runtime.evaluate({ type: "user", pubkey: CREATOR }, { profile: "feed" }).visibility.effect,
|
||||
).toBe("hide");
|
||||
});
|
||||
|
||||
it("evaluates many targets consistently with one", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
runtime.ingestEvent(rolesEvent());
|
||||
runtime.ingestEvent(denyEvent());
|
||||
|
||||
/** @type {import('@nostr-governance/core').GovernanceTarget} */
|
||||
const target = { type: "user", pubkey: CREATOR };
|
||||
const many = runtime.evaluateMany([target], { profile: "feed" });
|
||||
expect(many.get(`user:${CREATOR}`)).toEqual(runtime.evaluate(target, { profile: "feed" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("diagnostics and teardown", () => {
|
||||
it("describes runtime state", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
runtime.ingestEvent(rolesEvent());
|
||||
const description = runtime.describe();
|
||||
expect(description.applicationId).toBe("test-app");
|
||||
expect(description.policyId).toBe("app");
|
||||
expect(description.eventsIngested).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("closes subscriptions and detaches listeners on destroy", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
runtime.setActiveTargets([{ type: "event", id: EVENT_ID }]);
|
||||
runtime.subscribeToActiveTargetReports();
|
||||
|
||||
const handler = vi.fn();
|
||||
runtime.on("change", handler);
|
||||
runtime.destroy();
|
||||
|
||||
expect(runtime.subscriptions.size).toBe(0);
|
||||
runtime.admin.setRoles({ root: ROOT, actors: {} });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is safe to destroy twice", () => {
|
||||
const { runtime } = makeRuntime();
|
||||
runtime.destroy();
|
||||
expect(() => runtime.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createPolicyDefinition } from "@nostr-governance/core";
|
||||
|
||||
import { Emitter } from "../src/emitter.js";
|
||||
import {
|
||||
GovernanceAdminStore,
|
||||
OverrideStore,
|
||||
PolicyStore,
|
||||
ReportStore,
|
||||
TrustGraphStore,
|
||||
TrustedMuteStore,
|
||||
} from "../src/stores.js";
|
||||
|
||||
const ROOT = "a1".repeat(32);
|
||||
const MODERATOR = "b2".repeat(32);
|
||||
const CREATOR = "d4".repeat(32);
|
||||
const OTHER = "e5".repeat(32);
|
||||
const DAY = 86_400;
|
||||
|
||||
/** @param {string} pubkey @returns {import("@nostr-governance/core").UserTarget} */
|
||||
const user = (pubkey) => ({ type: "user", pubkey });
|
||||
|
||||
const roster = {
|
||||
root: ROOT,
|
||||
actors: { [ROOT]: ["super_admin"], [MODERATOR]: ["moderator"] },
|
||||
};
|
||||
|
||||
describe("Emitter", () => {
|
||||
it("delivers events to subscribers", () => {
|
||||
const emitter = new Emitter();
|
||||
const handler = vi.fn();
|
||||
emitter.on("change", handler);
|
||||
emitter.emit("change", { a: 1 });
|
||||
expect(handler).toHaveBeenCalledWith({ a: 1 });
|
||||
});
|
||||
|
||||
it("unsubscribes", () => {
|
||||
const emitter = new Emitter();
|
||||
const handler = vi.fn();
|
||||
emitter.on("change", handler)();
|
||||
emitter.emit("change");
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("contains a throwing listener", () => {
|
||||
const emitter = new Emitter();
|
||||
const good = vi.fn();
|
||||
emitter.on("change", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
emitter.on("change", good);
|
||||
expect(() => emitter.emit("change")).not.toThrow();
|
||||
expect(good).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores malformed subscriptions", () => {
|
||||
const emitter = new Emitter();
|
||||
expect(() => emitter.on(/** @type {any} */ (null), () => {})()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GovernanceAdminStore", () => {
|
||||
it("reduces contributions through the roster", () => {
|
||||
const store = new GovernanceAdminStore();
|
||||
store.setRoles(roster);
|
||||
store.upsertContribution({ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] });
|
||||
expect(store.state.userDeny.has(`user:${CREATOR}`)).toBe(true);
|
||||
});
|
||||
|
||||
it("emits only when effective state changes", () => {
|
||||
const store = new GovernanceAdminStore();
|
||||
store.setRoles(roster);
|
||||
const handler = vi.fn();
|
||||
store.on("change", handler);
|
||||
|
||||
store.upsertContribution({
|
||||
actor: MODERATOR,
|
||||
kind: "user-deny",
|
||||
targets: [user(CREATOR)],
|
||||
createdAt: 1,
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.upsertContribution({
|
||||
actor: MODERATOR,
|
||||
kind: "user-deny",
|
||||
targets: [user(CREATOR)],
|
||||
createdAt: 2,
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("replaces an actor's list rather than accumulating", () => {
|
||||
const store = new GovernanceAdminStore();
|
||||
store.setRoles(roster);
|
||||
store.upsertContribution({
|
||||
actor: MODERATOR,
|
||||
kind: "user-deny",
|
||||
targets: [user(CREATOR)],
|
||||
createdAt: 1,
|
||||
});
|
||||
store.upsertContribution({
|
||||
actor: MODERATOR,
|
||||
kind: "user-deny",
|
||||
targets: [user(OTHER)],
|
||||
createdAt: 2,
|
||||
});
|
||||
expect(store.state.userDeny.has(`user:${CREATOR}`)).toBe(false);
|
||||
expect(store.state.userDeny.has(`user:${OTHER}`)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores an out-of-order older list", () => {
|
||||
const store = new GovernanceAdminStore();
|
||||
store.setRoles(roster);
|
||||
store.upsertContribution({
|
||||
actor: MODERATOR,
|
||||
kind: "user-deny",
|
||||
targets: [user(OTHER)],
|
||||
createdAt: 5,
|
||||
});
|
||||
store.upsertContribution({
|
||||
actor: MODERATOR,
|
||||
kind: "user-deny",
|
||||
targets: [user(CREATOR)],
|
||||
createdAt: 1,
|
||||
});
|
||||
expect(store.state.userDeny.has(`user:${OTHER}`)).toBe(true);
|
||||
});
|
||||
|
||||
it("drops entries when the contributor's role is revoked", () => {
|
||||
const store = new GovernanceAdminStore();
|
||||
store.setRoles(roster);
|
||||
store.upsertContribution({ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] });
|
||||
expect(store.state.userDeny.size).toBe(1);
|
||||
|
||||
store.setRoles({ root: ROOT, actors: { [ROOT]: ["super_admin"] } });
|
||||
expect(store.state.userDeny.size).toBe(0);
|
||||
});
|
||||
|
||||
it("changes its root fingerprint when the roster changes", () => {
|
||||
const store = new GovernanceAdminStore();
|
||||
store.setRoles(roster);
|
||||
const before = store.rootFingerprint;
|
||||
store.setRoles({ root: OTHER, actors: {} });
|
||||
expect(store.rootFingerprint).not.toBe(before);
|
||||
});
|
||||
|
||||
it("ignores a malformed contribution", () => {
|
||||
const store = new GovernanceAdminStore();
|
||||
store.setRoles(roster);
|
||||
store.upsertContribution(/** @type {any} */ ({}));
|
||||
expect(store.state.userDeny.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TrustGraphStore", () => {
|
||||
it("prefers contacts over seeds", () => {
|
||||
const store = new TrustGraphStore();
|
||||
store.setSeeds([MODERATOR]);
|
||||
expect(store.trustSet.has(MODERATOR)).toBe(true);
|
||||
store.setContacts([CREATOR]);
|
||||
expect(store.trustSet.has(CREATOR)).toBe(true);
|
||||
expect(store.trustSet.has(MODERATOR)).toBe(false);
|
||||
});
|
||||
|
||||
it("emits only on real change", () => {
|
||||
const store = new TrustGraphStore();
|
||||
const handler = vi.fn();
|
||||
store.on("change", handler);
|
||||
store.setContacts([CREATOR]);
|
||||
store.setContacts([CREATOR]);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("normalizes and drops invalid pubkeys", () => {
|
||||
const store = new TrustGraphStore();
|
||||
store.setContacts([CREATOR.toUpperCase(), "junk"]);
|
||||
expect([...store.contacts]).toEqual([CREATOR]);
|
||||
});
|
||||
|
||||
it("clears viewer state but keeps operator seeds", () => {
|
||||
const store = new TrustGraphStore();
|
||||
store.setSeeds([MODERATOR]);
|
||||
store.setContacts([CREATOR]);
|
||||
store.setBlocks([OTHER]);
|
||||
store.clearViewerState();
|
||||
expect(store.contacts.size).toBe(0);
|
||||
expect(store.blocks.size).toBe(0);
|
||||
expect(store.seeds.has(MODERATOR)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ReportStore", () => {
|
||||
const report = (reporter, category, createdAt = 1) => ({
|
||||
reporter,
|
||||
target: user(CREATOR),
|
||||
category,
|
||||
createdAt,
|
||||
});
|
||||
const KEY = `user:${CREATOR}`;
|
||||
|
||||
it("aggregates reports per target", () => {
|
||||
const store = new ReportStore();
|
||||
store.ingest(report(MODERATOR, "spam"), KEY);
|
||||
expect(store.recordsFor(KEY)).toEqual([
|
||||
{ reporter: MODERATOR, category: "spam", createdAt: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("deduplicates a redelivered report", () => {
|
||||
const store = new ReportStore();
|
||||
store.ingest(report(MODERATOR, "spam", 5), KEY);
|
||||
store.ingest(report(MODERATOR, "spam", 5), KEY);
|
||||
expect(store.recordsFor(KEY)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps distinct categories from one reporter", () => {
|
||||
const store = new ReportStore();
|
||||
store.ingest(report(MODERATOR, "spam"), KEY);
|
||||
store.ingest(report(MODERATOR, "nudity"), KEY);
|
||||
expect(store.recordsFor(KEY)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not emit for a duplicate", () => {
|
||||
const store = new ReportStore();
|
||||
const handler = vi.fn();
|
||||
store.on("change", handler);
|
||||
store.ingest(report(MODERATOR, "spam", 5), KEY);
|
||||
store.ingest(report(MODERATOR, "spam", 5), KEY);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores malformed reports", () => {
|
||||
const store = new ReportStore();
|
||||
store.ingest(/** @type {any} */ ({ reporter: "", category: "" }), KEY);
|
||||
expect(store.recordsFor(KEY)).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds a record map for the evaluator", () => {
|
||||
const store = new ReportStore();
|
||||
store.ingest(report(MODERATOR, "spam"), KEY);
|
||||
expect(store.toRecordMap().get(KEY)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clears a target", () => {
|
||||
const store = new ReportStore();
|
||||
store.ingest(report(MODERATOR, "spam"), KEY);
|
||||
store.clearTarget(KEY);
|
||||
expect(store.recordsFor(KEY)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TrustedMuteStore", () => {
|
||||
const list = (owner, updatedAt, entries) => ({
|
||||
owner,
|
||||
updatedAt,
|
||||
entries,
|
||||
hasEncryptedEntries: false,
|
||||
});
|
||||
|
||||
it("stores a mute list", () => {
|
||||
const store = new TrustedMuteStore();
|
||||
store.replaceList(list(MODERATOR, 100, [{ pubkey: CREATOR }]));
|
||||
expect(store.toRecordMap().get(`user:${CREATOR}`)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("replaces an older list from the same owner", () => {
|
||||
const store = new TrustedMuteStore();
|
||||
store.replaceList(list(MODERATOR, 100, [{ pubkey: CREATOR }]));
|
||||
store.replaceList(list(MODERATOR, 200, [{ pubkey: OTHER }]));
|
||||
expect(store.toRecordMap().has(`user:${CREATOR}`)).toBe(false);
|
||||
expect(store.toRecordMap().has(`user:${OTHER}`)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores an out-of-order older list", () => {
|
||||
const store = new TrustedMuteStore();
|
||||
store.replaceList(list(MODERATOR, 200, [{ pubkey: OTHER }]));
|
||||
store.replaceList(list(MODERATOR, 100, [{ pubkey: CREATOR }]));
|
||||
expect(store.toRecordMap().has(`user:${OTHER}`)).toBe(true);
|
||||
});
|
||||
|
||||
it("counts distinct owners for one target", () => {
|
||||
const store = new TrustedMuteStore();
|
||||
store.replaceList(list(MODERATOR, 100, [{ pubkey: CREATOR }]));
|
||||
store.replaceList(list(ROOT, 100, [{ pubkey: CREATOR }]));
|
||||
expect(store.toRecordMap().get(`user:${CREATOR}`)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("prunes lists outside the window using the injected clock", () => {
|
||||
const store = new TrustedMuteStore({ windowSeconds: 60 * DAY, now: () => 100 * DAY });
|
||||
store.replaceList(list(MODERATOR, 10 * DAY, [{ pubkey: CREATOR }]));
|
||||
store.replaceList(list(ROOT, 90 * DAY, [{ pubkey: CREATOR }]));
|
||||
expect(store.prune()).toBe(1);
|
||||
expect(store.toRecordMap().get(`user:${CREATOR}`)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not prune when no window is configured", () => {
|
||||
const store = new TrustedMuteStore();
|
||||
store.replaceList(list(MODERATOR, 1, [{ pubkey: CREATOR }]));
|
||||
expect(store.prune()).toBe(0);
|
||||
});
|
||||
|
||||
it("answers batched author queries", () => {
|
||||
const store = new TrustedMuteStore();
|
||||
store.replaceList(list(MODERATOR, 100, [{ pubkey: CREATOR }]));
|
||||
const counts = store.countsForAuthors([CREATOR, OTHER]);
|
||||
expect(counts.get(CREATOR)).toBe(1);
|
||||
expect(counts.get(OTHER)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PolicyStore", () => {
|
||||
const policy = (id) =>
|
||||
createPolicyDefinition({ id, version: "1.0.0", profiles: { feed: { name: "feed" } } });
|
||||
|
||||
it("falls back to the default policy", () => {
|
||||
const store = new PolicyStore(policy("default"));
|
||||
expect(store.policy.id).toBe("default");
|
||||
});
|
||||
|
||||
it("prefers a root policy over the default", () => {
|
||||
const store = new PolicyStore(policy("default"));
|
||||
store.setRootPolicy(policy("root"));
|
||||
expect(store.policy.id).toBe("root");
|
||||
});
|
||||
|
||||
it("prefers a local policy over a root policy", () => {
|
||||
const store = new PolicyStore(policy("default"));
|
||||
store.setRootPolicy(policy("root"));
|
||||
store.setLocalPolicy(policy("local"));
|
||||
expect(store.policy.id).toBe("local");
|
||||
});
|
||||
|
||||
it("emits when the effective policy changes", () => {
|
||||
const store = new PolicyStore(policy("default"));
|
||||
const handler = vi.fn();
|
||||
store.on("change", handler);
|
||||
store.setRootPolicy(policy("root"));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reverts to the default when the root policy is cleared", () => {
|
||||
const store = new PolicyStore(policy("default"));
|
||||
store.setRootPolicy(policy("root"));
|
||||
store.setRootPolicy(null);
|
||||
expect(store.policy.id).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OverrideStore", () => {
|
||||
it("stores and reads an override", () => {
|
||||
const store = new OverrideStore();
|
||||
store.set("user:x", { visibility: "allow" });
|
||||
expect(store.toMap().get("user:x")).toEqual({ visibility: "allow", reason: undefined });
|
||||
});
|
||||
|
||||
it("filters expired overrides using the injected clock", () => {
|
||||
const store = new OverrideStore({ now: () => 100 });
|
||||
store.set("user:x", { visibility: "allow", expiresAt: 50 });
|
||||
store.set("user:y", { visibility: "allow", expiresAt: 150 });
|
||||
expect(store.toMap().has("user:x")).toBe(false);
|
||||
expect(store.toMap().has("user:y")).toBe(true);
|
||||
});
|
||||
|
||||
it("removes an override", () => {
|
||||
const store = new OverrideStore();
|
||||
store.set("user:x", { visibility: "allow" });
|
||||
store.remove("user:x");
|
||||
expect(store.toMap().size).toBe(0);
|
||||
});
|
||||
|
||||
it("clears overrides without clearing listeners", () => {
|
||||
const store = new OverrideStore();
|
||||
const handler = vi.fn();
|
||||
store.on("change", handler);
|
||||
store.set("user:x", { visibility: "allow" });
|
||||
store.clearOverrides();
|
||||
expect(store.toMap().size).toBe(0);
|
||||
|
||||
store.set("user:y", { visibility: "allow" });
|
||||
expect(handler).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user