diff --git a/docs/permissions.md b/docs/permissions.md index 29f36e2..1090bc7 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -107,6 +107,56 @@ each key is re-resolved against the post-execute state. This means a group *created* or *recreated* in the same apply always gets its grant written with its fresh `dataId`, never a pending placeholder or a stale, dangling id. +## Adopting existing grants — `ct adopt grants ` + +To bring an instance's existing rights under management without hand-transcribing +them, read the live rows and emit a paste-ready config block: + +```bash +ct adopt grants group_type_role 42 # or: group_role, and the hyphenated group-type-role +``` + +It fetches `GET /permissions//`, runs the rows through the +**same** normalization the planner uses (`normalizeActual`), and prints a +`ct.groupRole` / `ct.groupTypeRole` block whose every emitted grant is guaranteed +to be accepted by `ct plan` (the round trip is locked by tests): + +- **Excluded, as reconciliation excludes them:** the system baseline + (`meta.modifiedPid === -1`) and inherited rows. +- **Revoke/deny rows are preserved, not emitted.** The reconciler never deletes a + deny it did not author; if any exist, the block ends with a `NOTE` comment + saying so (authoring denies as config is a separate, unshipped feature). +- **`authId` → `module:right` via the catalog** (reverse lookup). An `authId` + with no catalog entry becomes a `WARNING` comment (regenerate the catalog or add + the right by hand) rather than failing the whole adoption. +- **Scoped rights** carry a group `dataId`. If it matches a group **managed in + your state file**, the scope is emitted as that group's logical key + (`scope: ["kids"]`). If it is unmanaged, you get a clearly-marked placeholder + comment telling you to `ct adopt group ` first — scope keys must be state + keys (see [Scope resolution](#scope-resolution)). A scoped right granted + **globally** in CT (row with no `dataId`) is a `WARNING` comment too — the DSL + deliberately cannot declare a global grant of a scoped right. +- **Not-writable rights become `NOTE` comments.** On `group_type_role`, rights + with `authId >= 10000` (the `churchdb:+…` family) are readable via inheritance + but rejected at plan time (see "Domain rules" below), so they are never + emitted as grants. +- **Only `group_role` / `group_type_role`** are valid; people domains are + refused (the same hard boundary as everywhere else). + +> **Warning — comment-only grants are pending revocations.** Reconciliation is +> set-based: a live grant absent from the pasted declaration lands in +> `toDelete`. So any grant the adopter could only express as a `WARNING`/`NOTE` +> comment is still **live on the instance but missing from your config** — +> applying the block as-is will **revoke** it. The block prints a header saying +> exactly how many such grants exist; resolve every one (adopt the group, +> regenerate the catalog, …) before `ct apply`. `ct plan` is only a no-op once +> no comment-only grants remain. + +Grants are **not** a state-tracked resource, so this prints config **only** — it +never writes the state file (unlike `ct adopt `). Pick a real logical +`key` (the emitted one is a rename-to-taste placeholder), paste into your config, +and `ct plan`. + ## Domain rules (validated, throw on violation) - **`group_type_role` requires `authId < 10000`.** Rights with `authId >= diff --git a/docs/runbook-manual-surface.md b/docs/runbook-manual-surface.md index 586a190..fd1e29d 100644 --- a/docs/runbook-manual-surface.md +++ b/docs/runbook-manual-surface.md @@ -38,7 +38,7 @@ this doc's structure. | Portable/logical references | Config still hardcodes numeric CT ids (`groupTypeId`, `groupStatusId`, `campusId`, permission `domainId`, dynamic-group ruleset `var` values like `q.eq("ctgroup.campusId", 4)`) instead of resolving keys/names per host | [#20](https://github.com/eqrm/ct-cli/issues/20) | Hand-resolve each id per target host (`ct get group-types`, `ct get campuses`, etc.) and hardcode it in config; a config authored against one instance will not plan correctly against another until this lands | | Environments (dev → prod promotion) | Named `(host, token, state file)` profiles and a `--env` flag; today one config + one state file = one host | [#22](https://github.com/eqrm/ct-cli/issues/22) | Point `CT_HOST`/state file manually at each target and re-run; keep dev and prod state files apart yourself, and be careful — nothing stops you from applying a dev-shaped config against prod today | | Permission `domainId` by reference | `ct.groupRole`/`ct.groupTypeRole` require the numeric `domainId` supplied by hand — for `group_role` this is CT's internal (group, role) _pairing_ id, with **no CLI lookup helper** | [#25](https://github.com/eqrm/ct-cli/issues/25) | Find the pairing id via the CT permission editor, or an existing `GET /permissions/group_role` response for a group+role you already have, and hardcode it ([`docs/permissions.md`](permissions.md) "domainId semantics") | -| Grant adoption | No `ct adopt grants ` — existing rights structures on a live instance must be hand-transcribed into `grants: [...]` config blocks | [#25](https://github.com/eqrm/ct-cli/issues/25) | `ct get raw /permissions/group_role/` (or `group_type_role`), read off the non-inherited, non-baseline rows (`isInherited: false`, `meta.modifiedPid !== -1`), and hand-author the equivalent `grants:` array | +| ~~Grant adoption~~ **(shipped)** | ~~existing rights structures must be hand-transcribed~~ — **`ct adopt grants ` ships this** (#25): it reads the live rows, applies the planner's normalization, and prints a paste-ready `ct.groupRole` / `ct.groupTypeRole` block (baseline/inherited excluded, denies noted-and-preserved, scope dataIds mapped back to managed-group keys). See [`docs/permissions.md`](permissions.md) "Adopting existing grants" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run `ct adopt grants group_role ` (or `group_type_role`), review the `WARNING`/`NOTE` comments, paste into config | | Permission catalog lifecycle | `catalog.json` is a one-off HAR-trace snapshot of a single CT version, with no staleness detection | [#25](https://github.com/eqrm/ct-cli/issues/25) | Manual regeneration procedure below (**Permission catalog lifecycle**) | | API re-audit for new CT releases | CT's OpenAPI spec is self-trimming (only shows endpoints your version has), so a new write endpoint (e.g. a group-status write) appears silently between CT upgrades | tracked by this issue ([#26](https://github.com/eqrm/ct-cli/issues/26)) | Procedure below (**Re-audit procedure for new CT releases**) | @@ -116,8 +116,9 @@ in where they'd otherwise be silently skipped: before trusting `ct plan`'s permission diff. 6. **Grants not yet expressed as config** — for any domain object with hand-set rights not covered by a `ct.groupRole`/`ct.groupTypeRole` - declaration, transcribe them into config now (adoption workaround above) - so they don't silently diverge from what `ct plan` believes is desired. + declaration, adopt them now with `ct adopt grants ` + (paste the emitted block into config) so they don't silently diverge from + what `ct plan` believes is desired. 7. **Anything from the "out of tool scope" table** — persons, memberships, calendars, services, resource booking, forms, check-in, wiki, finance, sync, module-level settings, custom fields, i18n — configure per your diff --git a/src/commands/adopt-grants.ts b/src/commands/adopt-grants.ts new file mode 100644 index 0000000..def1246 --- /dev/null +++ b/src/commands/adopt-grants.ts @@ -0,0 +1,63 @@ +import { Command } from "commander"; +import { authedSession } from "../api/session.js"; +import { resolveConfig } from "../config.js"; +import { assertNotPeople } from "../engine/guard.js"; +import { emitAdoptedGrants } from "../permissions/adopt.js"; +import type { DomainType, RawPermission } from "../permissions/grants.js"; +import { loadState, resolveStatePath } from "../state/state.js"; +import { info, warn } from "../ui.js"; + +interface AdoptGrantsOptions { + state?: string; +} + +/** Accept the DSL's `group_role` and the hyphenated CLI-friendly `group-role`; reject anything else. */ +function normalizeDomainType(raw: string): DomainType { + const t = raw.trim().replace(/-/g, "_"); + if (t === "group_role" || t === "group_type_role") return t; + throw new Error( + `Invalid domain type "${raw}" — expected "group_role" or "group_type_role" (people domains are never managed).`, + ); +} + +/** + * `ct adopt grants ` — read the live permission rows for a domain and print + * a paste-ready `ct.groupRole` / `ct.groupTypeRole` config block. Grants are NOT state-tracked, so + * this prints config only; it never writes the state file (contrast `ct adopt `). + */ +export function adoptGrantsCommand(): Command { + return new Command("grants") + .description("Print a paste-ready grants config block from a live domain's permission rows (does not write state)") + .argument("", "group_role | group_type_role") + .argument("", "the domainId of the permission domain object") + .option("-s, --state ", "state file path (or set CT_STATE) — used to resolve scope group ids to keys") + .action(async (rawType: string, rawId: string, opts: AdoptGrantsOptions) => { + const domainType = normalizeDomainType(rawType); + if (!/^\d+$/.test(rawId.trim())) { + throw new Error(`Invalid domainId "${rawId}" — expected a non-negative integer.`); + } + const domainId = Number.parseInt(rawId, 10); + const path = `/permissions/${domainType}/${domainId}`; + assertNotPeople(path); // belt-and-suspenders: the domain-type guard already excludes people + + // Load + validate the state file (host guard) BEFORE any network call, mirroring `ct adopt`, + // so a state file recorded against another instance never triggers a request to the wrong host. + const config = await resolveConfig(); + const statePath = resolveStatePath(opts.state); + const state = await loadState(statePath, config.host); + + const { client } = await authedSession(); + const rows = await client.get(path); + + const block = emitAdoptedGrants({ domainType, domainId, rows, state }); + + info(`Grants are not state-tracked — this prints config only and does NOT write ${statePath}.`); + info(`Paste the block below into your config, then run \`ct plan\`:`); + process.stdout.write(`${block}\n`); + warn( + "Any grant left as a WARNING/NOTE comment in the block is still LIVE on the instance but absent " + + "from the declaration — applying the block will REVOKE it. Resolve every comment first; `ct plan` " + + "is only a no-op once none remain.", + ); + }); +} diff --git a/src/commands/adopt.ts b/src/commands/adopt.ts index 21dfc47..5c30502 100644 --- a/src/commands/adopt.ts +++ b/src/commands/adopt.ts @@ -4,6 +4,7 @@ import { resolveConfig } from "../config.js"; import { resourceType, configSnippet } from "../resources/registry.js"; import { loadState, saveState, resolveStatePath, upsert } from "../state/state.js"; import { success, info, warn, out } from "../ui.js"; +import { adoptGrantsCommand } from "./adopt-grants.js"; interface AdoptOptions { key?: string; @@ -12,7 +13,7 @@ interface AdoptOptions { } export function adoptCommand(): Command { - return new Command("adopt") + const cmd = new Command("adopt") .description("Put one existing ChurchTools resource under management (adds it to the state file)") .argument("", "resource type, e.g. campus | group | group-type") .argument("", "ChurchTools id of the resource") @@ -59,4 +60,10 @@ export function adoptCommand(): Command { warn("This resource was already managed — its snapshot was refreshed."); } }); + + // `ct adopt grants ` — grants are not state-tracked, so this subcommand + // prints a config block only and never writes state. Commander matches the "grants" subcommand + // name before falling through to the ` ` action above. + cmd.addCommand(adoptGrantsCommand()); + return cmd; } diff --git a/src/permissions/adopt.ts b/src/permissions/adopt.ts new file mode 100644 index 0000000..dfd77c3 --- /dev/null +++ b/src/permissions/adopt.ts @@ -0,0 +1,251 @@ +/** + * Grant adoption: read a live domain's permission rows and emit a paste-ready + * `ct.groupRole({...})` / `ct.groupTypeRole({...})` config block, so an existing + * instance's rights structure comes under management without hand-transcription + * (issue #25). + * + * The live rows are run through the SAME normalization the planner uses + * (`normalizeActual`) so what is emitted is exactly what a subsequent `ct plan` + * would consider managed: the system baseline (`modifiedPid === -1`) and + * inherited rows are dropped, and pre-existing revoke/deny rows are surfaced as + * a note rather than emitted (the reconciler preserves them; re-authoring them + * is out of scope here). + * + * Grants are NOT state-tracked resources, so adoption prints config only — it + * never writes the state file. The caller makes that explicit in its output. + */ +import type { State } from "../state/state.js"; +import { findByTypeId } from "../state/state.js"; +import { CATALOG } from "./catalog.js"; +import { normalizeActual, type DomainType, type GrantTuple, type RawPermission } from "./grants.js"; + +/** DSL function name for each domain type — the call the emitted block should be pasted as. */ +const DSL_FN: Record = { + group_role: "ct.groupRole", + group_type_role: "ct.groupTypeRole", +}; + +interface ReverseEntry { + name: string; + scoped: boolean; +} + +/** + * authId → `module:right` reverse map, built once from the static catalog. The catalog is keyed by + * name; adoption needs the inverse. If two names share an authId (shouldn't happen), the first wins + * — deterministic because `Object.entries` preserves insertion order. + */ +function reverseCatalog(): Map { + const rev = new Map(); + for (const [name, entry] of Object.entries(CATALOG)) { + if (!rev.has(entry.authId)) { + rev.set(entry.authId, { name, scoped: entry.scopeField != null }); + } + } + return rev; +} + +/** A grant collapsed from the per-dataId rows CT returns: one entry per distinct authId. */ +interface CollapsedGrant { + authId: number; + /** Distinct scope dataIds (group ids). Empty ⇒ an unscoped grant. */ + dataIds: number[]; + /** True when at least one row for this authId carried no dataId (an unscoped grant). */ + hasUnscoped: boolean; +} + +/** + * Collapse normalized grant tuples (one per dataId — CT reads scoped grants back one row per + * dataId) into one {@link CollapsedGrant} per authId, preserving first-seen order and deduping + * dataIds. + */ +function collapse(tuples: GrantTuple[]): CollapsedGrant[] { + const byAuth = new Map(); + for (const t of tuples) { + let g = byAuth.get(t.authId); + if (!g) { + g = { authId: t.authId, dataIds: [], hasUnscoped: false }; + byAuth.set(t.authId, g); + } + if (t.dataId.length === 0) { + g.hasUnscoped = true; + } else { + for (const id of t.dataId) if (!g.dataIds.includes(id)) g.dataIds.push(id); + } + } + return [...byAuth.values()]; +} + +/** + * Build the paste-ready config block for a domain's adopted grants. Pure: takes the raw rows and + * the state, returns the block text (comments and all). The command wrapper handles the fetch and + * prints the result — this stays fully unit-testable without a network. + */ +export function emitAdoptedGrants(args: { + domainType: DomainType; + domainId: number; + rows: RawPermission[]; + state: State; +}): string { + const { domainType, domainId, rows, state } = args; + const normalized = normalizeActual(rows); + const grants = normalized.filter((t) => t.type === "grant"); + const revokes = normalized.filter((t) => t.type !== "grant"); + const rev = reverseCatalog(); + + const body: string[] = []; + let omitted = 0; + body.push(`${DSL_FN[domainType]}({`); + body.push(` key: "${domainType}_${domainId}", // a logical key, unique across the config — rename to taste`); + body.push(` id: ${domainId},`); + + if (grants.length === 0) { + body.push(" grants: [], // no user-authored grants on this domain (baseline/inherited rows excluded)"); + } else { + body.push(" grants: ["); + for (const g of collapse(grants)) { + const r = grantLines(g, rev, state, domainType); + body.push(...r.lines); + if (r.omitted) omitted += 1; + } + body.push(" ],"); + } + + body.push("});"); + + const lines: string[] = []; + if (omitted > 0) { + // Reconciliation is set-based: a live grant absent from the declaration lands in `toDelete`. + // So every grant left below as a comment WILL BE REVOKED by the next apply of this block — + // this must be impossible to miss, hence the header. + lines.push(`// WARNING: ${omitted} live grant(s) could not be expressed as config and are left as comments below.`); + lines.push("// They are still ACTIVE on the instance — applying this block as-is will REVOKE them, because"); + lines.push("// reconciliation deletes any live grant missing from the declaration. Resolve every WARNING/NOTE"); + lines.push("// comment (adopt the group, regenerate the catalog, …) before running `ct apply`."); + } + lines.push(...body); + + if (revokes.length > 0) { + lines.push( + `// NOTE: ${revokes.length} revoke/deny row(s) exist on this domain. The reconciler PRESERVES them (it never`, + ); + lines.push( + "// deletes a deny it did not author), so they are intentionally not emitted above. Re-authoring denies as", + ); + lines.push("// config is not supported yet (see issue #25 stretch goal)."); + } + + return lines.join("\n"); +} + +/** The lines emitted for one collapsed grant, plus whether a LIVE grant was left as a comment + * (⇒ reconciliation would revoke it — counted into the block-level header warning). */ +interface GrantLinesResult { + lines: string[]; + omitted: boolean; +} + +/** + * Emit the grant line(s) for one collapsed grant, resolving scope dataIds back to state keys. + * + * Round-trip invariant (locked by tests): every ACTIVE (non-comment) line this emits must pass + * `desiredTuples` (`src/permissions/plan.ts`) without throwing — anything the planner would + * reject (group_type_role authIds >= 10000, a scoped right without a declarable scope, an + * unscoped right carrying dataIds) is emitted as a comment instead. + */ +function grantLines( + g: CollapsedGrant, + rev: Map, + state: State, + domainType: DomainType, +): GrantLinesResult { + const entry = rev.get(g.authId); + if (!entry) { + // Unknown authId → no name to emit, and a numeric right is not declarable in the DSL. Surface + // it as a clearly-marked comment rather than emitting invalid config or failing the adoption. + return { + omitted: true, + lines: [ + ` // WARNING: authId ${g.authId} has no catalog entry — cannot map to a "module:right" name.`, + " // Regenerate the catalog (see docs) or add this right by hand.", + ], + }; + } + + // Mirror the planner's writability guard: `desiredTuples` throws for group_type_role grants + // with authId >= 10000 (the `churchdb:+…` family) — they are readable here via inheritance but + // not writable on this domain type, so emitting them would break the paste-and-plan round trip. + if (domainType === "group_type_role" && g.authId >= 10000) { + return { + omitted: true, + lines: [ + ` // NOTE: "${entry.name}" (authId ${g.authId}) is not writable on group_type_role — this right`, + " // reaches roles via inheritance only (authId >= 10000). Declaring it here would be", + " // rejected at plan time, so it is intentionally left as a comment.", + ], + }; + } + + if (entry.scoped) { + // Scoped right → resolve each dataId back to a MANAGED group's logical key. Scope keys must be + // state keys (see src/permissions/scope.ts), so an unmanaged dataId cannot be emitted as a key. + // + // NOTE on the lookup: `findByTypeId(state, "group", id)` assumes every scoped dataId is a GROUP + // id, but only `cdb_gruppe`-scoped rights actually scope by group — other scopeFields + // (cdb_station, cc_securitylevel, …) carry ids from different namespaces that could collide + // with a managed group's id. This mirrors the tool-wide constraint that scope declarations + // only support managed groups today (src/permissions/scope.ts); revisit if non-group scopes + // ever become declarable. + const resolvedKeys: string[] = []; + const unmanaged: number[] = []; + for (const id of g.dataIds) { + const group = findByTypeId(state, "group", id); + if (group) resolvedKeys.push(group.key); + else unmanaged.push(id); + } + + const out: string[] = []; + let omitted = false; + if (g.hasUnscoped) { + // A scoped right granted with dataId null = granted GLOBALLY in CT. The DSL cannot declare + // that (a bare string for a scoped right is rejected at plan time precisely to prevent + // accidental global grants), so it can only be surfaced as a comment. + out.push(` // WARNING: "${entry.name}" is granted GLOBALLY here (scoped right, no dataId). The config`); + out.push(" // DSL cannot declare a global grant of a scoped right; re-grant it with an explicit"); + out.push(" // scope in CT, or leave this domain unmanaged."); + omitted = true; + } + for (const id of unmanaged) { + out.push( + ` // WARNING: scope target group #${id} is not managed — run \`ct adopt group ${id}\` (or declare it),`, + ); + out.push(` // then add its logical key to the scope array below.`); + omitted = true; + } + if (resolvedKeys.length > 0) { + const scope = resolvedKeys.map((k) => JSON.stringify(k)).join(", "); + out.push(` { right: ${JSON.stringify(entry.name)}, scope: [${scope}] },`); + } else if (unmanaged.length > 0) { + // Every scope target is unmanaged: there is no valid key to emit, so the grant itself is a + // commented placeholder the user completes after adopting the group(s) above. + out.push(` // { right: ${JSON.stringify(entry.name)}, scope: [/* adopt the group(s) above first */] },`); + } + return { lines: out, omitted }; + } + + // Unscoped right. dataId rows on it contradict the catalog (which says it takes no scope) — + // likely a stale catalog; emitting a scope for it would be rejected at plan time, so comment. + const out: string[] = []; + let omitted = false; + if (g.dataIds.length > 0) { + out.push( + ` // WARNING: "${entry.name}" is unscoped per the catalog, but CT returned it with dataId(s)`, + ); + out.push(` // ${g.dataIds.join(", ")} — the catalog may be stale. Regenerate it, then re-adopt.`); + omitted = true; + } + if (g.hasUnscoped) { + out.push(` ${JSON.stringify(entry.name)},`); + } + return { lines: out, omitted }; +} diff --git a/tests/adopt-grants-command.test.ts b/tests/adopt-grants-command.test.ts new file mode 100644 index 0000000..7bea655 --- /dev/null +++ b/tests/adopt-grants-command.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { RawPermission } from "../src/permissions/grants.js"; + +const rows: RawPermission[] = [ + { authId: 1, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, +]; +const getMock = vi.fn(async (): Promise => rows); + +vi.mock("../src/api/session.js", () => ({ + authedSession: vi.fn(async () => ({ client: { get: getMock }, me: { id: 1 } })), +})); + +const { adoptCommand } = await import("../src/commands/adopt.js"); + +const HOST = "https://eqrm.church.tools"; +const originalHost = process.env.CT_HOST; + +async function run(args: string[]): Promise { + await adoptCommand().parseAsync(args, { from: "user" }); +} + +beforeEach(() => { + getMock.mockClear(); + process.env.CT_HOST = HOST; +}); + +afterEach(() => { + if (originalHost === undefined) delete process.env.CT_HOST; + else process.env.CT_HOST = originalHost; +}); + +describe("ct adopt grants", () => { + it("fetches the domain's rows and prints a config block to stdout", async () => { + const writes: string[] = []; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((s) => { + writes.push(String(s)); + return true; + }); + try { + await run(["grants", "group_type_role", "42", "--state", "/does/not/exist.json"]); + } finally { + spy.mockRestore(); + } + expect(getMock).toHaveBeenCalledWith("/permissions/group_type_role/42"); + expect(writes.join("")).toContain("ct.groupTypeRole({"); + }); + + it("accepts the hyphenated domain type spelling", async () => { + const spy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + try { + await run(["grants", "group-role", "7", "--state", "/does/not/exist.json"]); + } finally { + spy.mockRestore(); + } + expect(getMock).toHaveBeenCalledWith("/permissions/group_role/7"); + }); + + it("rejects an invalid domain type before any API call", async () => { + await expect(run(["grants", "persons", "1"])).rejects.toThrow(/Invalid domain type/); + expect(getMock).not.toHaveBeenCalled(); + }); + + it("rejects a non-integer domainId before any API call", async () => { + await expect(run(["grants", "group_role", "3abc"])).rejects.toThrow(/expected a non-negative integer/); + expect(getMock).not.toHaveBeenCalled(); + }); + + it("still runs the plain `adopt ` action (subcommand does not shadow it)", async () => { + // "campus" is not the "grants" subcommand → the base action runs and hits the resource path. + getMock.mockResolvedValueOnce({ id: 0, name: "Mainz", shorty: "MZ" } as never); + await run(["campus", "0", "--dry-run"]); + expect(getMock).toHaveBeenCalledWith("/campuses/0"); + }); +}); diff --git a/tests/permission-adopt.test.ts b/tests/permission-adopt.test.ts new file mode 100644 index 0000000..c542ac5 --- /dev/null +++ b/tests/permission-adopt.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect } from "vitest"; +import { emitAdoptedGrants } from "../src/permissions/adopt.js"; +import type { DomainType, RawPermission } from "../src/permissions/grants.js"; +import { desiredTuples } from "../src/permissions/plan.js"; +import type { Grant } from "../src/permissions/types.js"; +import type { State } from "../src/state/state.js"; + +const HOST = "https://eqrm.church.tools"; + +/** A state file with one managed group "kids" at id 99. */ +function stateWithKids(): State { + return { + version: 1, + host: HOST, + resources: { + kids: { type: "group", id: 99, key: "kids", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, + }; +} + +function emptyState(): State { + return { version: 1, host: HOST, resources: {} }; +} + +/** + * Parse the ACTIVE (non-comment) grant entries back out of an emitted block, so they can be fed + * through the real `desiredTuples` — the round-trip property the emitter guarantees. + */ +function parseEmittedGrants(block: string): Grant[] { + const lines = block.split("\n"); + const start = lines.findIndex((l) => l.trim() === "grants: ["); + if (start === -1) return []; // "grants: []," — nothing emitted + const grants: Grant[] = []; + for (const raw of lines.slice(start + 1)) { + const line = raw.trim(); + if (line === "],") break; + if (line.startsWith("//")) continue; + const entry = line.replace(/,$/, ""); + if (entry.startsWith('"')) { + grants.push(JSON.parse(entry) as string); + continue; + } + const m = /^\{ right: ("(?:[^"\\]|\\.)*"), scope: \[(.*)\] \}$/.exec(entry); + if (!m?.[1] || m[2] == null) throw new Error(`Unparseable emitted grant line: ${line}`); + grants.push({ right: JSON.parse(m[1]) as string, scope: JSON.parse(`[${m[2]}]`) as string[] }); + } + return grants; +} + +describe("emitAdoptedGrants", () => { + it("happy path — emits named unscoped grants and the right DSL function", () => { + const rows: RawPermission[] = [ + { authId: 1, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: emptyState() }); + + expect(block).toContain("ct.groupTypeRole({"); + expect(block).toContain("id: 42,"); + expect(block).toContain('"churchcore:administer settings"'); + // grants are config-only, never a numeric id when the authId is known + expect(block).not.toContain("authId"); + }); + + it("group_role emits ct.groupRole", () => { + const rows: RawPermission[] = [{ authId: 1, dataId: null, type: "grant", domainId: 7, meta: { modifiedPid: 5 } }]; + const block = emitAdoptedGrants({ domainType: "group_role", domainId: 7, rows, state: emptyState() }); + expect(block).toContain("ct.groupRole({"); + }); + + it("scoped grant whose dataId is a managed group → emits the group's logical key", () => { + const rows: RawPermission[] = [ + { authId: 1104, dataId: 99, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: stateWithKids() }); + + expect(block).toContain('{ right: "churchgroup:view group", scope: ["kids"] }'); + expect(block).not.toContain("WARNING"); + }); + + it("collapses a multi-scope grant (one CT row per dataId) into one entry", () => { + const state = stateWithKids(); + state.resources.youth = { type: "group", id: 100, key: "youth", fields: {}, adoptedAt: "t", updatedAt: "t" }; + const rows: RawPermission[] = [ + { authId: 1104, dataId: 99, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + { authId: 1104, dataId: 100, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state }); + expect(block).toContain('{ right: "churchgroup:view group", scope: ["kids", "youth"] }'); + }); + + it("scoped grant whose dataId is NOT managed → clearly-marked placeholder comment, no bare key", () => { + const rows: RawPermission[] = [ + { authId: 1104, dataId: 777, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: emptyState() }); + + expect(block).toContain("WARNING: scope target group #777 is not managed"); + expect(block).toContain("ct adopt group 777"); + // the grant is a commented placeholder — never an active line with an invalid/guessed key + expect(block).toContain("// { right: \"churchgroup:view group\", scope:"); + }); + + it("excludes baseline + inherited rows and notes preserved revoke/deny rows", () => { + const rows: RawPermission[] = [ + { authId: 1, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // kept + { authId: 2, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: -1 } }, // baseline → excluded + { authId: 3, dataId: null, type: "grant", domainId: 42, isInherited: true }, // inherited → excluded + { authId: 1104, dataId: 99, type: "revoke", domainId: 42, meta: { modifiedPid: 5 } }, // deny → preserved, noted + ]; + const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: stateWithKids() }); + + expect(block).toContain('"churchcore:administer settings"'); // authId 1 kept + expect(block).not.toContain('scope: ["kids"]'); // the revoke row is NOT emitted as a grant + expect(block).toContain("1 revoke/deny row(s) exist"); + expect(block).toContain("PRESERVES"); + }); + + it("unknown authId → warning comment only (numeric rights are undeclarable), does not fail", () => { + const rows: RawPermission[] = [ + { authId: 999999, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: emptyState() }); + + expect(block).toContain("WARNING: authId 999999 has no catalog entry"); + expect(parseEmittedGrants(block)).toEqual([]); // comment only, no active grant line + }); + + it("emits an empty grants array when no user-authored grants remain", () => { + const rows: RawPermission[] = [ + { authId: 2, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: -1 } }, // baseline only + ]; + const block = emitAdoptedGrants({ domainType: "group_role", domainId: 42, rows, state: emptyState() }); + expect(block).toContain("grants: [],"); + }); + + it("group_type_role right with authId >= 10000 → NOTE comment, never an active grant", () => { + // "churchdb:+edit group infos" (authId 10122) — desiredTuples rejects it on group_type_role. + const rows: RawPermission[] = [ + { authId: 10122, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: emptyState() }); + + expect(block).toContain('NOTE: "churchdb:+edit group infos" (authId 10122) is not writable on group_type_role'); + expect(parseEmittedGrants(block)).toEqual([]); + }); + + it("group_role right with authId >= 10000 IS emitted (only group_type_role rejects it)", () => { + const rows: RawPermission[] = [ + { authId: 10122, dataId: null, type: "grant", domainId: 7, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_role", domainId: 7, rows, state: emptyState() }); + expect(parseEmittedGrants(block)).toEqual(["churchdb:+edit group infos"]); + }); + + it("scoped right granted globally (dataId null) → WARNING comment, never a bare string", () => { + // A bare string for a scoped right is rejected by desiredTuples (silent-global-grant guard). + const rows: RawPermission[] = [ + { authId: 1104, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: emptyState() }); + + expect(block).toContain('WARNING: "churchgroup:view group" is granted GLOBALLY here'); + expect(parseEmittedGrants(block)).toEqual([]); + }); + + it("unscoped right carrying dataIds (stale catalog) → WARNING comment, never a scope", () => { + const rows: RawPermission[] = [ + { authId: 1, dataId: 55, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: emptyState() }); + + expect(block).toContain('WARNING: "churchcore:administer settings" is unscoped per the catalog'); + expect(parseEmittedGrants(block)).toEqual([]); + }); + + it("header warns that comment-only grants will be REVOKED on apply — and only when some exist", () => { + const dirty = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 42, + rows: [ + { authId: 999999, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // unknown + { authId: 10122, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // GTR-unwritable + ], + state: emptyState(), + }); + expect(dirty).toContain("WARNING: 2 live grant(s) could not be expressed as config"); + expect(dirty).toContain("REVOKE"); + + const clean = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 42, + rows: [{ authId: 1, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }], + state: emptyState(), + }); + expect(clean).not.toContain("REVOKE"); + }); + + it("round trip — every emitted grant passes the real desiredTuples, for any mix of rows", () => { + const state = stateWithKids(); + const rows: RawPermission[] = [ + { authId: 1, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // known unscoped + { authId: 1, dataId: 55, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // unscoped w/ dataId (stale catalog) + { authId: 1104, dataId: 99, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // scoped, managed + { authId: 1104, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // scoped, GLOBAL + { authId: 1112, dataId: 777, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // scoped, unmanaged + { authId: 999999, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // unknown authId + { authId: 10122, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // >= 10000 + { authId: 2, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: -1 } }, // baseline + { authId: 3, dataId: null, type: "grant", domainId: 42, isInherited: true }, // inherited + { authId: 1112, dataId: 99, type: "revoke", domainId: 42, meta: { modifiedPid: 5 } }, // deny + ]; + for (const domainType of ["group_role", "group_type_role"] as DomainType[]) { + const block = emitAdoptedGrants({ domainType, domainId: 42, rows, state }); + const grants = parseEmittedGrants(block); + expect(grants.length).toBeGreaterThan(0); // the property is not vacuous + expect(() => + desiredTuples({ key: "adopted", domainType, domainId: 42, grants }, state), + ).not.toThrow(); + } + }); +});