diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43f28bf..5658b97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: npm run format:check - run: npm run lint - run: npm run typecheck - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff248db..71b4456 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,7 @@ jobs: cache: npm - run: npm ci + - run: npm run format:check - run: npm run lint - run: npm run typecheck - run: npm test diff --git a/.gitignore b/.gitignore index 83942b2..a43ce0f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,9 +15,12 @@ ct-state.json # Generated typed client (regenerated from the live OpenAPI spec) src/api/schema.d.ts -# Test/coverage output -coverage/ +# Test/coverage output — ROOT-anchored: a bare `coverage/` also matches `src/coverage/` +# (the `ct coverage` report module, #103) and would silently drop it from every commit. +/coverage/ .vitest/ # MkDocs output for docs/handbuch/ (built by .github/workflows/docs.yml, never committed) site/ +# …and the venv docs/README.md tells you to create here to build the section locally +.venv-docs/ diff --git a/README.md b/README.md index cf01ede..e8c2387 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,13 @@ code, and reconcile it against the ChurchTools API with Terraform-style A ChurchTools instance's structure is normally maintained by clicking. That works — until you need to answer questions clicking cannot: -| Clicking | With `ct` | -| --- | --- | -| "Who changed this group's rights, and why?" | `git log`, and the PR that changed it | -| "Set up the next campus like the last one" | Call the same blueprint function again | -| "Try it somewhere safe first" | `ct plan --env dev` → `ct apply --env dev` → promote | -| "Has anyone edited this by hand?" | `ct plan` reports drift against the last known state | -| "What will this actually change?" | `ct plan` prints it before anything is written | +| Clicking | With `ct` | +| ------------------------------------------- | ---------------------------------------------------- | +| "Who changed this group's rights, and why?" | `git log`, and the PR that changed it | +| "Set up the next campus like the last one" | Call the same blueprint function again | +| "Try it somewhere safe first" | `ct plan --env dev` → `ct apply --env dev` → promote | +| "Has anyone edited this by hand?" | `ct plan` reports drift against the last known state | +| "What will this actually change?" | `ct plan` prints it before anything is written | ## Show me @@ -101,11 +101,13 @@ commands. ct auth login --host https://mychurch.church.tools --token ct auth status # who am I? -ct get groups # JSON to stdout — pipe into jq +ct get groups # JSON to stdout — pipe into jq (every page, not just the first) ct adopt campus 0 # bring ONE existing resource under management +ct coverage # what the instance has that the config does not manage ct state list # what is managed ct plan # diff the config against ChurchTools (read-only) ct apply # create + update in dependency order (confirm + backup first) +ct refresh --group # make ChurchTools re-evaluate one auto-group now ``` `apply` reconciles **creates and updates** only, saving state after each action @@ -116,23 +118,29 @@ confirmation — and a declaration marked `preventDestroy: true` blocks even tha ## What it manages -| Resource | DSL | Guide | -| --- | --- | --- | -| Campuses | `ct.campus` | [config guide](docs/configuration.md) | -| Groups (fields, campus, multi-parent hierarchy) | `ct.group` | [config guide](docs/configuration.md) | -| Group types, roles, age/target groups, relationship types, person statuses | `ct.groupType`, `ct.roleDefinition`, `ct.ageGroup`, `ct.targetGroup`, `ct.relationshipType`, `ct.personStatus` | reusable building blocks | -| Permissions (group-role, group-type-role, person-status) | `ct.groupRole`, `ct.groupTypeRole`, `ct.status` | [permissions](docs/handbuch/permissions.md) | -| Auto-groups (dynamic groups) | the `dynamic` block on a group | [dynamic groups](docs/handbuch/dynamic-groups.md) | -| Repeated structure, parametrized | a plain function over the DSL | [blueprints](docs/handbuch/blueprints.md) | +| Resource | DSL | Guide | +| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| Campuses | `ct.campus` | [config guide](docs/configuration.md) | +| Groups (fields, campus, multi-parent hierarchy) | `ct.group` | [config guide](docs/configuration.md) | +| Group types, roles, age/target groups, relationship types, person statuses | `ct.groupType`, `ct.roleDefinition`, `ct.ageGroup`, `ct.targetGroup`, `ct.relationshipType`, `ct.personStatus` | reusable building blocks | +| Permissions (group-role, group-type-role, person-status) | `ct.groupRole`, `ct.groupTypeRole`, `ct.status` | [permissions](docs/handbuch/permissions.md) | +| Auto-groups (dynamic groups) | the `dynamic` block on a group | [dynamic groups](docs/handbuch/dynamic-groups.md) | +| Repeated structure, parametrized | a plain function over the DSL | [blueprints](docs/handbuch/blueprints.md) | Read-only by design: the person master-data model, security levels and -custom-field *definitions* (`ct get person-masterdata`, `ct get data-fields`) — +custom-field _definitions_ (`ct get person-masterdata`, `ct get data-fields`) — schema in scope, per-record values never. See [field definitions](docs/handbuch/field-definitions.md). References are logical throughout: `groupType: "ministry_team"` resolves to that instance's id at plan time, so the same config drives a dev and a prod instance -unchanged. Numeric ids remain an escape hatch everywhere. +unchanged. Numeric ids remain an escape hatch everywhere — and where one is left +in place, `ct` says so rather than letting a host-specific id travel silently to +another instance. + +`ct coverage` answers the other direction: what exists on the instance that the +config does not manage, and which of it could be declared today (per group _and_ +role, with the blocking scope dimension named). `--json` makes it a CI gate. ## Environments and CI @@ -166,10 +174,10 @@ ct apply --env prod # protected env: type the env name to confirm ## Two-repo model -| Repo | Contents | -| --- | --- | -| **`eqrm/ct-cli`** (this repo) | The tool: CLI, API client, plan/apply engine. Generic, reusable. | -| *your config repo* (private) | Your instance's desired-state config + state files. Depends on this tool. | +| Repo | Contents | +| ----------------------------- | ------------------------------------------------------------------------- | +| **`eqrm/ct-cli`** (this repo) | The tool: CLI, API client, plan/apply engine. Generic, reusable. | +| _your config repo_ (private) | Your instance's desired-state config + state files. Depends on this tool. | Like Terraform, the tool never lives in the same repo as the infra config — the config repo holds an organisation's actual structure and stays private. This repo is the tool diff --git a/docs/README.md b/docs/README.md index 92d1566..0609890 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,13 +9,13 @@ dynamic groups and field definitions behave, independent of any one instance. These pages are published into a wider ChurchTools Handbuch, so they carry `sources:` frontmatter and are gated by the staleness checker (#89). -| Page | About | -| --- | --- | -| [`handbuch/index.md`](handbuch/index.md) | Section overview | -| [`handbuch/permissions.md`](handbuch/permissions.md) | Grant domains, `domainId` semantics, scope resolution | -| [`handbuch/dynamic-groups.md`](handbuch/dynamic-groups.md) | Auto-groups, the ChurchQuery DSL, portable rulesets | +| Page | About | +| ---------------------------------------------------------------- | ------------------------------------------------------------- | +| [`handbuch/index.md`](handbuch/index.md) | Section overview | +| [`handbuch/permissions.md`](handbuch/permissions.md) | Grant domains, `domainId` semantics, scope resolution | +| [`handbuch/dynamic-groups.md`](handbuch/dynamic-groups.md) | Auto-groups, the ChurchQuery DSL, portable rulesets | | [`handbuch/field-definitions.md`](handbuch/field-definitions.md) | Person master data, security levels, custom-field definitions | -| [`handbuch/blueprints.md`](handbuch/blueprints.md) | Parametrized, reusable structure | +| [`handbuch/blueprints.md`](handbuch/blueprints.md) | Parametrized, reusable structure | **A page publishes only if it lives under `handbuch/` and is reachable from `handbuch/mkdocs.yml`'s nav.** Everything else in `docs/` stays invisible. @@ -48,9 +48,9 @@ A page with no code behaviour to track declares `sources: []` plus a ## Everything else — developer- and operator-facing, unpublished -| Page | About | -| --- | --- | -| [`api-coverage.md`](api-coverage.md) | Which ChurchTools endpoints support which CRUD verbs | -| [`group-field-decisions.md`](group-field-decisions.md) | Which group fields are managed vs. left to the CT UI, and why | +| Page | About | +| -------------------------------------------------------- | --------------------------------------------------------------------- | +| [`api-coverage.md`](api-coverage.md) | Which ChurchTools endpoints support which CRUD verbs | +| [`group-field-decisions.md`](group-field-decisions.md) | Which group fields are managed vs. left to the CT UI, and why | | [`runbook-manual-surface.md`](runbook-manual-surface.md) | What `ct` cannot automate — API gaps and the manual steps around them | -| `superpowers/` | Historical implementation plans; kept as a record, never published | +| `superpowers/` | Historical implementation plans; kept as a record, never published | diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 1da5b59..8d98495 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -15,22 +15,22 @@ Analysis of the ChurchTools OpenAPI spec (`openapi.json`, OpenAPI 3.1.0, 487 pat Methods marked only if they actually exist on the matched path. "Update" = PUT or PATCH (noted). Collection paths (list/create) vs item paths (`/{id}`) are separated. -| # | Resource | Matched path(s) | GET (list / by-id) | POST (create) | PUT/PATCH (update) | DELETE | Verdict | -| --- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------- | ----------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | `ct_campus` | `/campuses`, `/campuses/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | -| 2 | `ct_group_type` | `/group/grouptypes`, `/group/grouptypes/{groupTypeId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | -| 3 | `ct_group` | `/groups`, `/groups/{groupId}` | list ✅ / by-id ✅ | ✅ | ✅ PATCH | ✅ | **Full CRUD** | -| 4 | `ct_group_hierarchy` | `/groups/hierarchies` (GET), `/groups/{groupId}/children` (GET), `/groups/{groupId}/parents` (GET), `/groups/{groupId}/parents/{parentGroupId}` (PUT/DELETE) | list ✅ (hierarchies/children/parents) | — (no collection POST) | ✅ PUT links a parent | ✅ unlinks a parent | **Writable** — parent/child edges created & removed via PUT/DELETE on the item path (no POST needed) | -| 5 | `ct_group_role` | `/group/roles`, `/group/roles/{roleId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** (master-data roles). Per-group assignment lives separately at `/groups/{groupId}/roles` GET + `/groups/{groupId}/roles/{roleId}` PATCH. | -| 6 | `ct_dynamic_group` | `/dynamicgroups` (GET), `/dynamicgroups/{groupId}/ruleset` (GET/PUT/DELETE), `/dynamicgroups/{groupId}/status` (GET/PUT), `/dynamicgroups/refresh` & `/dynamicgroups/{groupId}/refresh` (POST) | list ✅ / ruleset & status by-id ✅ | ⚠️ POST only triggers _refresh_, not create | ✅ PUT ruleset & status | ✅ ruleset DELETE | **Partial** — ruleset is fully updatable/deletable; the group entity itself is created via `/groups` (POST) then given a ruleset. No dedicated create/delete of the dynamic-group record. | -| 7 | `ct_permission` | `/permissions/global` (GET), `/permissions/{domainType}` (GET), `/permissions/{domainType}/{domainId}` (GET/PUT/DELETE); `/permissions/internal/...` (GET) | list ✅ / by-id ✅ | — (no collection POST) | ✅ PUT sets permission | ✅ | **Writable** — assign/revoke via PUT/DELETE on `/{domainType}/{domainId}` | -| 8 | `ct_group_status` | none — see note | ❌ | ❌ | ❌ | ❌ | **No REST endpoint at all → manual, permanently (#67).** `/group/memberstatus` looks like a match but is a DIFFERENT dimension — **member** statuses (`{id: "active", name: "Active"}`, STRING ids, assignable per-membership), not group statuses (`groupStatusId`, numeric, e.g. 1 = active / 4 = archived). Live-verified 2026-07-10 on eqrm prod: `/groups/statuses` parses as `/groups/{groupId}` (400), `/group/statuses` and `/groupstatuses` both 404 — no read OR write endpoint for group statuses exists. (`/statuses` + `/statuses/{id}` DO offer full CRUD, but that is the person/community **Status** master data, tag `Status`, a third, unrelated dimension — do not conflate any of the three.) `groupStatusId` stays a plain numeric field on `ct_group`, authored directly — never resolved by name. | -| 9 | `ct_age_group` | `/group/agegroups`, `/group/agegroups/{ageGroupId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | -| 10 | `ct_target_group` | `/group/targetgroups`, `/group/targetgroups/{targetGroupId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | -| 11 | `ct_meeting_point` | — none — | ❌ | ❌ | ❌ | ❌ | **Not in API → fully manual.** Zero matches for `treffpunkt`/`meetingpoint`/`meeting point` anywhere in the spec. Closest neighbours are _meeting templates_ (`/group/meetingtemplates`, full CRUD) and _group meetings_ (`/groups/{groupId}/meetings`, CRUD) — different concepts; confirm with product whether "meeting point" was meant to be one of those. | -| 12 | `ct_relationship_type` | `/person/relationshiptypes`, `/person/relationshiptypes/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | +| # | Resource | Matched path(s) | GET (list / by-id) | POST (create) | PUT/PATCH (update) | DELETE | Verdict | +| --- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------- | ----------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `ct_campus` | `/campuses`, `/campuses/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | +| 2 | `ct_group_type` | `/group/grouptypes`, `/group/grouptypes/{groupTypeId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | +| 3 | `ct_group` | `/groups`, `/groups/{groupId}` | list ✅ / by-id ✅ | ✅ | ✅ PATCH | ✅ | **Full CRUD** | +| 4 | `ct_group_hierarchy` | `/groups/hierarchies` (GET), `/groups/{groupId}/children` (GET), `/groups/{groupId}/parents` (GET), `/groups/{groupId}/parents/{parentGroupId}` (PUT/DELETE) | list ✅ (hierarchies/children/parents) | — (no collection POST) | ✅ PUT links a parent | ✅ unlinks a parent | **Writable** — parent/child edges created & removed via PUT/DELETE on the item path (no POST needed) | +| 5 | `ct_group_role` | `/group/roles`, `/group/roles/{roleId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** (master-data roles). Per-group assignment lives separately at `/groups/{groupId}/roles` GET + `/groups/{groupId}/roles/{roleId}` PATCH. | +| 6 | `ct_dynamic_group` | `/dynamicgroups` (GET), `/dynamicgroups/{groupId}/ruleset` (GET/PUT/DELETE), `/dynamicgroups/{groupId}/status` (GET/PUT), `/dynamicgroups/refresh` & `/dynamicgroups/{groupId}/refresh` (POST) | list ✅ / ruleset & status by-id ✅ | ⚠️ POST only triggers _refresh_, not create | ✅ PUT ruleset & status | ✅ ruleset DELETE | **Partial** — ruleset is fully updatable/deletable; the group entity itself is created via `/groups` (POST) then given a ruleset. No dedicated create/delete of the dynamic-group record. | +| 7 | `ct_permission` | `/permissions/global` (GET), `/permissions/{domainType}` (GET), `/permissions/{domainType}/{domainId}` (GET/PUT/DELETE); `/permissions/internal/...` (GET) | list ✅ / by-id ✅ | — (no collection POST) | ✅ PUT sets permission | ✅ | **Writable** — assign/revoke via PUT/DELETE on `/{domainType}/{domainId}` | +| 8 | `ct_group_status` | none — see note | ❌ | ❌ | ❌ | ❌ | **No REST endpoint at all → manual, permanently (#67).** `/group/memberstatus` looks like a match but is a DIFFERENT dimension — **member** statuses (`{id: "active", name: "Active"}`, STRING ids, assignable per-membership), not group statuses (`groupStatusId`, numeric, e.g. 1 = active / 4 = archived). Live-verified 2026-07-10 on eqrm prod: `/groups/statuses` parses as `/groups/{groupId}` (400), `/group/statuses` and `/groupstatuses` both 404 — no read OR write endpoint for group statuses exists. (`/statuses` + `/statuses/{id}` DO offer full CRUD, but that is the person/community **Status** master data, tag `Status`, a third, unrelated dimension — do not conflate any of the three.) `groupStatusId` stays a plain numeric field on `ct_group`, authored directly — never resolved by name. | +| 9 | `ct_age_group` | `/group/agegroups`, `/group/agegroups/{ageGroupId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | +| 10 | `ct_target_group` | `/group/targetgroups`, `/group/targetgroups/{targetGroupId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | +| 11 | `ct_meeting_point` | — none — | ❌ | ❌ | ❌ | ❌ | **Not in API → fully manual.** Zero matches for `treffpunkt`/`meetingpoint`/`meeting point` anywhere in the spec. Closest neighbours are _meeting templates_ (`/group/meetingtemplates`, full CRUD) and _group meetings_ (`/groups/{groupId}/meetings`, CRUD) — different concepts; confirm with product whether "meeting point" was meant to be one of those. | +| 12 | `ct_relationship_type` | `/person/relationshiptypes`, `/person/relationshiptypes/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | | 13 | `ct_person_status` | `/statuses`, `/statuses/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD, live-verified 2026-08-13** (eqrm prod, CT 3.135.2, read from the instance OpenAPI spec): `/statuses` → GET, POST; `/statuses/{id}` → GET, PUT, DELETE. `POST` requires `name`, `shorty`, `isMember`; `PUT` requires ALL of `name`, `shorty`, `isMember`, `isSearchable`, `sortKey`, `securityLevelId` — uniquely strict among managed types (every other managed PUT declares no required fields), and since PUT is a full replace the registry manages all six rather than a subset. The person/community **Status** master data (tag `Status`): "0 - First", "3 - Group Active", …, the domain a `ct.status` permission grant hangs off. Adoptable since #96, which is what makes a config using that domain self-sufficient across hosts. Do NOT conflate with `ct_group_status` (row 8, no endpoint at all) or `/group/memberstatus` (member statuses, string ids). Master data — never a person record; the people guard is unaffected. | -| 14 | `ct_department` | `/departments` | list ✅ / by-id ❌ | ❌ | ❌ | ❌ | **Read-only — a ref catalog, not a managed resource.** Live-probed 2026-08-13 (eqrm prod, CT 3.135.2): `GET /departments` returns `[{id, name, nameTranslated, sortKey, shorty}]`; no `POST`/`PUT`/`DELETE` on `/departments` exists in the spec, and there is no `/departments/{id}` path at all. Bereiche are the `cdb_bereich` permission scope dimension (`churchdb:view alldata`), so `ct` resolves them BY NAME for a `scope: [{ department: "…" }]` reference (#98) and surfaces them via `ct get departments` — but they can never be declared, adopted or created. | +| 14 | `ct_department` | `/departments` | list ✅ / by-id ❌ | ❌ | ❌ | ❌ | **Read-only — a ref catalog, not a managed resource.** Live-probed 2026-08-13 (eqrm prod, CT 3.135.2): `GET /departments` returns `[{id, name, nameTranslated, sortKey, shorty}]`; no `POST`/`PUT`/`DELETE` on `/departments` exists in the spec, and there is no `/departments/{id}` path at all. Bereiche are the `cdb_bereich` permission scope dimension (`churchdb:view alldata`), so `ct` resolves them BY NAME for a `scope: [{ department: "…" }]` reference (#98) and surfaces them via `ct get departments` — but they can never be declared, adopted or created. | > **Create-time required fields (#73).** The "POST ✅" marks above were spec-derived, not live-exercised for > create. CT's POST validators require fields the tool does not manage for diffing: `group-type` needs @@ -85,9 +85,9 @@ API-client libraries (5pm-HDH `churchtools-api` @ CT 3.104, bensteUEM `src/api/schema.d.ts` is git-ignored and was not available offline. Re-verify per the runbook's re-audit procedure once the schema is regenerated. -| Resource | Matched path(s) | GET | POST/PUT/PATCH/DELETE | Verdict | -| --------------------------------- | ------------------------------ | ----------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Person master-data model | `/person/masterdata` | ✅ (single object) | ❌ | **Read-only.** Versionable master-data model incl. the `securityLevels` enumeration. No write endpoint (edited in the CT master-data admin UI). | +| Resource | Matched path(s) | GET | POST/PUT/PATCH/DELETE | Verdict | +| ------------------------------------ | ----------------------------- | ------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Person master-data model | `/person/masterdata` | ✅ (single object) | ❌ | **Read-only.** Versionable master-data model incl. the `securityLevels` enumeration. No write endpoint (edited in the CT master-data admin UI). | | Data-field definitions (Datenfelder) | `/dbfields`, `/dbfields/{id}` | list ✅ / by-id ✅ | ❌ | **Read-only.** Unified person + group field definitions, discriminated by `fieldCategory`. Mutation only via legacy churchdb AJAX (`db_insert/update/deletefields`), not REST. | See [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md) for the full writability diff --git a/docs/ci.md b/docs/ci.md index d6af451..7f0583e 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -36,11 +36,11 @@ alone do not bypass that guardrail. Terraform-style. With the flag, `ct plan` exits: -| Exit code | Meaning | -|---|---| -| `0` | no changes — desired state already matches ChurchTools (resources AND permissions) | -| `1` | error — the plan is INCOMPLETE (a resource or permission fetch failed), or the command failed outright | -| `2` | changes are pending — at least one resource item is not a no-op, OR at least one permission item has a grant/revoke to apply | +| Exit code | Meaning | +| --------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `0` | no changes — desired state already matches ChurchTools (resources AND permissions) | +| `1` | error — the plan is INCOMPLETE (a resource or permission fetch failed), or the command failed outright | +| `2` | changes are pending — at least one resource item is not a no-op, OR at least one permission item has a grant/revoke to apply | Without the flag, behaviour is byte-identical to before: `ct plan` exits `1` only on an INCOMPLETE plan/error, `0` otherwise — so existing scripts that just @@ -56,7 +56,7 @@ esac ``` **Drift alone never sets exit `2`.** `--detailed-exitcode` mirrors what `ct -apply` would actually *do* — an item can carry drift (ChurchTools changed +apply` would actually _do_ — an item can carry drift (ChurchTools changed since the last apply) while its `action` stays `no-op` (the drifted field isn't managed by the current config, or happens to already match it), and `apply` would write nothing for it. Drift is always visible in the human @@ -81,23 +81,24 @@ Shape: "plan": { "items": [ { - "type": "group", "key": "kids", "id": 7, "action": "update", - "changes": [ - { "field": "name", "from": "Kid's", "to": "Kids", "source": "config" } - ], - "drift": [ - { "field": "campusId", "from": 4, "to": 9 } - ] - } - ] + "type": "group", + "key": "kids", + "id": 7, + "action": "update", + "changes": [{ "field": "name", "from": "Kid's", "to": "Kids", "source": "config" }], + "drift": [{ "field": "campusId", "from": 4, "to": 9 }], + }, + ], }, - "permissions": [ /* PermissionPlanItem[]: { key, domainType, domainId, pendingDomain?, diff: { toPut, toDelete, preserved } } */ ], + "permissions": [ + /* PermissionPlanItem[]: { key, domainType, domainId, pendingDomain?, diff: { toPut, toDelete, preserved } } */ + ], "summary": { "resources": { "create": 0, "update": 1, "delete": 0, "no-op": 3 }, "drifted": 1, "permissions": { "toPut": 0, "toDelete": 0, "preserved": 0 }, - "hasChanges": true - } + "hasChanges": true, + }, } ``` @@ -124,7 +125,7 @@ Per resource item: manages that field — a **superset** of what `changes[].source` narrows down to only the fields `apply` will actually touch. - **Permission items carry no `source`.** The state file snapshots managed - *resource* fields only, not granted permissions, so there is no + _resource_ fields only, not granted permissions, so there is no last-known baseline to attribute a permission diff to config-vs-drift. `diff.toPut`/`diff.toDelete` is honestly just desired-vs-actual — this is the one place the tool cannot make the distinction, so it doesn't diff --git a/docs/configuration.md b/docs/configuration.md index 970ba13..0358b0e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,11 +12,22 @@ export default (ct) => { // Hierarchy is opt-in and multi-parent: `parents` are managed group keys, each declared // in this config. Omit it to leave a group's hierarchy unmanaged; edges to unmanaged // groups stay invisible. (`parent:` is unrelated — an ordering hint only, not hierarchy.) - ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", groupType: "ministry_team", parents: ["mainz_area"] }); + ct.group({ + key: "mainz_kids_lead", + name: "Mainz · Kids Leitung", + groupType: "ministry_team", + parents: ["mainz_area"], + }); // Assign a group to a campus BY KEY: `campus: "mainz"` links to the campus above even though // it is created in the same apply (its id is filled in at apply time). The numeric escape // hatch still works — `campusId: 3` (or `campusId: null` to clear) targets an existing id. - ct.group({ key: "mainz_kids", name: "Mainz · Kids", groupType: "ministry_team", campus: "mainz", parents: ["mainz_kids_lead"] }); + ct.group({ + key: "mainz_kids", + name: "Mainz · Kids", + groupType: "ministry_team", + campus: "mainz", + parents: ["mainz_kids_lead"], + }); }; ``` diff --git a/docs/group-field-decisions.md b/docs/group-field-decisions.md index 9a1ffa8..0a3b63f 100644 --- a/docs/group-field-decisions.md +++ b/docs/group-field-decisions.md @@ -1,12 +1,12 @@ # Group field decisions (#21) -`ct-cli` manages the rights-bearing *structure* of a group, not everything the +`ct-cli` manages the rights-bearing _structure_ of a group, not everything the ChurchTools group object carries. This table records the deliberate decision for each field seen on the live group / group `PATCH` body: **managed** (diffed and applied like `name`), **opt-in synthetic** (its own DSL block + endpoint, not the plain field bag), or **out of scope** (left to the CT admin UI, never touched). -Adding a *managed* field is a real commitment — it needs a registry entry, a diff +Adding a _managed_ field is a real commitment — it needs a registry entry, a diff test, an adopt round-trip, and a note on state-snapshot migration. So this issue promotes exactly one new field (`campusId`) and triages the rest rather than silently widening `managedFields`. @@ -23,20 +23,20 @@ field — a plain top-level key. `campusId` is wired the same deliberate way as ## Decision table -| Field | Decision | Rationale | -|---|---|---| -| `name` | **managed** | Core identity; already managed. | -| `groupTypeId` | **managed** | Determines the group's kind and role template; already managed. | -| `groupStatusId` | **managed** | Lifecycle status; already managed. | -| **`campusId`** | **managed (new, #21)** | The campus link is the tool's core "instantiate this area per campus" requirement. Numeric escape hatch only — an existing CT campus id (or `null` to clear). A *logical* `campus: "key"` reference (resolving a same-run campus by key) is **deferred to [#20](https://github.com/eqrm/ct-cli/issues/20)**; the DSL rejects a `campus` field with a pointer to #20 so it can't slip through as an un-diffable phantom. | -| `parents` (hierarchy) | **opt-in synthetic** | Group→group hierarchy is reconciled through its own endpoint, not the group body — see `src/engine/synthetic.ts`. Opt-in via `parents: [...]`. | -| `dynamic` (auto-group ruleset) | **opt-in synthetic** | Ruleset + status live behind a dedicated endpoint (#14); opt-in via the `dynamic` block. See `docs/handbuch/dynamic-groups.md`. | +| Field | Decision | Rationale | +| ---------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | **managed** | Core identity; already managed. | +| `groupTypeId` | **managed** | Determines the group's kind and role template; already managed. | +| `groupStatusId` | **managed** | Lifecycle status; already managed. | +| **`campusId`** | **managed (new, #21)** | The campus link is the tool's core "instantiate this area per campus" requirement. Numeric escape hatch only — an existing CT campus id (or `null` to clear). A _logical_ `campus: "key"` reference (resolving a same-run campus by key) is **deferred to [#20](https://github.com/eqrm/ct-cli/issues/20)**; the DSL rejects a `campus` field with a pointer to #20 so it can't slip through as an un-diffable phantom. | +| `parents` (hierarchy) | **opt-in synthetic** | Group→group hierarchy is reconciled through its own endpoint, not the group body — see `src/engine/synthetic.ts`. Opt-in via `parents: [...]`. | +| `dynamic` (auto-group ruleset) | **opt-in synthetic** | Ruleset + status live behind a dedicated endpoint (#14); opt-in via the `dynamic` block. See `docs/handbuch/dynamic-groups.md`. | | `allowDuplicateName` (CT's `force` create flag, #75) | **create-only, unmanaged** | Not a field on the live group at all — it's a request-only escape hatch for `POST /groups`' same-name guard (`forbidden.duplicate.group`). Opt-in per declaration; sent as `force: true` on CREATE only. Deliberately NOT a registry `managedFields` entry: it has no live value to diff against, so it is destructured out of the DSL input before the field bag (never triggers the unknown-field warning, never lands in state, never adopted, never touched on update). | -| `visibility` | **out of scope** | Not rights-bearing structure; instance-/policy-specific and easily changed in the UI. No demand in #21 to manage it. Promote later only with its own registry entry + tests. | -| `note` | **out of scope** | Free-text annotation, not structure. Managing it would fight human edits in the UI for no structural benefit. | -| `autoAccept` / open-for-members settings | **out of scope** | Membership-request policy — adjacent to *who is in a group*, which the tool never manages (`assertNotPeople`, `src/engine/guard.ts`). Left to the UI. | -| chat status | **out of scope** | Chat/messaging toggle, outside the structural mandate (README: "campuses, structural groups, hierarchies, group types/roles, permission & auto-groups"). | -| sort key | **out of scope** | Presentation ordering, not structure. (Note: `sortKey` *is* managed on the master-data types `age-group`/`target-group`, where ordering is the resource's point; on a group it is cosmetic.) | +| `visibility` | **out of scope** | Not rights-bearing structure; instance-/policy-specific and easily changed in the UI. No demand in #21 to manage it. Promote later only with its own registry entry + tests. | +| `note` | **out of scope** | Free-text annotation, not structure. Managing it would fight human edits in the UI for no structural benefit. | +| `autoAccept` / open-for-members settings | **out of scope** | Membership-request policy — adjacent to _who is in a group_, which the tool never manages (`assertNotPeople`, `src/engine/guard.ts`). Left to the UI. | +| chat status | **out of scope** | Chat/messaging toggle, outside the structural mandate (README: "campuses, structural groups, hierarchies, group types/roles, permission & auto-groups"). | +| sort key | **out of scope** | Presentation ordering, not structure. (Note: `sortKey` _is_ managed on the master-data types `age-group`/`target-group`, where ordering is the resource's point; on a group it is cosmetic.) | ## State-snapshot migration @@ -46,15 +46,15 @@ and needs no migration**, because: - the diff is **desired-driven** — `diffFields` only walks the config's fields, so a config that doesn't declare `campusId` never invents a change for it; -- drift is **snapshot-driven** — `driftFields` only walks the *old* snapshot's +- drift is **snapshot-driven** — `driftFields` only walks the _old_ snapshot's keys, so a key absent there is never surfaced; - the write body comes from the **fetched actual** ([#27](https://github.com/eqrm/ct-cli/issues/27)), so an unrelated field update never omits or reverts `campusId`, and the post-write snapshot self-heals to include it. -Contrast the `shortName → shorty` case (#17): a *renamed* key lingers in the old +Contrast the `shortName → shorty` case (#17): a _renamed_ key lingers in the old snapshot and drifts forever, so it needs an explicit `migrateState` rename. An -*added* key cannot. See the comment on `migrateState` in `src/state/state.ts`. +_added_ key cannot. See the comment on `migrateState` in `src/state/state.ts`. **Re-adopt guidance:** to capture the campus of an already-managed group into state immediately (rather than waiting for the next apply to self-heal the diff --git a/docs/handbuch/blueprints.md b/docs/handbuch/blueprints.md index c72766f..8e6227e 100644 --- a/docs/handbuch/blueprints.md +++ b/docs/handbuch/blueprints.md @@ -4,7 +4,7 @@ sources: - src/config/context.ts - src/engine/graph.ts - src/engine/hierarchy.ts -sources_hash: e1bc9429a635e819 +sources_hash: 0124644b70452450 reviewed: 2026-08-13 --- @@ -48,7 +48,7 @@ language, no generated files, just a function called twice. Link a group to a campus **by key** — `campus: "mainz"` (or, when the campus key is a loop variable, `campus`) — and the per-host resolver fills in the id (#20). -When the blueprint *creates* the campus in the same apply, its id is unknown at +When the blueprint _creates_ the campus in the same apply, its id is unknown at eval time, so the resolver marks the link **pending** and writes the freshly-created id at apply time (tier ordering creates the campus first). `ct plan` renders it as `campusId = `. @@ -92,7 +92,7 @@ export default (ct: ConfigContext): void => { `${campus}_kids_lead`, `${campus}_kids_0_3`, `${campus}_kids_checkin`, and so on give every campus's copy of the structure its own non-colliding key -namespace, while the *shape* (lead group + N ministry teams) stays defined +namespace, while the _shape_ (lead group + N ministry teams) stays defined once in `kidsArea`. Add a third campus to `CAMPUSES` and the same function produces a third, fully independent structure — no changes to `kidsArea` itself. @@ -112,7 +112,11 @@ that call), never at Mainz's tree: function kidsArea(ct: ConfigContext, campus: string): void { const lead = `${campus}_kids_lead`; ct.group({ key: lead, name: `${campus} · Kids Leitung`, groupTypeId: 2, parents: [] }); - for (const [suffix, label] of [["0_3", "0–3"], ["4_6", "4–6"], ["checkin", "Check-in"]] as const) { + for (const [suffix, label] of [ + ["0_3", "0–3"], + ["4_6", "4–6"], + ["checkin", "Check-in"], + ] as const) { ct.group({ key: `${campus}_kids_${suffix}`, name: `${campus} · Kids ${label}`, @@ -198,7 +202,7 @@ A blueprint doesn't need to worry about sequencing: `${campus}_kids_0_3`/`_4_6`/`_checkin`/`_all` — the topological sort guarantees it structurally, not by declaration order. - **Hierarchy and auto-group state ride along with their group.** `parents` - and `dynamic` are *synthetic fields* on the `group` resource itself (see + and `dynamic` are _synthetic fields_ on the `group` resource itself (see `SYNTHETIC_FIELDS` in [`src/engine/synthetic.ts`](https://github.com/eqrm/ct-cli/blob/main/src/engine/synthetic.ts)), not separate resources with their own tier — they're diffed and applied as part of that same group's create/update, once the group (and, for @@ -224,7 +228,7 @@ beyond the `parents: [lead]` you'd write anyway. any plan or diff is computed (`validateReferences` in [`src/config/context.ts`](https://github.com/eqrm/ct-cli/blob/main/src/config/context.ts), run by `evaluateConfig`). Every key listed in a `parents` array must resolve to a -`group` declared *somewhere in the same config* — including inside a +`group` declared _somewhere in the same config_ — including inside a blueprint function called from the top-level export. A typo, a forgotten `kidsArea(ct, campus)` call, or a `parents` key pointing at a non-group resource throws immediately: diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index 84a28f0..63a1a12 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -6,7 +6,7 @@ sources: - src/engine/dynamic.ts - src/engine/synthetic.ts - src/commands/adopt-group.ts -sources_hash: 785c613f106cbd8a +sources_hash: 3fe32a405c9a8b4f reviewed: 2026-08-10 --- @@ -120,7 +120,7 @@ no-op — it does not re-`PUT` on every apply). Two equivalent ways to author it Simple marker `kind`s carry a single `key` (the logical key / slug): `campus`, `group`, `group-type`. A **role** (`role.id`) uses the compound `group-type-role` marker instead — `{ "__ctRef": true, "kind": - "group-type-role", "groupType": "", "role": "" }` — +"group-type-role", "groupType": "", "role": "" }` — because a ruleset's `role.id` is a **groupTypeRoleId** (a role scoped to a group type), and role names are not globally unique (see the table note below). See `ref` in `src/resolve/refs.ts`. @@ -131,28 +131,29 @@ resolve against) can keep the plain number; you then own its per-environment correctness. This mirrors the permission scope escape hatch (#49): prefer a reference, fall back to a number where no managed key exists. -#### Auto-rewrite on capture: `--portable-rulesets` (opt-in, #76) +#### Auto-rewrite on capture (default since #101; was `--portable-rulesets`, #76) -Rather than hand-editing markers into a freshly captured file, let adopt do it: +Rather than hand-editing markers into a freshly captured file, adopt does it: ```sh -ct adopt group --with-dynamic --portable-rulesets +ct adopt group --with-dynamic # portablized (the default) +ct adopt group --with-dynamic --no-portable-rulesets # verbatim, this host's ids ``` -With the flag, `ct adopt group --with-dynamic` runs the captured (normalized) +`ct adopt group --with-dynamic` runs the captured (normalized) ruleset through `portablizeRuleset` (`src/config/query-refs.ts`) before writing `rulesets/.json`: every numeric id sitting in a known ChurchQuery `var` position that maps to a **managed** logical key is rewritten to its `{ __ctRef }` marker; every other id is left numeric. The `var → RefKind` catalog it keys off (`VAR_REF_KINDS`) is: -| ChurchQuery `var` | marker `kind` | source catalog / state | -| ---------------------- | ------------------ | ------------------------------- | -| `ctgroup.id` | `group` | managed state (no REST catalog) | -| `ctgroup.campusId` | `campus` | `/campuses` | -| `person.campusId` | `campus` | `/campuses` | -| `ctgroup.groupTypeId` | `group-type` | `/group/grouptypes` | -| `role.id` | `group-type-role` | `/group/roles` (by `groupTypeId` + name) | +| ChurchQuery `var` | marker `kind` | source catalog / state | +| --------------------- | ----------------- | ---------------------------------------- | +| `ctgroup.id` | `group` | managed state (no REST catalog) | +| `ctgroup.campusId` | `campus` | `/campuses` | +| `person.campusId` | `campus` | `/campuses` | +| `ctgroup.groupTypeId` | `group-type` | `/group/grouptypes` | +| `role.id` | `group-type-role` | `/group/roles` (by `groupTypeId` + name) | The same `group-type-role` rewrite also covers the **out-of-query** integer field `process.*.handleMembership.groupTypeRoleId` (the target role a @@ -173,21 +174,55 @@ marker carries the group-type key + role name, and the resolver picks the one name slugs to the role. This corrects the earlier `role-def` mapping (#86), which was unresolvable on the real instance. -The flag is **default OFF**: auto-rewriting an id you _thought_ was managed would -silently change query semantics, so you opt in per invocation. Ids that don't map -to a managed key (an operational group outside the scaffold, or the catalog-less -`ctgroup.groupStatusId`, #67) stay numeric — the escape hatch — and adopt emits a -warning naming the file: +#### What could not be portablized is REPORTED, never swallowed (#101) + +Portablization only rewrites references to targets it can resolve. Anything +pointing at an unmanaged target keeps its raw numeric id — the escape hatch — +and that is the dangerous half, because **ChurchTools treats a ruleset as opaque +JSON and does not validate the ids inside it.** A ruleset carrying prod's +`ctgroup.id` applied to dev does not error: the auto-group simply collects the +wrong people, or nobody, and `ct plan` stays green because the ruleset +round-trips byte-identically against the host it was written for. That is +materially worse than a wrong permission scope, because an auto-group's payload +IS group membership — which is what carries grants. + +So every id left numeric is named, with its reason, **both at capture and at +plan time**. + +At **capture** time (`ct adopt … --with-dynamic`) the state file and the +`/group/roles` catalog are both in hand, so the reason is a checked fact: + +```text +! rulesets/jugend.json keeps 3 host-specific id(s) — NOT portable to another host: + ctgroup.id: 1246 left numeric — not under management — `ct adopt group ` for each (then re-adopt) makes them portable + ctgroup.groupStatusId: 1, 2 left numeric — group statuses have no REST catalog (#67) — no logical form exists +``` + +At **plan** time the same scan runs over every declared dynamic group, but with +no state, no catalogs and no network — it can prove that an id sits in an entity +position and nothing more. So it says exactly that, rather than asserting a +reason it never checked: ```text -! left 2 unmanaged id(s) numeric in jugend.json — operational/unmanaged refs, not portable (escape hatch) +! dynamic group "jugend": ruleset carries 1 host-specific id(s) — not portable to another instance: + ctgroup.id: 1246 left numeric — host-specific id(s) frozen into a cross-host ruleset — re-adopt the group with `--with-dynamic` to rewrite them into logical references (it reports what, if anything, blocks each) ``` -> **Interim caveat (when NOT using `--portable-rulesets`):** applying a -> raw-id prod snapshot to a _different_ environment is mechanically fine (CT -> accepts it; unknown ids → empty matches) but **semantically wrong** for that -> environment's memberships. Treat it as a known, documented gap — not a silent -> one — when rehearsing prod configs against dev. +The capture-time reasons are distinct because the fixes are: an **unmanaged** +target (adopt it), a **role unknown to `/group/roles`**, a role whose **group +type is unmanaged**, or a dimension with **no logical form at all** +(`ctgroup.groupStatusId` — group statuses have no REST catalog, #67; this one +needs no lookup, so the plan-time scan reports it too). + +**`--strict-rulesets`** turns the warning into a refusal: adopt writes nothing +if the ruleset would still contain a host-specific id. Use it in a repo that has +decided every ruleset must be portable. + +> **Caveat when using `--no-portable-rulesets`:** applying a raw-id prod +> snapshot to a _different_ environment is mechanically fine (CT accepts it; +> unknown ids → empty matches) but **semantically wrong** for that environment's +> memberships. Adopt says so once per file; treat it as a known, documented gap — +> not a silent one — when rehearsing prod configs against dev. ### The `RuleSet` shape @@ -324,11 +359,35 @@ actually changed in this run. ct apply --refresh ``` -This is deliberately **per-group only** (`refreshChangedDynamicGroups` in -`src/commands/apply.ts`) — the all-groups `/dynamicgroups/refresh` endpoint -has a huge blast radius and is never called from here. A change to one -group's ruleset never triggers a recompute of every dynamic group in the -instance. +This is deliberately **per-group only** — the all-groups +`/dynamicgroups/refresh` endpoint has a huge blast radius and is never called +from here. A change to one group's ruleset never triggers a recompute of every +dynamic group in the instance. + +### `ct refresh` — re-evaluate a group that did NOT change (#105) + +`ct apply --refresh` only covers groups changed in that run, so it cannot +re-evaluate an existing group and does nothing at all on a no-op plan. That +leaves no lever for the most common confusion of all: **a freshly created +auto-group is legitimately empty after a green apply**, because ChurchTools +materializes membership on its own schedule. `ct apply` now says so in its +output rather than leaving you to guess whether the ruleset is wrong. + +```bash +ct refresh --env prod --group jugend # one managed dynamic group +ct refresh --env prod --all # every managed dynamic group +``` + +`--all` is required to fan out — refreshing recomputes membership, so it is +never the default. `ct refresh` only ever touches **managed** groups, refuses a +group that has no ruleset on this host (rather than POSTing into a 404), and +keeps going after a per-group failure (exiting non-zero). + +> **The scheduler ping is NOT fired by `ct`.** ChurchTools' admin cron page hits +> `GET https:///?q=cron&standby=true`, which runs **every due scheduled +> job on the instance** — far beyond auto-groups. It is documented in +> [`docs/runbook-manual-surface.md`](https://github.com/eqrm/ct-cli/blob/main/docs/runbook-manual-surface.md) as a manual +> escape hatch; `ct` deliberately never calls it. ## Full example diff --git a/docs/handbuch/field-definitions.md b/docs/handbuch/field-definitions.md index 623def0..a41a6a5 100644 --- a/docs/handbuch/field-definitions.md +++ b/docs/handbuch/field-definitions.md @@ -3,30 +3,30 @@ title: Field definitions & security levels sources: - src/commands/get.ts - src/api/ctClient.ts -sources_hash: 24ea82f0326ed7d5 +sources_hash: 16a6283b7ce1d982 reviewed: 2026-08-13 --- # Field definitions & security levels (#47, #48) -`ct` can **read** the structural *schema* that defines what data a person or a +`ct` can **read** the structural _schema_ that defines what data a person or a group carries: the person master-data model, the security-level enumeration, and the data-field definitions ("Datenfelder") for both persons and groups. ## Hard boundary — schema in scope, people never -| In scope (schema / DEFINITIONS) | **Never** (people / records) | -| --------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| What fields a person/group *has* (field definitions, types, field groups) | The **value** of any field on an actual person or group record | -| The security-level model (levels + what visibility they gate) | Which persons exist, their memberships, their master-data values | -| The person master-data model (sexes, titles, statuses, campuses, …) | Assigning/reading a person's status, sex, campus, etc. | +| In scope (schema / DEFINITIONS) | **Never** (people / records) | +| ------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| What fields a person/group _has_ (field definitions, types, field groups) | The **value** of any field on an actual person or group record | +| The security-level model (levels + what visibility they gate) | Which persons exist, their memberships, their master-data values | +| The person master-data model (sexes, titles, statuses, campuses, …) | Assigning/reading a person's status, sex, campus, etc. | > **Exception — person statuses are managed, not just read (#96).** The status -> *enumeration* ("0 - First", "3 - Group Active", …) is a declarable resource: +> _enumeration_ ("0 - First", "3 - Group Active", …) is a declarable resource: > `ct.personStatus({ key, name, shorty })`, adoptable with > `ct adopt person-status ` and listable with `ct get statuses` > (`/statuses`, full CRUD). That is what lets a config declare permission grants -> on a status (`ct.status`) and still stand up on a fresh host. Which *person* +> on a status (`ct.status`) and still stand up on a fresh host. Which _person_ > carries which status remains permanently out of scope, like every other > per-record value. Campuses are likewise managed (`ct.campus`); the rest of the > master-data model — sexes, titles, the security-level enumeration — stays @@ -34,7 +34,7 @@ the data-field definitions ("Datenfelder") for both persons and groups. This mirrors the tool's permanent people boundary (README "People are never managed"; `assertNotPeople` in `src/engine/guard.ts`). The commands below read -*definitions only*; none of them read or write a per-record field value. +_definitions only_; none of them read or write a per-record field value. ## Read commands @@ -102,11 +102,11 @@ the row out of the read-only section here and in the runbook. ## Endpoint reference -| Purpose | Path | Methods (this CT) | `ct` surface | -| ---------------------------------------- | ----------------------- | ------------------------ | --------------------------- | -| Person master-data model + security levels | `/person/masterdata` | GET (read-only) | `ct get person-masterdata` | -| Data-field definitions (person + group) | `/dbfields`, `/dbfields/{id}` | GET (read-only) | `ct get data-fields` | -| Field-definition **mutation** | legacy `churchdb` AJAX (`db_insertfields`/`db_updatefields`/`db_deletefields`) | non-REST | **not managed — manual** | +| Purpose | Path | Methods (this CT) | `ct` surface | +| ------------------------------------------ | ------------------------------------------------------------------------------ | ----------------- | -------------------------- | +| Person master-data model + security levels | `/person/masterdata` | GET (read-only) | `ct get person-masterdata` | +| Data-field definitions (person + group) | `/dbfields`, `/dbfields/{id}` | GET (read-only) | `ct get data-fields` | +| Field-definition **mutation** | legacy `churchdb` AJAX (`db_insertfields`/`db_updatefields`/`db_deletefields`) | non-REST | **not managed — manual** | All paths verified against public CT client libraries + CT Academy docs, **not** against this repo's (git-ignored, ungenerated) `src/api/schema.d.ts`. diff --git a/docs/handbuch/index.md b/docs/handbuch/index.md index 5a1c46c..4b894d3 100644 --- a/docs/handbuch/index.md +++ b/docs/handbuch/index.md @@ -20,11 +20,11 @@ each page marks explicitly what was **verified** and what is still an ## Where each kind of documentation lives -| Layer | Repo | Example | -| --- | --- | --- | -| **Generic ChurchTools behaviour** | `ct-cli` `docs/handbuch/` (these pages) | "A `group_type_role` grant reaches every group of that type" | -| **One instance's structure** | that organisation's private config repo, `docs/` | "Which Bereiche exist here and why" | -| **Tool-specific how-tos** | the relevant tool's own Handbuch | "How to run check-in" | +| Layer | Repo | Example | +| --------------------------------- | ------------------------------------------------ | ------------------------------------------------------------ | +| **Generic ChurchTools behaviour** | `ct-cli` `docs/handbuch/` (these pages) | "A `group_type_role` grant reaches every group of that type" | +| **One instance's structure** | that organisation's private config repo, `docs/` | "Which Bereiche exist here and why" | +| **Tool-specific how-tos** | the relevant tool's own Handbuch | "How to run check-in" | ## The pages @@ -36,13 +36,13 @@ each page marks explicitly what was **verified** and what is still an ruleset/status model, the typed ChurchQuery DSL, how membership recompute is triggered, and how a captured ruleset is made portable across instances. - **[Field definitions](field-definitions.md)** — the person master-data model, - security levels, and person/group custom-field *definitions*: what is readable + security levels, and person/group custom-field _definitions_: what is readable over REST, what is writable, and where the boundary to per-record values runs. - **[Blueprints](blueprints.md)** — describing a repeated structure (e.g. one campus's area scaffold) once and instantiating it per campus. !!! note "Language" - These pages are English while the rest of the Handbuch is German. That is a - known, deliberate mismatch — they are placed here first and translated (or - relocated) by the follow-on content work; see - [ct-cli#89](https://github.com/eqrm/ct-cli/issues/89). +These pages are English while the rest of the Handbuch is German. That is a +known, deliberate mismatch — they are placed here first and translated (or +relocated) by the follow-on content work; see +[ct-cli#89](https://github.com/eqrm/ct-cli/issues/89). diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index c9493eb..fe0c962 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -5,7 +5,7 @@ sources: - src/resolve/resolver.ts - src/resolve/refs.ts - src/config/context.ts -sources_hash: 03c3ae24524afcc9 +sources_hash: 050a604ccb2391c8 reviewed: 2026-08-13 --- @@ -20,25 +20,25 @@ person-status rights — as code, and reconcile them idempotently with the same ```ts export default (ct) => { ct.groupTypeRole({ - key: "leiter_tpl", // logical key (unique across the whole config) - groupType: "ministry_team", // domain BY NAME — resolved to the domainId per host (#20) + key: "leiter_tpl", // logical key (unique across the whole config) + groupType: "ministry_team", // domain BY NAME — resolved to the domainId per host (#20) grants: [ - "churchgroup:view group", // unscoped - { right: "churchgroup:view group", scope: ["kids_area"] }, // scoped + "churchgroup:view group", // unscoped + { right: "churchgroup:view group", scope: ["kids_area"] }, // scoped ], }); ct.groupRole({ key: "kids_lead_grant", - group: "kids_area", // domain BY (group, role) — resolved to the pairing domainId per host (#25) - role: "Leiter", // (or keep the numeric escape hatch: `id: 2882`) + group: "kids_area", // domain BY (group, role) — resolved to the pairing domainId per host (#25) + role: "Leiter", // (or keep the numeric escape hatch: `id: 2882`) // "edit group memberships of group" is a scoped right, so it takes a `scope: [...]`. grants: [{ right: "churchgroup:edit group memberships of group", scope: ["kids_area"] }], }); ct.status({ key: "core_external_login", - personStatus: "5 - Core", // domain BY PERSON-STATUS NAME — resolved against /statuses (#90) + personStatus: "5 - Core", // domain BY PERSON-STATUS NAME — resolved against /statuses (#90) // -1 is ChurchTools' "all values of this dimension" sentinel (here: every external system). grants: [{ right: "churchcore:login to external system", scope: [-1] }], }); @@ -91,7 +91,7 @@ Each line shows the name, its numeric `authId`, and whether it's `scoped` **plan/apply time** — inside `desiredTuples` (`src/permissions/plan.ts`), when grants are resolved to tuples against the catalog, after the authed fetch — with a "did you mean" hint drawn from same-module names. (Config -evaluation only checks a grant's *shape*: `module:right` string or +evaluation only checks a grant's _shape_: `module:right` string or `{ right, scope }`; it does not resolve the name against the catalog.) ## Catalog lifecycle & staleness (#25) @@ -100,33 +100,57 @@ The catalog (`src/permissions/catalog.json`) is a snapshot of one instance's permission master data, captured at a specific ChurchTools version. Two things keep it honest: -**Regeneration — one command.** Point it at a live instance and it rewrites -`catalog.json` (rights + a fresh `$meta` provenance stamp): +**Refresh it for YOUR instance — `ct permissions catalog --refresh` (#105).** +This is the one to reach for from a consumer repo. It captures the catalog from +the instance you are targeting and writes it beside your config: + +```bash +ct permissions catalog --refresh --env prod # → .ct/permission-catalog..json +ct permissions catalog --env prod # show which catalog is active, and where it came from +``` + +**Commit that file.** Every subsequent command that needs to know what a right +_is_ — `ct plan`, `ct apply`, `ct coverage`, `ct adopt grants` — loads it in +preference to the catalog bundled with the `ct` release, and says so in its +header (`permission catalog: .ct/permission-catalog..json`). They all load +it, and all load it before evaluating your config, so no two of them can disagree +about the same right on the same host. A repo that never runs the refresh is +unaffected — the bundled catalog stays the fallback. + +This exists because the bundled catalog is a snapshot of **one** instance's +ChurchTools version, and the staleness warning below used to tell you to run a +script that only exists in the ct-cli repo. A consumer repo could not act on its +own warning short of opening a PR upstream and waiting for a release. + +**Regenerating the BUNDLED catalog (ct-cli maintainers).** Inside this repo, to +move the shipped default forward: ```bash CT_HOST=https://your.church.tools CT_LOGINTOKEN= npm run regenerate:permission-catalog ``` -It logs in, calls the legacy `POST /index.php?q=churchauth/ajax` `func=getMasterData` -endpoint (the only source of the name↔authId map — see -`src/permissions/README.md`), records the instance's CT version, and writes the -file. It performs a single **read**; it never writes to the instance. Review -the `git diff` before committing. +Both paths read the same source: the legacy `POST /index.php?q=churchauth/ajax` +`func=getMasterData` endpoint, the only place the name↔authId map is exposed +(see `src/permissions/README.md`). Both perform a single **read**; neither +writes to the instance. Review the `git diff` before committing either file. **Staleness & unknown rights — `ct plan` warns (never fails).** `$meta.ctVersion` records the version the catalog was captured from. On every `plan`/`apply`: - If the live instance's CT version differs from `$meta.ctVersion`, `ct plan` - prints a warning — right names/authIds/scopeFields may have drifted; - regenerate to be sure. + prints a warning — right names/authIds/scopeFields may have drifted; capture + one for this instance to be sure. A per-instance capture is authoritative for + its host **at capture time, not forever**: the instance gets upgraded while the + committed file does not, so the comparison still runs against a capture, and + the warning then asks you to _re-capture_ rather than to capture. - If a **live grant carries an `authId` the catalog cannot name** (a stale or foreign right), `ct plan` names the `authId` + domain and **leaves the grant - untouched** — it is deliberately kept *out* of the diff so `ct apply` never + untouched** — it is deliberately kept _out_ of the diff so `ct apply` never revokes a right it cannot even describe. This is idempotent: the unknown row is excluded every run, so it neither churns nor silently disappears. Both are warnings, not errors: the plan still runs and the exit code stays -success. Regenerating the catalog (above) is the fix for both. +success. `ct permissions catalog --refresh` is the fix for both. ## `domainId` semantics @@ -139,7 +163,7 @@ The two DSL functions manage two different ChurchTools "domain types," and `groupType: ""` (resolved per host, #20) or directly as `id: `. - **`group_role`** (`ct.groupRole`) — the domain is the **internal (group, role) pairing's own id** — a ChurchTools-internal id for one - specific group's specific role, *not* the group's id and *not* the role's + specific group's specific role, _not_ the group's id and _not_ the role's id. Declare it portably as `group: "", role: ""` (resolved per host, #25) or directly as `id: `. - **`status`** (`ct.status`) — the domain is a **person status's own id** @@ -174,7 +198,7 @@ The two DSL functions manage two different ChurchTools "domain types," and match it. > **Teardown caveat.** A person status is the one managed type whose deletion - > reaches person *records*: dropping the declaration and running `ct destroy` + > reaches person _records_: dropping the declaration and running `ct destroy` > deletes the status, and ChurchTools re-stamps every person carrying it. > `assertNotPeople` cannot catch this (`/statuses/{id}` is not a people path), > so `ct destroy` warns explicitly for this type and `--force` does **not** @@ -184,8 +208,8 @@ The two DSL functions manage two different ChurchTools "domain types," and That is what makes a config using the `status` domain self-sufficient across hosts. Before it, `personStatus: "…"` could only resolve against statuses that already existed on the target instance, so a config that planned to a clean - no-op on prod died on dev with *"no managed resource and no live person-status - at /statuses matches key …"* — whose own advice ("Declare/adopt it") was not + no-op on prod died on dev with _"no managed resource and no live person-status + at /statuses matches key …"_ — whose own advice ("Declare/adopt it") was not actually possible. A status declared in the same config resolves to a pending domain and converges in one `ct apply`, exactly like a same-run group type. @@ -233,7 +257,7 @@ handled as a **pending domain** rather than aborting the plan: **`group_role` is deliberately NOT symmetric here.** A `group_role` domain id is the (group, role) **pairing** id, which only exists on -`GET /groups/{groupId}/roles` — re-resolving it needs a *live fetch* after the +`GET /groups/{groupId}/roles` — re-resolving it needs a _live fetch_ after the group exists, not just a post-execute state lookup. So a `group_role` domain referencing a same-run-created **group** still fails fast with its own actionable message ("apply the group first, or pass a numeric id"). Its harder @@ -245,16 +269,16 @@ scenario). A scoped grant's `scope: [...]` is a list where each entry is one of three forms (`src/permissions/scope.ts`): -| Form | Example | Dimension | -| --- | --- | --- | -| **Logical group key** (string) | `scope: ["kids_area"]` | groups (`cdb_gruppe`) only | -| **Typed logical reference** (#98) | `scope: [{ campus: "koblenz" }]` | campuses, group types — see below | -| **Raw numeric `dataId`** (escape hatch, #49) | `scope: [1, 2, 3]` | any dimension | +| Form | Example | Dimension | +| -------------------------------------------- | -------------------------------- | --------------------------------- | +| **Logical group key** (string) | `scope: ["kids_area"]` | groups (`cdb_gruppe`) only | +| **Typed logical reference** (#98) | `scope: [{ campus: "koblenz" }]` | campuses, group types — see below | +| **Raw numeric `dataId`** (escape hatch, #49) | `scope: [1, 2, 3]` | any dimension | String entries are resolved against **desired ∪ state**: - A key already in state resolves to that group's `dataId`. -- A key **declared in this config but not yet created** resolves to a *pending* +- A key **declared in this config but not yet created** resolves to a _pending_ target: the plan renders it as `scope=[ (created this apply)]`, and its real `dataId` is filled in at apply time — so a config can declare a group AND a grant scoped to it and still plan/apply in one run (no bootstrap deadlock). @@ -273,14 +297,14 @@ silently orphan a grant's scope. **Re-resolution at apply time.** Every scoped tuple resolved from a logical group key retains its symbolic scope key. Immediately before grants are written (after the resource tier has run), each key is re-resolved against the -post-execute state. This means a group *created* or *recreated* in the same +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. ### Typed logical scope references (#98) Not every scoped right scopes **by group**, and some of the other dimensions are -resources this tool *can* name portably. Those take a typed reference: +resources this tool _can_ name portably. Those take a typed reference: ```ts ct.groupRole({ @@ -297,22 +321,22 @@ ct.groupRole({ `{ campus: "koblenz" }` is sugar for `ref.campus("koblenz")` — the same `Ref` the rest of the DSL uses — so both spellings are interchangeable. -| `scopeField` | Reference form | Resolved against | -| --- | --- | --- | -| `cdb_gruppe` | `{ group: "" }` (or the bare string) | managed groups | -| `cdb_station` | `{ campus: "" }` | managed campuses, then `GET /campuses` | -| `cdb_gruppentyp` | `{ groupType: "" }` | managed group types, then `GET /group/grouptypes` | -| `cdb_bereich` | `{ department: "" }` | `GET /departments` **only** — read-only, see below | +| `scopeField` | Reference form | Resolved against | +| ---------------- | ----------------------------------------- | -------------------------------------------------- | +| `cdb_gruppe` | `{ group: "" }` (or the bare string) | managed groups | +| `cdb_station` | `{ campus: "" }` | managed campuses, then `GET /campuses` | +| `cdb_gruppentyp` | `{ groupType: "" }` | managed group types, then `GET /group/grouptypes` | +| `cdb_bereich` | `{ department: "" }` | `GET /departments` **only** — read-only, see below | **Why this matters:** campus ids are host-specific — Mainz is `0` on eqrm prod and `6` on eqrm dev. A campus-scoped grant written as a numeric literal is therefore a cross-environment misgrant, and because declaring a domain makes -`ct` *own* it, the wrong-scope grant also revokes whatever is really there on +`ct` _own_ it, the wrong-scope grant also revokes whatever is really there on the other host. The typed reference makes one config plan clean on both. Resolution mirrors the domain-reference rules: managed state first, the live master-data catalog second, and a target **declared in this same config** -resolves to a *pending* scope re-resolved at apply time. A reference resolved +resolves to a _pending_ scope re-resolved at apply time. A reference resolved through the catalog (not under management) carries an already-final id and is not re-resolved. Catalogs are read **paginated** — ChurchTools returns only a first page (10 rows) for a plain list read, so an instance with more campuses, @@ -365,7 +389,7 @@ e.g.: - `churchdb:view comments` → `scopeField: "cdb_comment_viewer"` - `churchdb:security level view own data` / `edit own data` → `scopeField: - "cc_securitylevel"` +"cc_securitylevel"` For these, a `dataId` like `1`, `2`, `3` names a security level or a comment-viewer bucket — **not** a group — so `GET /groups/{1,2,3}` 404s and @@ -409,6 +433,36 @@ them, read the live rows and emit a paste-ready config block: ct adopt grants group_type_role 42 # or: group_role / status, and the hyphenated group-type-role ``` +### Bulk adoption (#104) + +Adopting a whole instance one domain at a time meant dozens of invocations and +dozens of pastes, each needing its `key` renamed and its emitted numeric `id:` +swapped for the portable `group` + `role` pair — the two edits a human forgets +on the 30th paste. So: + +```bash +ct adopt grants --group kids # every role instance of one group +ct adopt grants --all-declarable # every declarable role instance on the host +ct adopt grants --all-declarable --write config/grants.ts # append instead of printing +``` + +In bulk mode: + +- the **portable domain form** is emitted whenever the group is managed — + `group: "kids"` + `role: "Leiter"` instead of the host-specific pairing + `id:` — and the key is derived as `_` (`kids_leiter`). + An unmanaged group falls back to `id:` with a comment saying why; +- a block that would **revoke live grants** is never emitted. In bulk the + per-block `WARNING` header stops being a safeguard and becomes something the + reader scrolls past, so such domains are skipped and listed instead; +- a role instance blocked by an undeclarable scope dimension is skipped and + named, with the dimension to pass to [`preserveUnknown`](#partial-ownership-preserveunknown-opt-in-102). + The single-domain form still emits it, deliberately, one domain at a time; +- nothing is capped silently — the run prints how many blocks it emitted **and** + how many it skipped, with the reason for each. + +### The single-domain form + 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 @@ -478,7 +532,7 @@ and `ct plan`. too broad and is removed. - **Revocation is a later extension, not exposed yet.** `GrantTuple.type` is typed as `"grant" | "revoke"`, but the DSL and `desiredTuples` currently - only ever *emit* `"grant"` tuples — there is no config-level way to declare + only ever _emit_ `"grant"` tuples — there is no config-level way to declare an explicit `type: "revoke"` grant today (`type: "revoke"` is reserved for a future `group_role`-only extension). Removing a grant's entry from the config still works as expected: the reconciler's set-diff (see below) @@ -517,6 +571,82 @@ one `DELETE` per `toDelete` tuple against unchanged instance diffs to empty and issues no requests — the reconciliation is idempotent. +## Partial ownership — `preserveUnknown` (opt-in, #102) + +A declaration **owns its whole domain**: any live grant absent from `grants` is +revoked. That is the right default, and it does not move. But it also means one +unmanageable grant makes an entire role instance undeclarable — and on a real +instance the blocker is almost always module data (an HTML template, a calendar +category, a wiki category) sitting next to perfectly expressible structural +grants. On eqrm prod that cost 403 of 590 authored grants, several of them +blocked by a _single_ `cc_html_template` row on a 41-grant role. + +`preserveUnknown` is the deliberate way out: + +```ts +ct.groupRole({ + key: "team_office_leiter", + group: "team_office", + role: "Leiter", + // Own the structural grants; leave anything on these dimensions alone. This role also carries + // cc_html_template grants that this scaffold has no business owning. + preserveUnknown: ["cc_html_template"], + grants: [/* the 40 structural ones */], +}); +``` + +- **Opt-in per declaration.** There is no global switch, and no default change. +- **`true` preserves everything undeclared**; a **list of scope dimensions** is + the form to prefer — it keeps the escape hatch's blast radius to the + dimensions you consciously excluded, so a genuinely unexpected new grant on a + dimension you _do_ manage still shows up as drift rather than being swallowed. +- A dimension list never widens to **unscoped** rights: you named dimensions to + leave alone, and "no dimension" is not one of them. +- A dimension no right in the catalog scopes by is an **eval-time error**, not a + silent no-op — a typo that preserves nothing would otherwise read exactly like + "there was nothing to preserve", right up until an apply revokes 41 grants. +- **Never invisible.** The plan renders every preserved grant and counts them + separately from the change totals, so "I forgot one" and "I deliberately left + the module grants alone" cannot look alike: + +``` + group_role #44675 (team_office_leiter): +0 grant(s), -0 remove(s), ~2 preserved + ~ authId=17 scope=[3] (grant) (preserved, not managed — preserveUnknown) + +Permission plan: 0 to grant, 0 to remove, 2 preserved (not managed). +``` + +`ct coverage` (below) names the exact dimensions blocking each role instance — +those are the strings to pass here. + +## What is declarable — `ct coverage` (#103) + +To ask an instance "what exists here that I am not managing, and could I manage +it?": + +```bash +ct coverage --env prod # totals, per-type table, declarability verdict +ct coverage --env prod --blocked # only the role instances something blocks, and what +ct coverage --env prod --json # for a CI gate +``` + +It joins `/groups?include[]=roles`, `/dynamicgroups` and +`/permissions/group_role` against your state file. Two details it gets right +that a hand-rolled audit easily does not: `?include[]=roles` turns one role +lookup per group into a handful of paged calls, and **inherited rows are +excluded** from the authored counts (forgetting that inflated one real audit +from 590 to 714 grants and made several role instances look unmanageable that +were not). + +Declarability is reported per **(group, role)**, not per group — one group +routinely has two declarable roles and one blocked one, and group granularity +would hide exactly that. A role instance is declarable when every authored grant +is either unscoped, uses the `-1` ALL sentinel, scopes by a dimension with a +[logical reference form](#scope-resolution) (`cdb_gruppe`, `cdb_station`, +`cdb_gruppentyp`, `cdb_bereich`), or scopes by a numeric-but-host-independent +dimension (`cc_securitylevel`). Anything else is module data with no resource +behind it, and is named as the blocker. + ## Example See [`examples/permissions.config.ts`](https://github.com/eqrm/ct-cli/blob/main/examples/permissions.config.ts) for diff --git a/docs/runbook-manual-surface.md b/docs/runbook-manual-surface.md index 2a3b556..b7ca3c0 100644 --- a/docs/runbook-manual-surface.md +++ b/docs/runbook-manual-surface.md @@ -17,12 +17,12 @@ Every item below falls into exactly one of three buckets: ## API gap — CT does not expose a write endpoint -| Item | What it is | Why manual | Where in the CT admin UI | How to verify | -| ------------------------------ | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Group member-statuses | The set of "member status" values a group can assign to its members (e.g. active/candidate) — NOT the same dimension as a group's own `groupStatusId`, which IS fully managed (`ct.group({ groupStatusId })`, [`docs/group-field-decisions.md`](group-field-decisions.md)); do not conflate the two (#67) | Only `GET /group/memberstatus` exists; no create/update/delete endpoint ([`docs/api-coverage.md`](api-coverage.md) #8) | Group settings → member status admin (org-wide master data, not per-group) | `ct get raw /group/memberstatus` (no dedicated `ct get member-statuses` subcommand yet — the generic `raw` path covers it) and diff by eye against the expected list below | -| Meeting points (Treffpunkte) | A group's meeting-location master data | No endpoint at all — zero matches for `treffpunkt`/`meetingpoint` anywhere in the OpenAPI spec ([`docs/api-coverage.md`](api-coverage.md) #11) | Group admin → meeting point field on a group | No API verification possible; visually confirm in the UI. (Do not confuse with _meeting templates_ `/group/meetingtemplates` or _group meetings_ `/groups/{id}/meetings`, both full CRUD but different concepts — confirm with product if "meeting point" was meant to be one of those instead) | -| Bereiche (departments) — creating/renaming one | The Bereich master data (`/departments`) — the `cdb_bereich` permission scope dimension | `GET /departments` exists but no write verb does — no `POST`/`PUT`/`DELETE`, and no `/departments/{id}` path at all (live-probed against the instance OpenAPI spec, eqrm prod CT 3.135.2, 2026-08-13; [`docs/api-coverage.md`](api-coverage.md) #14) | Master data admin → Bereiche | `ct get departments` lists them. **Grants scoped to one ARE declarable** — `scope: [{ department: "" }]` resolves by name per host (#98) — so only CREATING or RENAMING a Bereich is manual. Renaming one in CT breaks every config reference to its old name, by design: the reference then hard-errors instead of silently regranting elsewhere | -| Permission name↔authId catalog | The mapping from human-readable `module:right` names to the numeric `authId` the API actually writes | Not exposed by the REST API at all; only servable via the legacy `POST /index.php?q=churchauth/ajax&func=getMasterData` call ([`src/permissions/README.md`](../src/permissions/README.md)) | Permission editor (any role's right-picker enumerates the live set) | Regeneration procedure below (**Permission catalog lifecycle**) | +| Item | What it is | Why manual | Where in the CT admin UI | How to verify | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Group member-statuses | The set of "member status" values a group can assign to its members (e.g. active/candidate) — NOT the same dimension as a group's own `groupStatusId`, which IS fully managed (`ct.group({ groupStatusId })`, [`docs/group-field-decisions.md`](group-field-decisions.md)); do not conflate the two (#67) | Only `GET /group/memberstatus` exists; no create/update/delete endpoint ([`docs/api-coverage.md`](api-coverage.md) #8) | Group settings → member status admin (org-wide master data, not per-group) | `ct get raw /group/memberstatus` (no dedicated `ct get member-statuses` subcommand yet — the generic `raw` path covers it) and diff by eye against the expected list below | +| Meeting points (Treffpunkte) | A group's meeting-location master data | No endpoint at all — zero matches for `treffpunkt`/`meetingpoint` anywhere in the OpenAPI spec ([`docs/api-coverage.md`](api-coverage.md) #11) | Group admin → meeting point field on a group | No API verification possible; visually confirm in the UI. (Do not confuse with _meeting templates_ `/group/meetingtemplates` or _group meetings_ `/groups/{id}/meetings`, both full CRUD but different concepts — confirm with product if "meeting point" was meant to be one of those instead) | +| Bereiche (departments) — creating/renaming one | The Bereich master data (`/departments`) — the `cdb_bereich` permission scope dimension | `GET /departments` exists but no write verb does — no `POST`/`PUT`/`DELETE`, and no `/departments/{id}` path at all (live-probed against the instance OpenAPI spec, eqrm prod CT 3.135.2, 2026-08-13; [`docs/api-coverage.md`](api-coverage.md) #14) | Master data admin → Bereiche | `ct get departments` lists them. **Grants scoped to one ARE declarable** — `scope: [{ department: "" }]` resolves by name per host (#98) — so only CREATING or RENAMING a Bereich is manual. Renaming one in CT breaks every config reference to its old name, by design: the reference then hard-errors instead of silently regranting elsewhere | +| Permission name↔authId catalog | The mapping from human-readable `module:right` names to the numeric `authId` the API actually writes | Not exposed by the REST API at all; only servable via the legacy `POST /index.php?q=churchauth/ajax&func=getMasterData` call ([`src/permissions/README.md`](../src/permissions/README.md)) | Permission editor (any role's right-picker enumerates the live set) | Regeneration procedure below (**Permission catalog lifecycle**) | **Expected values for a given instance:** left blank here deliberately — this runbook is generic (part of `ct-cli`, the tool repo). The per-instance @@ -31,16 +31,16 @@ in that instance's own config repo, in a runbook following this doc's structure. ## Not yet implemented — API supports it, `ct` doesn't drive it yet -| Item | What it is | Tracking issue | Manual workaround today | -| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Group/group-type field decision table | Fields deliberately left unmanaged (decided out of scope): visibility, note, `autoAccept`/open-for-members, chat status, sort key. The triage **shipped** as a committed decision table ([`docs/group-field-decisions.md`](group-field-decisions.md)) | [#21](https://github.com/eqrm/ct-cli/issues/21) (decided) | Set by hand; these fields are intentionally not diffed — `ct` will neither preserve nor revert them. Promote one later only with its own registry entry + tests | -| Portable/logical references | **Shipped (#20, #25).** Configs reference master data by name/key — `campus`/`groupType` on a group, `ref.campus(...)` in ruleset `var` values, `groupType: ""` for a `group_type_role` domain, and now `group: "", role: ""` for a `group_role` domain (#25) — and the per-host resolver maps each to that instance's id at plan time (managed resources ∪ live catalogs). A same-run campus resolves at apply time. Numeric ids still work as an escape hatch. **`status` (group status) is NOT part of this** (#67) — group statuses have no REST catalog, so `status:` fails fast at eval time; declare the numeric `groupStatusId` directly | [#20](https://github.com/eqrm/ct-cli/issues/20) (done), [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | None needed for the shipped surface. Write logical names; run `ct plan`. The `group_role` pairing-id resolution is verified live (row below) | -| 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 `group_role` domain by reference **(shipped, verified live)** | `ct.groupRole({ group, role })` now resolves the (group, role) pair to its pairing domainId at plan time (#25). **Confirmed live 2026-08-13 (CT 3.135.2):** it reads the group's role list (`GET /groups/{groupId}/roles`) and takes the matched role row's `id` as the pairing domainId. Two anchors on different group types: each row's `id` is a live `group_role` domainId carrying that role's grants, while its type-level `groupTypeRoleId` appears nowhere in the domainId set | [#25](https://github.com/eqrm/ct-cli/issues/25) (done, verified) | None needed. Works by reference for managed, already-created groups; numeric `id:` remains a supported escape hatch ([`docs/handbuch/permissions.md`](handbuch/permissions.md) "domainId semantics") | -| ~~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/handbuch/permissions.md`](handbuch/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~~ **(shipped)** | ~~`catalog.json` is a one-off HAR-trace snapshot with no staleness detection~~ — **shipped (#25):** `npm run regenerate:permission-catalog` rewrites it from a live instance (records the CT version in `$meta`), and `ct plan` now warns on a version mismatch or an unknown-authId live grant (which it leaves untouched, never revoking a right it cannot name). See [`docs/handbuch/permissions.md`](handbuch/permissions.md) "Catalog lifecycle & staleness" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run the command; heed the `ct plan` warnings | -| Field definitions & security levels (person + group custom fields) **(read-only, shipped #47/#48)** | The person master-data model, the security-level enumeration, and the data-field DEFINITIONS ("Datenfelder") for persons and groups — structural schema, not per-record values | [#47](https://github.com/eqrm/ct-cli/issues/47), [#48](https://github.com/eqrm/ct-cli/issues/48) (read shipped; write is an API gap — see note) | Read with `ct get person-masterdata` (model + security levels) and `ct get data-fields` (all field definitions, person + group, discriminated by `fieldCategory`). **Mutation stays manual:** field definitions have no REST write endpoint — only the legacy churchdb admin AJAX (`db_insertfields`/`db_updatefields`/`db_deletefields`) — so create/edit/delete them by hand in the master-data admin UI. Decision + evidence: [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md) | -| 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 member-status write, or — separately — a first-ever group-status list/write endpoint, #67) 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**) | +| Item | What it is | Tracking issue | Manual workaround today | +| --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Group/group-type field decision table | Fields deliberately left unmanaged (decided out of scope): visibility, note, `autoAccept`/open-for-members, chat status, sort key. The triage **shipped** as a committed decision table ([`docs/group-field-decisions.md`](group-field-decisions.md)) | [#21](https://github.com/eqrm/ct-cli/issues/21) (decided) | Set by hand; these fields are intentionally not diffed — `ct` will neither preserve nor revert them. Promote one later only with its own registry entry + tests | +| Portable/logical references | **Shipped (#20, #25).** Configs reference master data by name/key — `campus`/`groupType` on a group, `ref.campus(...)` in ruleset `var` values, `groupType: ""` for a `group_type_role` domain, and now `group: "", role: ""` for a `group_role` domain (#25) — and the per-host resolver maps each to that instance's id at plan time (managed resources ∪ live catalogs). A same-run campus resolves at apply time. Numeric ids still work as an escape hatch. **`status` (group status) is NOT part of this** (#67) — group statuses have no REST catalog, so `status:` fails fast at eval time; declare the numeric `groupStatusId` directly | [#20](https://github.com/eqrm/ct-cli/issues/20) (done), [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | None needed for the shipped surface. Write logical names; run `ct plan`. The `group_role` pairing-id resolution is verified live (row below) | +| 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 `group_role` domain by reference **(shipped, verified live)** | `ct.groupRole({ group, role })` now resolves the (group, role) pair to its pairing domainId at plan time (#25). **Confirmed live 2026-08-13 (CT 3.135.2):** it reads the group's role list (`GET /groups/{groupId}/roles`) and takes the matched role row's `id` as the pairing domainId. Two anchors on different group types: each row's `id` is a live `group_role` domainId carrying that role's grants, while its type-level `groupTypeRoleId` appears nowhere in the domainId set | [#25](https://github.com/eqrm/ct-cli/issues/25) (done, verified) | None needed. Works by reference for managed, already-created groups; numeric `id:` remains a supported escape hatch ([`docs/handbuch/permissions.md`](handbuch/permissions.md) "domainId semantics") | +| ~~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/handbuch/permissions.md`](handbuch/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~~ **(shipped)** | ~~`catalog.json` is a one-off HAR-trace snapshot with no staleness detection~~ — **shipped (#25):** `npm run regenerate:permission-catalog` rewrites it from a live instance (records the CT version in `$meta`), and `ct plan` now warns on a version mismatch or an unknown-authId live grant (which it leaves untouched, never revoking a right it cannot name). See [`docs/handbuch/permissions.md`](handbuch/permissions.md) "Catalog lifecycle & staleness" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run the command; heed the `ct plan` warnings | +| Field definitions & security levels (person + group custom fields) **(read-only, shipped #47/#48)** | The person master-data model, the security-level enumeration, and the data-field DEFINITIONS ("Datenfelder") for persons and groups — structural schema, not per-record values | [#47](https://github.com/eqrm/ct-cli/issues/47), [#48](https://github.com/eqrm/ct-cli/issues/48) (read shipped; write is an API gap — see note) | Read with `ct get person-masterdata` (model + security levels) and `ct get data-fields` (all field definitions, person + group, discriminated by `fieldCategory`). **Mutation stays manual:** field definitions have no REST write endpoint — only the legacy churchdb admin AJAX (`db_insertfields`/`db_updatefields`/`db_deletefields`) — so create/edit/delete them by hand in the master-data admin UI. Decision + evidence: [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md) | +| 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 member-status write, or — separately — a first-ever group-status list/write endpoint, #67) 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**) | ## Out of tool scope — deliberate, not a gap @@ -48,13 +48,52 @@ in that instance's own config repo, in a runbook following this doc's structure. | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | People, memberships, group member lists | Hard boundary enforced in code (`assertNotPeople`, `src/engine/guard.ts`) — the tool manages rights-bearing _structure_ only, never who's in it. This is permanent by design, not a roadmap item; see README's "People are never managed" | | Other CT modules — calendars, services (`churchservice`), resource booking (`churchresource`), forms, check-in, wiki, finance, sync | Never in the tool's stated mandate ("campuses, structural groups, hierarchies, group types/roles, permission & auto-groups" — README). Phase 0's coverage matrix (`docs/api-coverage.md`) only analyzed the 12 resource types relevant to that structural mandate; nothing else was assessed for CRUD support and nothing else is planned | -| Module-level settings, i18n | Out of tool scope by design — global instance configuration, not per-resource declarative structure | -| Custom field / master-data field **VALUES on individual person/group records** | People/record data is the permanent people boundary (`assertNotPeople`). Note: the field **DEFINITIONS** (schema) are **no longer out of scope** — they moved to read-only supported above (#47/#48, [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md)); only the per-record *values* stay out of scope. | +| Module-level settings, i18n | Out of tool scope by design — global instance configuration, not per-resource declarative structure | +| Custom field / master-data field **VALUES on individual person/group records** | People/record data is the permanent people boundary (`assertNotPeople`). Note: the field **DEFINITIONS** (schema) are **no longer out of scope** — they moved to read-only supported above (#47/#48, [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md)); only the per-record _values_ stay out of scope. | + +## Forcing ChurchTools to evaluate auto-groups now + +**Supported path — `ct refresh` (#105).** `ct apply` writes the ruleset and flips +the status; ChurchTools materializes membership on its **own schedule**, so a +freshly created auto-group is legitimately empty right after a green apply. + +```bash +ct refresh --env prod --group # POST /dynamicgroups/{id}/refresh for one managed group +ct refresh --env prod --all # …for every managed dynamic group +``` + +**Manual escape hatch — the legacy scheduler ping. `ct` deliberately never fires +this.** ChurchTools' admin cron page hits: + +```bash +curl -s -o /dev/null "https:///?q=cron&standby=true" # returns a 1×1 image +``` + +That runs **every due scheduled job on the instance**, not just auto-group +evaluation — calendar syncs, mailings, whatever else is due. Its blast radius is +the whole instance, which is exactly why it stayed out of the tool: `ct refresh` +targets one auto-group at a time and only ever touches groups the config manages. +Reach for the curl only when you need CT's scheduler itself to tick, and know +what else is due when you do. ## Permission catalog lifecycle (regeneration procedure) -**Scripted (#25) — the normal path.** Regenerate `src/permissions/catalog.json` -from a live instance with one command: +**From a consumer repo (#105) — the normal path.** Capture the catalog for the +instance you actually target, and commit it: + +```bash +ct permissions catalog --refresh --env prod # → .ct/permission-catalog..json +ct permissions catalog --env prod # which catalog is active, and where it came from +``` + +Every `ct plan`/`ct apply` against that host then uses it in preference to the +catalog bundled with the `ct` release (and the version-skew warning goes quiet, +since a capture from the target host is authoritative for it). Before this, the +warning told you to run a script that only exists in the ct-cli repo — so it was +unactionable exactly where it was printed, and printed on every single plan. + +**In the ct-cli repo (#25) — moving the shipped default forward.** Regenerate +`src/permissions/catalog.json` from a live instance with one command: ```bash CT_HOST=https://your.church.tools CT_LOGINTOKEN= npm run regenerate:permission-catalog @@ -67,7 +106,7 @@ stamps the instance's CT version into `$meta`, and rewrites the file (read-only against the instance). Review the `git diff` and commit. **Staleness signals.** `ct plan`/`ct apply` throw a clear "did you mean" error -for an unknown right *name* in a config; they now also **warn** (not fail) when +for an unknown right _name_ in a config; they now also **warn** (not fail) when the live instance's CT version differs from `$meta.ctVersion`, and when a live grant carries an `authId` the catalog cannot name (left untouched, never revoked). Both are fixed by regenerating. @@ -139,7 +178,7 @@ in where they'd otherwise be silently skipped: 7. **Field definitions & security levels** — read the current schema with `ct get person-masterdata` and `ct get data-fields` to confirm the expected fields, field groups, and security levels exist; create/edit any missing - *definitions* by hand in the master-data admin UI (no REST write endpoint — + _definitions_ by hand in the master-data admin UI (no REST write endpoint — see the field-definitions row above and [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md)). 8. **Anything from the "out of tool scope" table** — persons, memberships, per-record field values, calendars, services, resource booking, forms, diff --git a/docs/superpowers/plans/2026-07-07-phase-4-apply-destroy.md b/docs/superpowers/plans/2026-07-07-phase-4-apply-destroy.md index 537465f..6aa673f 100644 --- a/docs/superpowers/plans/2026-07-07-phase-4-apply-destroy.md +++ b/docs/superpowers/plans/2026-07-07-phase-4-apply-destroy.md @@ -42,11 +42,13 @@ ## Task 1: `preventDestroy` lifecycle flag **Files:** + - Modify: `src/engine/types.ts` - Modify: `src/config/context.ts` - Test: `tests/context.test.ts` **Interfaces:** + - Produces: `DesiredResource.preventDestroy?: boolean`; `ResourceInput.preventDestroy?: boolean`. The flag is extracted before building `fields`, so it is never diffed or sent to the API. - [ ] **Step 1: Write the failing test** — append to `tests/context.test.ts`: @@ -125,10 +127,12 @@ git commit -m "feat(config): preventDestroy lifecycle flag" ## Task 2: Registry write specs + new writable types **Files:** + - Modify: `src/resources/registry.ts` - Test: `tests/registry.test.ts` **Interfaces:** + - Produces: `AdoptableResource` gains `collectionPath: string` and `updateMethod: "PUT" | "PATCH"`. `itemPath(id)` still returns `\`${collectionPath}/${id}\``. New entries: `age-group`, `target-group`, `relationship-type`, `group-role`. - [ ] **Step 1: Write the failing test** — append to `tests/registry.test.ts`: @@ -259,10 +263,12 @@ git commit -m "feat(registry): write specs (collectionPath, updateMethod) + new ## Task 3: People-boundary guard **Files:** + - Create: `src/engine/guard.ts` - Test: `tests/guard.test.ts` **Interfaces:** + - Produces: `assertNotPeople(path: string): void` — throws for any people/membership write path; returns silently for structural paths (including `/groups/{id}/parents/{id}`). - [ ] **Step 1: Write the failing test** — create `tests/guard.test.ts`: @@ -325,9 +331,7 @@ const FORBIDDEN: RegExp[] = [ export function assertNotPeople(path: string): void { if (FORBIDDEN.some((re) => re.test(path))) { - throw new Error( - `Refusing to write to "${path}": people/memberships are never managed by this tool.`, - ); + throw new Error(`Refusing to write to "${path}": people/memberships are never managed by this tool.`); } } ``` @@ -349,10 +353,12 @@ git commit -m "feat(engine): assertNotPeople hard boundary guard" ## Task 4: Backup writer **Files:** + - Create: `src/engine/backup.ts` - Test: `tests/backup.test.ts` **Interfaces:** + - Produces: `writeBackup(dir: string, host: string, actual: Map>, now?: Date): Promise` — returns the written file path. Creates `dir` if missing. Filename `ct-backup-.json`. Content: `{ host, capturedAt, resources }`. - [ ] **Step 1: Write the failing test** — create `tests/backup.test.ts`: @@ -447,10 +453,12 @@ git commit -m "feat(engine): pre-write backup writer" ## Task 5: Confirmation prompts **Files:** + - Create: `src/ui/prompt.ts` - Test: `tests/prompt.test.ts` **Interfaces:** + - Produces: - `confirm(message: string, opts?: PromptOptions & { assumeYes?: boolean }): Promise` - `confirmTyped(expected: string, opts?: PromptOptions & { force?: boolean }): Promise` @@ -578,11 +586,13 @@ git commit -m "feat(ui): confirm + confirmTyped prompts" ## Task 6: Shared plan building (`buildPlan`) **Files:** + - Create: `src/engine/build.ts` - Modify: `src/commands/plan.ts` - Test: `tests/build.test.ts` **Interfaces:** + - Produces: `buildPlan(client, state, desired): Promise` where - `client: Pick` - `interface BuildResult { plan: Plan; actual: Map>; fetchErrors: string[] }` @@ -795,10 +805,12 @@ git commit -m "refactor(engine): extract buildPlan; plan command reuses it" ## Task 7: The executor (`executePlan`) **Files:** + - Create: `src/engine/execute.ts` - Test: `tests/execute.test.ts` **Interfaces:** + - Consumes: `RESOURCES` (Task 2), `assertNotPeople` (Task 3), `upsert`/`saveState` from `state.ts`, `Plan`/`PlanItem`/`FieldChange` from `types.ts`. - Produces: - `interface ExecuteDeps { client: Pick; state: State; statePath: string; now?: () => string; save?: (path: string, state: State) => Promise }` @@ -853,35 +865,93 @@ describe("executePlan", () => { }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.created).toEqual(["zurich"]); - expect(calls[0]).toEqual({ method: "POST", path: "/campuses", body: { name: "Zürich", shortName: "ZH" } }); - expect(state.resources.zurich).toMatchObject({ type: "campus", id: 5, key: "zurich", fields: { name: "Zürich", shortName: "ZH" } }); + expect(calls[0]).toEqual({ + method: "POST", + path: "/campuses", + body: { name: "Zürich", shortName: "ZH" }, + }); + expect(state.resources.zurich).toMatchObject({ + type: "campus", + id: 5, + key: "zurich", + fields: { name: "Zürich", shortName: "ZH" }, + }); }); it("updates a group via PATCH with the full managed snapshot", async () => { const state = emptyState("h"); - state.resources.team = { type: "group", id: 9, key: "team", fields: { name: "Team", groupTypeId: 2, groupStatusId: 1 }, adoptedAt: "t", updatedAt: "t" }; + state.resources.team = { + type: "group", + id: 9, + key: "team", + fields: { name: "Team", groupTypeId: 2, groupStatusId: 1 }, + adoptedAt: "t", + updatedAt: "t", + }; const { client, calls } = recorder(); const plan: Plan = { items: [ - { type: "group", key: "team", id: 9, action: "update", changes: [{ field: "name", from: "Team", to: "Team A" }] }, + { + type: "group", + key: "team", + id: 9, + action: "update", + changes: [{ field: "name", from: "Team", to: "Team A" }], + }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.updated).toEqual(["team"]); - expect(calls[0]).toEqual({ method: "PATCH", path: "/groups/9", body: { name: "Team A", groupTypeId: 2, groupStatusId: 1 } }); + expect(calls[0]).toEqual({ + method: "PATCH", + path: "/groups/9", + body: { name: "Team A", groupTypeId: 2, groupStatusId: 1 }, + }); expect(state.resources.team!.fields).toEqual({ name: "Team A", groupTypeId: 2, groupStatusId: 1 }); }); it("reconciles hierarchy edges via PUT/DELETE and never stores parents in state", async () => { const state = emptyState("h"); - state.resources.parent = { type: "group", id: 1, key: "parent", fields: { name: "P" }, adoptedAt: "t", updatedAt: "t" }; - state.resources.child = { type: "group", id: 2, key: "child", fields: { name: "C" }, adoptedAt: "t", updatedAt: "t" }; + state.resources.parent = { + type: "group", + id: 1, + key: "parent", + fields: { name: "P" }, + adoptedAt: "t", + updatedAt: "t", + }; + state.resources.child = { + type: "group", + id: 2, + key: "child", + fields: { name: "C" }, + adoptedAt: "t", + updatedAt: "t", + }; const { client, calls } = recorder(); const plan: Plan = { items: [ - { type: "group", key: "child", id: 2, action: "update", changes: [{ field: "parents", from: [], to: ["parent"] }] }, + { + type: "group", + key: "child", + id: 2, + action: "update", + changes: [{ field: "parents", from: [], to: ["parent"] }], + }, ], }; await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); @@ -894,7 +964,13 @@ describe("executePlan", () => { state.resources.old = { type: "campus", id: 3, key: "old", fields: {}, adoptedAt: "t", updatedAt: "t" }; const { client, calls } = recorder(); const plan: Plan = { items: [{ type: "campus", key: "old", id: 3, action: "delete", changes: [] }] }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.skippedDeletes).toEqual(["old"]); expect(calls).toEqual([]); expect(state.resources.old).toBeDefined(); @@ -908,9 +984,23 @@ describe("executePlan", () => { }, }; const plan: Plan = { - items: [{ type: "campus", key: "zurich", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "Z" }] }], + items: [ + { + type: "campus", + key: "zurich", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "Z" }], + }, + ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed).toEqual({ key: "zurich", message: "boom" }); expect(result.created).toEqual([]); }); @@ -1026,7 +1116,12 @@ export async function executePlan(plan: Plan, deps: ExecuteDeps): Promise 0) { - error(`Aborting: ${fetchErrors.length} resource(s) could not be fetched — the plan is incomplete. Re-run when resolved.`); + error( + `Aborting: ${fetchErrors.length} resource(s) could not be fetched — the plan is incomplete. Re-run when resolved.`, + ); process.exitCode = 1; return; } @@ -1202,7 +1306,9 @@ export function applyCommand(): Command { const result = await executePlan(plan, { client, state, statePath, save: saveState }); success(`Applied: ${result.created.length} created, ${result.updated.length} updated.`); if (result.failed) { - error(`Stopped at ${result.failed.key}: ${result.failed.message}. State saved up to this point — re-run to resume.`); + error( + `Stopped at ${result.failed.key}: ${result.failed.message}. State saved up to this point — re-run to resume.`, + ); process.exitCode = 1; } }); @@ -1226,10 +1332,12 @@ git commit -m "feat(cli): ct apply — plan, confirm, backup, execute" ## Task 9: `ct destroy` command **Files:** + - Create: `src/commands/destroy.ts` - Test: `tests/destroy.test.ts` (target parsing + reverse ordering + preventDestroy set) **Interfaces:** + - Consumes: `RESOURCES`, `assertNotPeople`, `confirmTyped`, `writeBackup`, `tierOf` from `engine/graph.js`, `saveState`. - Produces: `destroyCommand(): Command`; exported helpers `parseTargets(raw: string[]): string[]` and `orderDestroy(state, keys): string[]`. @@ -1249,7 +1357,14 @@ describe("parseTargets", () => { describe("orderDestroy", () => { it("orders higher tiers first (reverse of apply): groups before campuses", () => { const state = emptyState("h"); - state.resources.mainz = { type: "campus", id: 0, key: "mainz", fields: {}, adoptedAt: "t", updatedAt: "t" }; + state.resources.mainz = { + type: "campus", + id: 0, + key: "mainz", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; state.resources.team = { type: "group", id: 1, key: "team", fields: {}, adoptedAt: "t", updatedAt: "t" }; expect(orderDestroy(state, ["mainz", "team"])).toEqual(["team", "mainz"]); }); @@ -1404,11 +1519,13 @@ git commit -m "feat(cli): ct destroy — explicit, protected, reverse-order dele ## Task 10: Wire commands + integration test + cleanup **Files:** + - Modify: `src/index.ts` - Modify: `src/commands/placeholders.ts` - Test: `tests/cli.test.ts`, `tests/integration.test.ts` **Interfaces:** + - Consumes: `applyCommand`, `destroyCommand`. - Produces: `apply`/`destroy` registered on the program; `placeholders.ts` no longer lists them. @@ -1479,7 +1596,14 @@ describe("apply → re-plan shows no drift", () => { const client = fakeCt(); const state = emptyState("h"); // adopt the existing Mainz campus - state.resources.mz = { type: "campus", id: 0, key: "mz", fields: { name: "Mainz", shortName: "MZ" }, adoptedAt: "t", updatedAt: "t" }; + state.resources.mz = { + type: "campus", + id: 0, + key: "mz", + fields: { name: "Mainz", shortName: "MZ" }, + adoptedAt: "t", + updatedAt: "t", + }; const desired: DesiredResource[] = [ { type: "campus", key: "mz", fields: { name: "Mainz City", shortName: "MZ" }, dependsOn: [] }, @@ -1521,8 +1645,8 @@ import { destroyCommand } from "./commands/destroy.js"; and, after `program.addCommand(planCommand());`: ```ts - program.addCommand(applyCommand()); - program.addCommand(destroyCommand()); +program.addCommand(applyCommand()); +program.addCommand(destroyCommand()); ``` (The `for (const cmd of plannedCommands())` loop stays — it just adds nothing now.) @@ -1544,6 +1668,7 @@ git commit -m "feat(cli): wire apply/destroy; drop placeholders; DoD integration ## Task 11: Docs, live-verify field sets, lint/typecheck/build **Files:** + - Modify: `README.md` - Modify: `src/resources/registry.ts` (only if live-verify shows different fields) @@ -1574,5 +1699,5 @@ git commit -m "docs(phase-4): README apply/destroy; live-verified field sets" - **Spec coverage:** apply (T6–T8), destroy + preventDestroy (T1, T9), backup (T4, T8, T9), people boundary (T3, used in T7/T9), rate-limit/retry (existing — noted, no task), state-after-each (T7 saves; T9 saves), confirmation (T5, T8, T9), all writable types (T2), DoD integration (T10), live-verify (T11). No gaps. - **Type consistency:** `ExecuteDeps`/`ExecuteResult`/`BuildResult`/`PromptOptions` names are used identically across tasks. `snapshotFromChanges` is the single field-merge helper. `resolveBackupDir` defined in T8, reused by T9. -- **Placeholder scan:** none — every code step is complete. The only deferred item is the *provisional field sets*, which are explicitly a live-verify step (T11) with a concrete verification command, not a plan gap. +- **Placeholder scan:** none — every code step is complete. The only deferred item is the _provisional field sets_, which are explicitly a live-verify step (T11) with a concrete verification command, not a plan gap. - **Known limitation (documented):** `orderDestroy` uses reverse-tier order only (state doesn't store hierarchy edges), so deleting a parent group before its child within the same tier isn't auto-ordered; acceptable for Phase 4 and noted. diff --git a/docs/superpowers/plans/2026-07-08-auto-groups.md b/docs/superpowers/plans/2026-07-08-auto-groups.md index dc7874f..ad67832 100644 --- a/docs/superpowers/plans/2026-07-08-auto-groups.md +++ b/docs/superpowers/plans/2026-07-08-auto-groups.md @@ -40,11 +40,13 @@ ## Task 1: Capture live fixtures for a real dynamic group **Files:** + - Create: `tests/fixtures/dynamic/ruleset.get.json` (bare `RuleSet` as returned by GET) - Create: `tests/fixtures/dynamic/status.get.json` (`{ dynamicGroupStatus }`) - Create: `tests/fixtures/dynamic/README.md` (how the fixture was produced + the group id used, so it is reproducible) **Interfaces:** + - Produces: canonical example `RuleSet` + status JSON consumed by Tasks 4, 5, 7 (normalizer, synthetic-field, typed-query). All later normalizer code is validated against these files. **Why a task:** the `query.params.filter` JSONLogic internals and `handleMembership` contents are opaque in OpenAPI. The only way to write a correct normalizer + typed-query compiler is against real rulesets. @@ -58,12 +60,14 @@ ## Task 2: Generic synthetic-field seam; migrate `parents` into it **Files:** + - Create: `src/engine/synthetic.ts` - Modify: `src/engine/build.ts` (replace the inline hierarchy fold with the registry) - Modify: `src/engine/execute.ts` (route synthetic-field writes through the registry) - Test: `tests/synthetic.test.ts` **Interfaces:** + - Produces: ```ts export interface SyntheticFoldCtx { @@ -75,23 +79,26 @@ export interface SyntheticApplyCtx { client: Pick; state: State; - id: number; // CT id of the owning resource just created/updated - change: FieldChange; // the change whose `field` === this.field + id: number; // CT id of the owning resource just created/updated + change: FieldChange; // the change whose `field` === this.field } export interface SyntheticField { - field: string; // pseudo-field name, e.g. "parents", "dynamic" + field: string; // pseudo-field name, e.g. "parents", "dynamic" fold(ctx: SyntheticFoldCtx): Promise<{ desired: DesiredResource[]; errors: string[] }>; apply(ctx: SyntheticApplyCtx): Promise; } - export const SYNTHETIC_FIELDS: SyntheticField[]; // registry; parents first + export const SYNTHETIC_FIELDS: SyntheticField[]; // registry; parents first export function isSyntheticField(field: string): boolean; - export async function foldSynthetic(ctx: SyntheticFoldCtx): Promise<{ desired: DesiredResource[]; errors: string[] }>; + export async function foldSynthetic( + ctx: SyntheticFoldCtx, + ): Promise<{ desired: DesiredResource[]; errors: string[] }>; ``` - Consumes: existing `applyHierarchy`, `parentIdsByGroupId`, `HierarchyEntry` (from `hierarchy.ts`); existing `applyParentEdges` logic (moved/wrapped). - [ ] **Step 1: Write the failing test** `tests/synthetic.test.ts`: + ```ts import { describe, it, expect } from "vitest"; import { isSyntheticField, SYNTHETIC_FIELDS, foldSynthetic } from "../src/engine/synthetic.js"; @@ -107,20 +114,41 @@ describe("synthetic-field registry", () => { it("parents fold folds managed hierarchy into desired + actual", async () => { const state: State = { - version: 1, host: "h", + version: 1, + host: "h", resources: { - child: { type: "group", id: 1311, key: "child", fields: { name: "child" }, adoptedAt: "t", updatedAt: "t" }, - parent: { type: "group", id: 8, key: "parent", fields: { name: "parent" }, adoptedAt: "t", updatedAt: "t" }, + child: { + type: "group", + id: 1311, + key: "child", + fields: { name: "child" }, + adoptedAt: "t", + updatedAt: "t", + }, + parent: { + type: "group", + id: 8, + key: "parent", + fields: { name: "parent" }, + adoptedAt: "t", + updatedAt: "t", + }, }, }; const actual = new Map>([ - ["child", { name: "child" }], ["parent", { name: "parent" }], + ["child", { name: "child" }], + ["parent", { name: "parent" }], ]); const desired: DesiredResource[] = [ { type: "group", key: "child", fields: { name: "child" }, parents: ["parent"], dependsOn: ["parent"] }, { type: "group", key: "parent", fields: { name: "parent" }, dependsOn: [] }, ]; - const client = { get: async () => [{ groupId: 1311, parents: [8], children: [] }, { groupId: 8, children: [1311] }] }; + const client = { + get: async () => [ + { groupId: 1311, parents: [8], children: [] }, + { groupId: 8, children: [1311] }, + ], + }; const out = await foldSynthetic({ client, state, desired, actual }); expect(out.errors).toEqual([]); expect(actual.get("child")?.parents).toEqual(["parent"]); @@ -239,19 +267,23 @@ Expected: PASS. - [ ] **Step 5: Rewire `build.ts` to use `foldSynthetic`** In `src/engine/build.ts`, replace the hierarchy block (the `let parentIds` / `hasManagedGroups` / `applyHierarchy` section, lines ~59-76) with: + ```ts - // Synthetic sub-resource fields (parents, dynamic, …) fold into the diff on both sides. - const folded = await foldSynthetic({ client, state, desired, actual }); - fetchErrors.push(...folded.errors); - const plan = computePlan(folded.desired, state, actual, { unresolved }); +// Synthetic sub-resource fields (parents, dynamic, …) fold into the diff on both sides. +const folded = await foldSynthetic({ client, state, desired, actual }); +fetchErrors.push(...folded.errors); +const plan = computePlan(folded.desired, state, actual, { unresolved }); ``` + Update imports: remove `applyHierarchy`, `parentIdsByGroupId`, `HierarchyEntry`, and add `import { foldSynthetic } from "./synthetic.js";`. Delete the now-unused `desiredWithHierarchy`/`hierarchyOk` locals. - [ ] **Step 6: Rewire `execute.ts` to route synthetic writes through the registry** In `src/engine/execute.ts`: + - Replace `applyParentEdges(...)` calls (in both the create and update branches) with a generic `applySyntheticFields(client, state, id, item.changes)`. - Add the helper and update `snapshotFromChanges` to drop **all** synthetic fields, not just `parents`: + ```ts import { isSyntheticField, syntheticField } from "./synthetic.js"; @@ -263,7 +295,10 @@ function snapshotFromChanges(base: Record, changes: FieldChange } async function applySyntheticFields( - client: Pick, state: State, id: number, changes: FieldChange[], + client: Pick, + state: State, + id: number, + changes: FieldChange[], ): Promise { for (const c of changes) { const f = syntheticField(c.field); @@ -271,6 +306,7 @@ async function applySyntheticFields( } } ``` + Also update `hasFieldChange` in the update branch to `item.changes.some((c) => !isSyntheticField(c.field))` so a change touching only synthetic fields still skips the body PATCH. Remove the old `parentEdges`/`resolveId`/`applyParentEdges` functions. - [ ] **Step 7: Run the full suite to verify the refactor is behavior-preserving** @@ -290,18 +326,20 @@ git commit -m "refactor(engine): generic synthetic-field seam; migrate parents i ## Task 3: Accept and validate the `dynamic` block in the config DSL **Files:** + - Modify: `src/config/context.ts` (add `dynamic` handling to `toDesired` + validation) - Modify: `src/engine/types.ts` (add `dynamic?` to `DesiredResource`, define `DynamicSpec`) - Test: `tests/context.test.ts` (extend) **Interfaces:** + - Produces: ```ts // types.ts export type DynamicStatus = "active" | "inactive" | "manual" | "none"; export interface DynamicSpec { status: DynamicStatus; - ruleset: unknown; // RuleSet object, or { ref: "./path.json" }, or a typed-query build result + ruleset: unknown; // RuleSet object, or { ref: "./path.json" }, or a typed-query build result } // DesiredResource gains: dynamic?: DynamicSpec; ``` @@ -315,23 +353,32 @@ import { createContext } from "../src/config/context.js"; describe("dynamic block", () => { it("attaches a validated dynamic spec to the group and drops it from plain fields", async () => { const { ct, resources } = createContext(); - ct.group({ key: "all_mainz", name: "Alle Mainz", groupTypeId: 1, - dynamic: { status: "manual", ruleset: { description: "x", method: "ChurchQuery", params: {} } } }); + ct.group({ + key: "all_mainz", + name: "Alle Mainz", + groupTypeId: 1, + dynamic: { status: "manual", ruleset: { description: "x", method: "ChurchQuery", params: {} } }, + }); const g = resources.find((r) => r.key === "all_mainz")!; - expect(g.dynamic).toEqual({ status: "manual", ruleset: { description: "x", method: "ChurchQuery", params: {} } }); - expect(g.fields).not.toHaveProperty("dynamic"); // never a plain diffed field + expect(g.dynamic).toEqual({ + status: "manual", + ruleset: { description: "x", method: "ChurchQuery", params: {} }, + }); + expect(g.fields).not.toHaveProperty("dynamic"); // never a plain diffed field }); it("rejects an invalid status", async () => { const { ct } = createContext(); - expect(() => ct.group({ key: "g", name: "G", dynamic: { status: "bogus", ruleset: {} } as never })) - .toThrow(/dynamic.*status/i); + expect(() => + ct.group({ key: "g", name: "G", dynamic: { status: "bogus", ruleset: {} } as never }), + ).toThrow(/dynamic.*status/i); }); it("rejects dynamic on a non-group", async () => { const { ct } = createContext(); - expect(() => ct.campus({ key: "c", name: "C", dynamic: { status: "manual", ruleset: {} } } as never)) - .toThrow(/dynamic.*only.*group/i); + expect(() => + ct.campus({ key: "c", name: "C", dynamic: { status: "manual", ruleset: {} } } as never), + ).toThrow(/dynamic.*only.*group/i); }); }); ``` @@ -346,21 +393,24 @@ Expected: FAIL — `g.dynamic` undefined; no validation. In `src/engine/types.ts` add the exports above and `dynamic?: DynamicSpec;` to `DesiredResource`. In `src/config/context.ts`: + - Add `dynamic` to the destructure in `toDesired`: `const { key, parent, parents, dependsOn = [], preventDestroy, dynamic, ...fields } = input;` - After the existing validation, add: + ```ts - const DYNAMIC_STATUSES = ["active", "inactive", "manual", "none"] as const; - let dynamicSpec: DynamicSpec | undefined; - if (dynamic !== undefined) { - if (type !== "group") throw new Error(`${type} "${key}": "dynamic" is only valid on a group.`); - const d = dynamic as Record; - if (!DYNAMIC_STATUSES.includes(d.status as never)) - throw new Error(`group "${key}": "dynamic.status" must be one of ${DYNAMIC_STATUSES.join(", ")}.`); - if (d.ruleset == null || typeof d.ruleset !== "object") - throw new Error(`group "${key}": "dynamic.ruleset" must be a RuleSet object or a { ref } reference.`); - dynamicSpec = { status: d.status as DynamicStatus, ruleset: d.ruleset }; - } +const DYNAMIC_STATUSES = ["active", "inactive", "manual", "none"] as const; +let dynamicSpec: DynamicSpec | undefined; +if (dynamic !== undefined) { + if (type !== "group") throw new Error(`${type} "${key}": "dynamic" is only valid on a group.`); + const d = dynamic as Record; + if (!DYNAMIC_STATUSES.includes(d.status as never)) + throw new Error(`group "${key}": "dynamic.status" must be one of ${DYNAMIC_STATUSES.join(", ")}.`); + if (d.ruleset == null || typeof d.ruleset !== "object") + throw new Error(`group "${key}": "dynamic.ruleset" must be a RuleSet object or a { ref } reference.`); + dynamicSpec = { status: d.status as DynamicStatus, ruleset: d.ruleset }; +} ``` + - Return `dynamic: dynamicSpec` on the `DesiredResource` (add to the returned object). Import `DynamicSpec`, `DynamicStatus` from `../engine/types.js`. - Keep `dynamic` out of `fields` (it is destructured out above — verify it is not spread back in). @@ -381,21 +431,27 @@ git commit -m "feat(dynamic): accept and validate the dynamic block in the confi ## Task 4: Ruleset normalizer **Files:** + - Create: `src/engine/dynamic.ts` (normalizer + status helpers) - Test: `tests/dynamic.test.ts` **Interfaces:** + - Produces: ```ts - export interface NormalizedDynamic { status: DynamicStatus; ruleset: Record } - export function normalizeRuleset(rule: unknown): Record; // canonical form for diffing - export function stripCosmeticLabels(node: unknown): unknown; // remove dterm [label, expr] wrappers - export function coerceScalars(node: unknown): unknown; // int/string coercion of leaf values + export interface NormalizedDynamic { + status: DynamicStatus; + ruleset: Record; + } + export function normalizeRuleset(rule: unknown): Record; // canonical form for diffing + export function stripCosmeticLabels(node: unknown): unknown; // remove dterm [label, expr] wrappers + export function coerceScalars(node: unknown): unknown; // int/string coercion of leaf values export function normalizeDynamic(spec: { status: DynamicStatus; ruleset: unknown }): NormalizedDynamic; ``` - Consumes: `DynamicStatus` from `types.js`; the fixtures from Task 1. **Reference:** the golden inputs are the **real production fixtures** captured in Task 1 (`tests/fixtures/dynamic/ruleset*.get.json`) — see `tests/fixtures/dynamic/README.md`. Key realities they encode: + - **`GET …/ruleset` returns a single-element array `[RuleSet]`** — `normalizeRuleset` unwraps it (see code below). - `dterm: [label, expr]` labels appear as **both** plain strings (`"Nur aktive Personen"`) and objects (`{ title, stereotype? }`, `title` possibly an i18n key). The stripper drops element 0 regardless of its shape and keeps `expr`. - int/string inconsistency is pervasive (`1` vs `"1"`, `oneof [...["112","8"]]` vs `oneof [...[1]]`) — `coerceScalars` fixes it. Non-numeric strings (`"active"`, field names) are left alone. @@ -403,10 +459,16 @@ git commit -m "feat(dynamic): accept and validate the dynamic block in the confi - [ ] **Step 1: Write the failing test** `tests/dynamic.test.ts`: + ```ts import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; -import { normalizeRuleset, stripCosmeticLabels, coerceScalars, normalizeDynamic } from "../src/engine/dynamic.js"; +import { + normalizeRuleset, + stripCosmeticLabels, + coerceScalars, + normalizeDynamic, +} from "../src/engine/dynamic.js"; describe("stripCosmeticLabels", () => { it("unwraps a dterm with a string label to its expr, recursively", () => { @@ -414,27 +476,37 @@ describe("stripCosmeticLabels", () => { expect(stripCosmeticLabels(input)).toEqual({ and: [{ "==": [{ var: "ctgroup.campusId" }, 1] }] }); }); it("unwraps a dterm with an object label (title/stereotype/i18n key)", () => { - const input = { dterm: [{ title: "group.x.title", stereotype: ["groupmembership"] }, { isnull: [{ var: "person.dateOfDeath" }] }] }; + const input = { + dterm: [ + { title: "group.x.title", stereotype: ["groupmembership"] }, + { isnull: [{ var: "person.dateOfDeath" }] }, + ], + }; expect(stripCosmeticLabels(input)).toEqual({ isnull: [{ var: "person.dateOfDeath" }] }); }); }); describe("coerceScalars", () => { it("coerces numeric strings to numbers so int/string drift is not spurious", () => { - expect(coerceScalars({ "==": [{ var: "ctgroup.campusId" }, "1"] })) - .toEqual({ "==": [{ var: "ctgroup.campusId" }, 1] }); + expect(coerceScalars({ "==": [{ var: "ctgroup.campusId" }, "1"] })).toEqual({ + "==": [{ var: "ctgroup.campusId" }, 1], + }); }); it("coerces numeric strings inside oneof arrays but leaves non-numeric strings", () => { - expect(coerceScalars({ oneof: [{ var: "ctgroup.id" }, ["112", "8"]] })) - .toEqual({ oneof: [{ var: "ctgroup.id" }, [112, 8]] }); - expect(coerceScalars({ "==": [{ var: "groupmember.groupMemberStatus" }, "active"] })) - .toEqual({ "==": [{ var: "groupmember.groupMemberStatus" }, "active"] }); + expect(coerceScalars({ oneof: [{ var: "ctgroup.id" }, ["112", "8"]] })).toEqual({ + oneof: [{ var: "ctgroup.id" }, [112, 8]], + }); + expect(coerceScalars({ "==": [{ var: "groupmember.groupMemberStatus" }, "active"] })).toEqual({ + "==": [{ var: "groupmember.groupMemberStatus" }, "active"], + }); }); }); describe("normalizeRuleset", () => { it("drops read-only timestamps and the PUT envelope, and is idempotent", () => { - const withEnvelope = { dynamicGroupRuleSet: { description: "x", dynamicGroupUpdateStarted: "t", process: {}, query: {} } }; + const withEnvelope = { + dynamicGroupRuleSet: { description: "x", dynamicGroupUpdateStarted: "t", process: {}, query: {} }, + }; const once = normalizeRuleset(withEnvelope); expect(once).not.toHaveProperty("dynamicGroupUpdateStarted"); expect(once).not.toHaveProperty("dynamicGroupRuleSet"); @@ -450,8 +522,8 @@ describe("normalizeRuleset", () => { for (const name of ["ruleset-683", "ruleset-2022", "ruleset-1092"]) { const raw = JSON.parse(readFileSync(`tests/fixtures/dynamic/${name}.get.json`, "utf8")); // array shape const once = normalizeRuleset(raw); - expect(normalizeRuleset(once)).toEqual(once); // idempotent - expect(JSON.stringify(once)).not.toContain("dterm"); // cosmetic labels stripped + expect(normalizeRuleset(once)).toEqual(once); // idempotent + expect(JSON.stringify(once)).not.toContain("dterm"); // cosmetic labels stripped } }); }); @@ -519,7 +591,7 @@ function dropReadOnly(rule: Record): Record { /** Canonicalise a ruleset for diffing: unwrap array/PUT envelope, drop timestamps, strip labels, coerce scalars. */ export function normalizeRuleset(rule: unknown): Record { let r: unknown = rule ?? {}; - if (Array.isArray(r)) r = r[0] ?? {}; // GET returns a single-element [RuleSet] + if (Array.isArray(r)) r = r[0] ?? {}; // GET returns a single-element [RuleSet] let obj = (r ?? {}) as Record; if (obj.dynamicGroupRuleSet && typeof obj.dynamicGroupRuleSet === "object") { obj = obj.dynamicGroupRuleSet as Record; // unwrap the PUT envelope @@ -527,7 +599,10 @@ export function normalizeRuleset(rule: unknown): Record { return coerceScalars(stripCosmeticLabels(dropReadOnly(obj))) as Record; } -export interface NormalizedDynamic { status: DynamicStatus; ruleset: Record } +export interface NormalizedDynamic { + status: DynamicStatus; + ruleset: Record; +} export function normalizeDynamic(spec: { status: DynamicStatus; ruleset: unknown }): NormalizedDynamic { return { status: spec.status, ruleset: normalizeRuleset(spec.ruleset) }; @@ -551,11 +626,13 @@ git commit -m "feat(dynamic): ruleset normalizer (labels, scalars, timestamps, e ## Task 5: `dynamic` synthetic-field entry (fold + apply) **Files:** + - Modify: `src/engine/dynamic.ts` (add `resolveRulesetRef`) - Modify: `src/engine/synthetic.ts` (register the `dynamic` field) - Test: `tests/synthetic-dynamic.test.ts` **Interfaces:** + - Consumes: `normalizeDynamic`, `normalizeRuleset` (Task 4); `SyntheticField` (Task 2); `CtClient.get/request`. - Produces: a second entry in `SYNTHETIC_FIELDS` with `field: "dynamic"`. @@ -564,6 +641,7 @@ git commit -m "feat(dynamic): ruleset normalizer (labels, scalars, timestamps, e - [ ] **Step 1: Write the failing test** `tests/synthetic-dynamic.test.ts`: + ```ts import { describe, it, expect, vi } from "vitest"; import { SYNTHETIC_FIELDS, syntheticField } from "../src/engine/synthetic.js"; @@ -575,25 +653,50 @@ const dynamicField = () => syntheticField("dynamic")!; describe("dynamic synthetic field — fold", () => { it("injects normalized dynamic into desired.fields and actual for an opted-in managed group", async () => { expect(SYNTHETIC_FIELDS.some((f) => f.field === "dynamic")).toBe(true); - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const desired: DesiredResource[] = [ - { type: "group", key: "g", fields: { name: "G" }, dependsOn: [], - dynamic: { status: "manual", ruleset: { description: "x", query: {}, process: {} } } }, + { + type: "group", + key: "g", + fields: { name: "G" }, + dependsOn: [], + dynamic: { status: "manual", ruleset: { description: "x", query: {}, process: {} } }, + }, ]; - const client = { get: vi.fn(async (p: string) => - p.endsWith("/ruleset") ? { description: "x", query: {}, process: {}, dynamicGroupUpdateStarted: "t" } - : { dynamicGroupStatus: "manual" }) }; + const client = { + get: vi.fn(async (p: string) => + p.endsWith("/ruleset") + ? { description: "x", query: {}, process: {}, dynamicGroupUpdateStarted: "t" } + : { dynamicGroupStatus: "manual" }, + ), + }; const out = await dynamicField().fold({ client, state, desired, actual }); expect(out.errors).toEqual([]); - expect(actual.get("g")?.dynamic).toEqual({ status: "manual", ruleset: { description: "x", query: {}, process: {} } }); - expect(out.desired[0]?.fields.dynamic).toEqual({ status: "manual", ruleset: { description: "x", query: {}, process: {} } }); + expect(actual.get("g")?.dynamic).toEqual({ + status: "manual", + ruleset: { description: "x", query: {}, process: {} }, + }); + expect(out.desired[0]?.fields.dynamic).toEqual({ + status: "manual", + ruleset: { description: "x", query: {}, process: {} }, + }); }); it("ignores groups that did not opt into dynamic", async () => { - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const desired: DesiredResource[] = [{ type: "group", key: "g", fields: { name: "G" }, dependsOn: [] }]; const client = { get: vi.fn() }; @@ -607,21 +710,41 @@ describe("dynamic synthetic field — apply", () => { it("PUTs the wrapped ruleset then the status", async () => { const request = vi.fn(async () => ({})); const state: State = { version: 1, host: "h", resources: {} }; - await dynamicField().apply({ client: { request }, state, id: 5, - change: { field: "dynamic", from: undefined, - to: { status: "active", ruleset: { description: "x", query: {}, process: {} } } } }); - expect(request).toHaveBeenNthCalledWith(1, "PUT", "/dynamicgroups/5/ruleset", - { dynamicGroupRuleSet: { description: "x", query: {}, process: {} } }); - expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "active" }); + await dynamicField().apply({ + client: { request }, + state, + id: 5, + change: { + field: "dynamic", + from: undefined, + to: { status: "active", ruleset: { description: "x", query: {}, process: {} } }, + }, + }); + expect(request).toHaveBeenNthCalledWith(1, "PUT", "/dynamicgroups/5/ruleset", { + dynamicGroupRuleSet: { description: "x", query: {}, process: {} }, + }); + expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { + dynamicGroupStatus: "active", + }); }); it("demotes to a normal group when status is none: DELETE ruleset then status none", async () => { const request = vi.fn(async () => ({})); const state: State = { version: 1, host: "h", resources: {} }; - await dynamicField().apply({ client: { request }, state, id: 5, - change: { field: "dynamic", from: { status: "active", ruleset: {} }, to: { status: "none", ruleset: {} } } }); + await dynamicField().apply({ + client: { request }, + state, + id: 5, + change: { + field: "dynamic", + from: { status: "active", ruleset: {} }, + to: { status: "none", ruleset: {} }, + }, + }); expect(request).toHaveBeenNthCalledWith(1, "DELETE", "/dynamicgroups/5/ruleset"); - expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "none" }); + expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { + dynamicGroupStatus: "none", + }); }); }); ``` @@ -634,6 +757,7 @@ Expected: FAIL — no `dynamic` entry registered. - [ ] **Step 3: Add `resolveRulesetRef` to `dynamic.ts`** Append to `src/engine/dynamic.ts`: + ```ts import { readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; @@ -647,11 +771,13 @@ export function resolveRulesetRef(ruleset: unknown, baseDir: string = process.cw return ruleset; } ``` + (`dirname` import is for future per-config-file resolution; if unused now, omit it to satisfy `no-unused-vars`.) - [ ] **Step 4: Register the `dynamic` field in `synthetic.ts`** Add to `src/engine/synthetic.ts`: + ```ts import { normalizeDynamic, normalizeRuleset, resolveRulesetRef } from "./dynamic.js"; import type { DynamicStatus } from "./types.js"; @@ -659,7 +785,9 @@ import type { DynamicStatus } from "./types.js"; const dynamicField: SyntheticField = { field: "dynamic", async fold({ client, state, desired, actual }) { - const optedIn = new Set(desired.filter((d) => d.type === "group" && d.dynamic !== undefined).map((d) => d.key)); + const optedIn = new Set( + desired.filter((d) => d.type === "group" && d.dynamic !== undefined).map((d) => d.key), + ); if (optedIn.size === 0) return { desired, errors: [] }; const errors: string[] = []; for (const managed of Object.values(state.resources)) { @@ -668,7 +796,9 @@ const dynamicField: SyntheticField = { if (!a) continue; // vanished from CT → handled as a recreate by the plain plan try { const ruleset = await client.get>(`/dynamicgroups/${managed.id}/ruleset`); - const statusRes = await client.get<{ dynamicGroupStatus?: string }>(`/dynamicgroups/${managed.id}/status`); + const statusRes = await client.get<{ dynamicGroupStatus?: string }>( + `/dynamicgroups/${managed.id}/status`, + ); a.dynamic = { status: (statusRes?.dynamicGroupStatus ?? "none") as DynamicStatus, ruleset: normalizeRuleset(ruleset), @@ -680,7 +810,16 @@ const dynamicField: SyntheticField = { } const augmented = desired.map((d) => d.type === "group" && d.dynamic !== undefined - ? { ...d, fields: { ...d.fields, dynamic: normalizeDynamic({ status: d.dynamic.status, ruleset: resolveRulesetRef(d.dynamic.ruleset) }) } } + ? { + ...d, + fields: { + ...d.fields, + dynamic: normalizeDynamic({ + status: d.dynamic.status, + ruleset: resolveRulesetRef(d.dynamic.ruleset), + }), + }, + } : d, ); return { desired: augmented, errors }; @@ -703,6 +842,7 @@ const dynamicField: SyntheticField = { export const SYNTHETIC_FIELDS: SyntheticField[] = [parentsField, dynamicField]; ``` + (Replace the previous single-entry `SYNTHETIC_FIELDS` line.) - [ ] **Step 5: Run to verify it passes** @@ -727,16 +867,19 @@ git commit -m "feat(dynamic): synthetic-field fold + apply for ruleset & status" ## Task 6: End-to-end plan/apply + opt-in live round-trip test **Files:** + - Modify: `src/commands/apply.ts` (add `--refresh` flag → per-group `POST /dynamicgroups/{id}/refresh` after a successful apply of a changed dynamic group) - Test: `tests/dynamic.integration.test.ts` (opt-in, `CT_LIVE=1`) **Interfaces:** + - Consumes: `buildPlan`, `executePlan`, the `dynamic` synthetic field. - Produces: `ct apply --refresh` behavior; a green round-trip proof. - [ ] **Step 1: Write the opt-in live round-trip test** `tests/dynamic.integration.test.ts`: + ```ts import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; @@ -784,12 +927,15 @@ if (opts.refresh) { const id = state.resources[item.key]?.id; if (id === undefined) continue; const res = await client.request>( - "POST", `/dynamicgroups/${id}/refresh`); + "POST", + `/dynamicgroups/${id}/refresh`, + ); const r = res?.[0]; if (r) info(`refreshed ${item.key}: +${r.created} ~${r.updated} -${r.deleted}`); } } ``` + (Use the file's existing logging helper in place of `info`.) - [ ] **Step 4: Add a unit test for the refresh gating** (in `tests/apply.test.ts` or a new `tests/apply-refresh.test.ts`) asserting `POST /dynamicgroups/{id}/refresh` is called once per changed dynamic group and **never** `/dynamicgroups/refresh`, and not at all without `--refresh`. @@ -811,18 +957,26 @@ git commit -m "feat(dynamic): end-to-end apply, opt-in --refresh, live round-tri ## Task 7: Typed query DSL (Phase 2) **Files:** + - Create: `src/config/query.ts` (the `q.*` builder + resolver hook) - Modify: `src/config/context.ts` (expose `q` and a `campus()`/`group()` key→id resolver on the context, or export `q` standalone) - Test: `tests/query.test.ts` **Interfaces:** + - Produces: ```ts - export interface QueryNode { [op: string]: unknown } + export interface QueryNode { + [op: string]: unknown; + } export const q: { - and(...n: QueryNode[]): QueryNode; or(...n: QueryNode[]): QueryNode; not(n: QueryNode): QueryNode; - eq(varName: string, value: unknown): QueryNode; oneof(varName: string, values: unknown[]): QueryNode; - isnull(varName: string): QueryNode; var(name: string): QueryNode; + and(...n: QueryNode[]): QueryNode; + or(...n: QueryNode[]): QueryNode; + not(n: QueryNode): QueryNode; + eq(varName: string, value: unknown): QueryNode; + oneof(varName: string, values: unknown[]): QueryNode; + isnull(varName: string): QueryNode; + var(name: string): QueryNode; }; export function churchQuery(filter: QueryNode, opts?: { description?: string }): Record; ``` @@ -833,6 +987,7 @@ git commit -m "feat(dynamic): end-to-end apply, opt-in --refresh, live round-tri - [ ] **Step 1: Write the failing test** `tests/query.test.ts`: + ```ts import { describe, it, expect } from "vitest"; import { q, churchQuery } from "../src/config/query.js"; @@ -841,25 +996,35 @@ import { normalizeRuleset } from "../src/engine/dynamic.js"; describe("typed query builder", () => { it("builds a JSONLogic tree", () => { const tree = q.and(q.eq("ctgroup.campusId", 1), q.eq("person.isArchived", false)); - expect(tree).toEqual({ and: [ - { "==": [{ var: "ctgroup.campusId" }, 1] }, - { "==": [{ var: "person.isArchived" }, false] }, - ] }); + expect(tree).toEqual({ + and: [{ "==": [{ var: "ctgroup.campusId" }, 1] }, { "==": [{ var: "person.isArchived" }, false] }], + }); }); it("oneof and isnull", () => { - expect(q.oneof("ctgroup.groupTypeId", [1, 2])).toEqual({ oneof: [{ var: "ctgroup.groupTypeId" }, [1, 2]] }); + expect(q.oneof("ctgroup.groupTypeId", [1, 2])).toEqual({ + oneof: [{ var: "ctgroup.groupTypeId" }, [1, 2]], + }); expect(q.isnull("person.isArchived")).toEqual({ isnull: [{ var: "person.isArchived" }] }); }); it("churchQuery wraps the filter in the ChurchQuery envelope", () => { const cq = churchQuery(q.eq("ctgroup.campusId", 1), { description: "Mainz" }); - expect(cq).toEqual({ description: "Mainz", method: "ChurchQuery", params: { filter: { "==": [{ var: "ctgroup.campusId" }, 1] } } }); + expect(cq).toEqual({ + description: "Mainz", + method: "ChurchQuery", + params: { filter: { "==": [{ var: "ctgroup.campusId" }, 1] } }, + }); }); it("a built ruleset normalizes stably (matches read-back normalization)", () => { - const ruleset = { description: "x", importance: 0, personIdFieldName: "id", - process: {}, query: churchQuery(q.eq("ctgroup.campusId", 1)) }; + const ruleset = { + description: "x", + importance: 0, + personIdFieldName: "id", + process: {}, + query: churchQuery(q.eq("ctgroup.campusId", 1)), + }; expect(normalizeRuleset(normalizeRuleset(ruleset))).toEqual(normalizeRuleset(ruleset)); }); }); @@ -878,7 +1043,9 @@ Expected: FAIL — module missing. * tree that lives inside the ChurchQuery `params.filter`. `var` values are raw * ChurchTools ids — resolve keys → ids at config-build time and pass the number. */ -export interface QueryNode { [op: string]: unknown } +export interface QueryNode { + [op: string]: unknown; +} export const q = { and: (...n: QueryNode[]): QueryNode => ({ and: n }), @@ -917,6 +1084,7 @@ git commit -m "feat(dynamic): typed query builder + ChurchQuery envelope (Phase ## Task 8: Docs + example config **Files:** + - Modify: `README.md` (add an "Auto-groups" subsection under usage/status) - Create: `docs/dynamic-groups.md` (the full feature guide) - Create: `examples/dynamic-group.config.ts` (a runnable example config) @@ -932,13 +1100,19 @@ import { q, churchQuery } from "../src/config/query.js"; // or the published imp export default (ct: any) => { ct.campus({ key: "mainz", name: "Mainz", shorty: "MZ" }); ct.group({ - key: "all_mainz", name: "Alle Mainz", groupTypeId: 1, + key: "all_mainz", + name: "Alle Mainz", + groupTypeId: 1, dynamic: { status: "manual", ruleset: { description: "Alle aktiven Personen in Mainz", // description lives on the ruleset, NOT inside query - importance: 0, personIdFieldName: "person.id", process: {}, - query: churchQuery(q.and(q.eq("ctgroup.campusId", 0 /* mainz id */), q.eq("person.isArchived", false))), + importance: 0, + personIdFieldName: "person.id", + process: {}, + query: churchQuery( + q.and(q.eq("ctgroup.campusId", 0 /* mainz id */), q.eq("person.isArchived", false)), + ), }, }, }); @@ -965,6 +1139,7 @@ git commit -m "docs(dynamic): auto-groups guide + runnable example config" ## Self-Review **Spec coverage (against the #14 section of the design spec):** + - DSL `dynamic` block on a group → Task 3. ✓ - `ct plan` shows ruleset/status create/update/delete with a normalizer avoiding cosmetic false diffs → Tasks 4 (normalizer) + 5 (fold makes it a diffable field). ✓ - `ct apply` writes ruleset + status in the right order (group first); re-run is a no-op → Task 5 (apply order) + group tier 1 means the group item owns the write, exactly like `parents` (owner exists before the synthetic write). ✓ diff --git a/docs/superpowers/plans/2026-07-08-blueprints.md b/docs/superpowers/plans/2026-07-08-blueprints.md index 6eba4c3..f527121 100644 --- a/docs/superpowers/plans/2026-07-08-blueprints.md +++ b/docs/superpowers/plans/2026-07-08-blueprints.md @@ -9,6 +9,7 @@ **Tech Stack:** TypeScript (NodeNext ESM, `.js` specifiers), Vitest. ## Global Constraints + - Node ≥ 20; ESM NodeNext — all relative imports use `.js` specifiers. - Add no new runtime dependency. - People are never managed. @@ -16,6 +17,7 @@ - The example is verified by config-LOAD only (no live `plan`/`apply`; the machine is on production). ## Context from #14 / #13 (already merged on this branch) + - `dynamic: { status, ruleset }` block on a group; `q`/`churchQuery` typed query builder re-exported from `src/config/context.js`. - `ct.groupTypeRole({ key, id, grants })` with `module:right` grant names; `ct.groupRole({ key, id, grants })`. - `parents: [groupKey]` opt-in hierarchy; `evaluateConfig(mod) → { resources, permissions }`; `orderKeys(resources)` (`src/engine/graph.ts`) topologically orders by tier then declaration order. @@ -25,9 +27,11 @@ ## Task 1: Example campus blueprint config **Files:** + - Create: `examples/campus-blueprint.config.ts` **Interfaces:** + - Produces: a default-exported `(ct) => void` config that defines a `kidsArea(campus)` blueprint and instantiates it across two campuses. Consumed by Task 2's test and by users as a reference. - [ ] **Step 1: Write the blueprint example** @@ -47,7 +51,11 @@ const CAMPUSES = ["mainz", "berlin"] as const; function kidsArea(ct: ConfigContext, campus: string): void { const lead = `${campus}_kids_lead`; ct.group({ key: lead, name: `${campus} · Kids Leitung`, groupTypeId: 2, parents: [] }); - for (const [suffix, label] of [["0_3", "0–3"], ["4_6", "4–6"], ["checkin", "Check-in"]] as const) { + for (const [suffix, label] of [ + ["0_3", "0–3"], + ["4_6", "4–6"], + ["checkin", "Check-in"], + ] as const) { ct.group({ key: `${campus}_kids_${suffix}`, name: `${campus} · Kids ${label}`, @@ -80,16 +88,22 @@ export default (ct: ConfigContext): void => { kidsArea(ct, campus); } // A permission grant (#13) on a shared group-type-role template — id is an illustrative placeholder. - ct.groupTypeRole({ key: "kids_lead_tpl", id: 2, grants: ["churchgroup:view group", "churchgroup:edit group members"] }); + ct.groupTypeRole({ + key: "kids_lead_tpl", + id: 2, + grants: ["churchgroup:view group", "churchgroup:edit group members"], + }); }; ``` - [ ] **Step 2: Verify it loads (config-load only — NO live plan/apply)** Run: + ```bash npx tsx -e "import('./src/config/load.js').then(m=>m.loadConfig('examples/campus-blueprint.config.ts')).then(r=>console.log('resources',r.resources.length,'permissions',r.permissions.length)).catch(e=>{console.error(e.message);process.exit(1)})" ``` + Expected: `resources 12 permissions 1` (2 campuses + 2×5 groups [lead + 3 teams + 1 dynamic] = 12 resources; 1 permission). No load/validation error. Do NOT run `ct plan`/`ct apply` (production login). - [ ] **Step 3: Commit** @@ -104,9 +118,11 @@ git commit -m "feat(blueprints): example parametrized campus blueprint (kids are ## Task 2: Ordered-plan test (the DoD lock) **Files:** + - Create: `tests/blueprint.test.ts` **Interfaces:** + - Consumes: `evaluateConfig` (`src/config/context.js`), `orderKeys` (`src/engine/graph.js`), and the Task 1 blueprint pattern (the test defines its own inline blueprint so it does not depend on the example file's exact ids). - [ ] **Step 1: Write the test** @@ -137,7 +153,7 @@ describe("campus blueprint", () => { const order = orderKeys(resources); const pos = (k: string) => order.indexOf(k); for (const c of ["mainz", "berlin"]) { - expect(pos(c)).toBeLessThan(pos(`${c}_lead`)); // campus (tier 0) before its groups (tier 1) + expect(pos(c)).toBeLessThan(pos(`${c}_lead`)); // campus (tier 0) before its groups (tier 1) expect(pos(`${c}_lead`)).toBeLessThan(pos(`${c}_team`)); // parent before child (intra-tier dependency) } }); @@ -168,6 +184,7 @@ git commit -m "test(blueprints): lock ordered-plan guarantee for a blueprint acr ## Task 3: Docs + README **Files:** + - Create: `docs/blueprints.md` - Modify: `README.md` @@ -191,6 +208,7 @@ git commit -m "docs(blueprints): parametrized campus blueprint guide; Phase 5 co ## Self-Review **Spec coverage (#7 design + DoD):** + - Blueprint abstraction (TS function parametrized by campus) → Task 1 (`kidsArea`), Task 2 (inline blueprint). ✓ - Reusable building blocks shared across blueprints → the `kidsArea` function + shared group type/status ids; no new abstraction needed (YAGNI). ✓ - Auto-groups inside a blueprint → Task 1 (`dynamic` block in `kidsArea`). ✓ (dynamic reconciliation idempotency is covered by #14's tests + normalizer.) diff --git a/docs/superpowers/plans/2026-07-08-permissions.md b/docs/superpowers/plans/2026-07-08-permissions.md index dcac188..6973f89 100644 --- a/docs/superpowers/plans/2026-07-08-permissions.md +++ b/docs/superpowers/plans/2026-07-08-permissions.md @@ -34,21 +34,29 @@ ## Task 1: Catalog resolver **Files:** + - Create: `src/permissions/catalog.ts` - Test: `tests/permission-catalog.test.ts` **Interfaces:** + - Produces: ```ts - export interface CatalogEntry { authId: number; scopeField: string | null; revocable: boolean; desc: string } - export function loadCatalog(): Record; // reads catalog.json once (memoized) - export function resolveAuthId(name: string): CatalogEntry; // throws with suggestions if unknown + export interface CatalogEntry { + authId: number; + scopeField: string | null; + revocable: boolean; + desc: string; + } + export function loadCatalog(): Record; // reads catalog.json once (memoized) + export function resolveAuthId(name: string): CatalogEntry; // throws with suggestions if unknown ``` - Consumes: the committed `src/permissions/catalog.json`. - [ ] **Step 1: Write the failing test** `tests/permission-catalog.test.ts`: + ```ts import { describe, it, expect } from "vitest"; import { resolveAuthId, loadCatalog } from "../src/permissions/catalog.js"; @@ -65,7 +73,9 @@ describe("permission catalog", () => { expect(e.scopeField).toBe("cdb_gruppe"); }); it("throws a helpful error for an unknown right", () => { - expect(() => resolveAuthId("churchgroup:no such right")).toThrow(/unknown permission "churchgroup:no such right"/i); + expect(() => resolveAuthId("churchgroup:no such right")).toThrow( + /unknown permission "churchgroup:no such right"/i, + ); }); it("loads the whole catalog (187 rights)", () => { expect(Object.keys(loadCatalog()).length).toBeGreaterThanOrEqual(180); @@ -90,7 +100,12 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -export interface CatalogEntry { authId: number; scopeField: string | null; revocable: boolean; desc: string } +export interface CatalogEntry { + authId: number; + scopeField: string | null; + revocable: boolean; + desc: string; +} let cache: Record | null = null; @@ -105,13 +120,16 @@ export function resolveAuthId(name: string): CatalogEntry { const entry = loadCatalog()[name]; if (!entry) { const [mod] = name.split(":"); - const near = Object.keys(loadCatalog()).filter((k) => k.startsWith(`${mod}:`)).slice(0, 6); + const near = Object.keys(loadCatalog()) + .filter((k) => k.startsWith(`${mod}:`)) + .slice(0, 6); const hint = near.length ? ` Did you mean one of: ${near.join(", ")}?` : ""; throw new Error(`Unknown permission "${name}".${hint}`); } return entry; } ``` + (`catalog.ts` compiles to `dist/permissions/`; ensure the build copies `catalog.json` next to it — see Task 8's build note. During `tsx`/vitest it reads from `src/permissions/`.) - [ ] **Step 4: Run to verify it passes** @@ -131,25 +149,41 @@ git commit -m "feat(permissions): catalog resolver (name→authId, scope, revoca ## Task 2: Grant-tuple model + set reconciliation **Files:** + - Create: `src/permissions/grants.ts` - Test: `tests/permission-grants.test.ts` **Interfaces:** + - Produces: ```ts export type DomainType = "group_role" | "group_type_role"; - export interface GrantTuple { authId: number; dataId: number[]; type: "grant" | "revoke" } // dataId sorted; [] = unscoped - export interface RawPermission { authId: number; dataId: number | null; type: "grant" | "revoke"; isInherited?: boolean; meta?: { modifiedPid?: number } } - export function tupleKey(t: { authId: number; dataId: number[]; type: string }): string; // stable identity - export function normalizeActual(rows: RawPermission[]): GrantTuple[]; // exclude baseline+inherited; scalar dataId→[] - export interface GrantDiff { toPut: GrantTuple[]; toDelete: GrantTuple[] } - export function diffGrants(desired: GrantTuple[], actual: GrantTuple[]): GrantDiff; // set reconciliation + export interface GrantTuple { + authId: number; + dataId: number[]; + type: "grant" | "revoke"; + } // dataId sorted; [] = unscoped + export interface RawPermission { + authId: number; + dataId: number | null; + type: "grant" | "revoke"; + isInherited?: boolean; + meta?: { modifiedPid?: number }; + } + export function tupleKey(t: { authId: number; dataId: number[]; type: string }): string; // stable identity + export function normalizeActual(rows: RawPermission[]): GrantTuple[]; // exclude baseline+inherited; scalar dataId→[] + export interface GrantDiff { + toPut: GrantTuple[]; + toDelete: GrantTuple[]; + } + export function diffGrants(desired: GrantTuple[], actual: GrantTuple[]): GrantDiff; // set reconciliation ``` - Consumes: nothing from other tasks. - [ ] **Step 1: Write the failing test** `tests/permission-grants.test.ts`: + ```ts import { describe, it, expect } from "vitest"; import { normalizeActual, diffGrants, tupleKey } from "../src/permissions/grants.js"; @@ -160,7 +194,7 @@ describe("normalizeActual", () => { { authId: 1104, dataId: 3, type: "grant" as const, meta: { modifiedPid: 1 } }, { authId: 1101, dataId: null, type: "grant" as const, meta: { modifiedPid: 1 } }, { authId: 9999, dataId: 1, type: "grant" as const, meta: { modifiedPid: -1 } }, // system baseline → excluded - { authId: 8888, dataId: 1, type: "grant" as const, isInherited: true }, // inherited → excluded + { authId: 8888, dataId: 1, type: "grant" as const, isInherited: true }, // inherited → excluded ]; expect(normalizeActual(rows)).toEqual([ { authId: 1104, dataId: [3], type: "grant" }, @@ -173,11 +207,11 @@ describe("diffGrants", () => { it("adds missing, deletes extra, no-ops identical (order-independent dataId)", () => { const desired = [ { authId: 1104, dataId: [7, 3], type: "grant" as const }, // present but reordered - { authId: 1101, dataId: [], type: "grant" as const }, // new + { authId: 1101, dataId: [], type: "grant" as const }, // new ]; const actual = [ { authId: 1104, dataId: [3, 7], type: "grant" as const }, // same tuple, different order - { authId: 2000, dataId: [], type: "grant" as const }, // extra → delete + { authId: 2000, dataId: [], type: "grant" as const }, // extra → delete ]; const d = diffGrants(desired, actual); expect(d.toPut.map(tupleKey)).toEqual([tupleKey({ authId: 1101, dataId: [], type: "grant" })]); @@ -201,10 +235,17 @@ Expected: FAIL — module missing. */ export type DomainType = "group_role" | "group_type_role"; -export interface GrantTuple { authId: number; dataId: number[]; type: "grant" | "revoke" } +export interface GrantTuple { + authId: number; + dataId: number[]; + type: "grant" | "revoke"; +} export interface RawPermission { - authId: number; dataId: number | null; type: "grant" | "revoke"; - isInherited?: boolean; meta?: { modifiedPid?: number }; + authId: number; + dataId: number | null; + type: "grant" | "revoke"; + isInherited?: boolean; + meta?: { modifiedPid?: number }; } export function tupleKey(t: { authId: number; dataId: number[]; type: string }): string { @@ -215,14 +256,17 @@ export function normalizeActual(rows: RawPermission[]): GrantTuple[] { const out: GrantTuple[] = []; for (const r of rows) { if (r.meta?.modifiedPid === -1) continue; // system baseline — invisible to reconciliation - if (r.isInherited) continue; // inherited — not directly owned here + if (r.isInherited) continue; // inherited — not directly owned here const dataId = r.dataId == null ? [] : [r.dataId]; out.push({ authId: r.authId, dataId: dataId.sort((a, b) => a - b), type: r.type }); } return out; } -export interface GrantDiff { toPut: GrantTuple[]; toDelete: GrantTuple[] } +export interface GrantDiff { + toPut: GrantTuple[]; + toDelete: GrantTuple[]; +} export function diffGrants(desired: GrantTuple[], actual: GrantTuple[]): GrantDiff { const desiredKeys = new Map(desired.map((t) => [tupleKey(t), t])); @@ -250,16 +294,23 @@ git commit -m "feat(permissions): grant-tuple model + set reconciliation (baseli ## Task 3: DSL — `ct.groupRole` / `ct.groupTypeRole` **Files:** + - Modify: `src/config/context.ts` (add the two declarations) - Create: `src/permissions/types.ts` (the desired-permission shape) - Test: `tests/context.test.ts` (extend) **Interfaces:** + - Produces: ```ts // src/permissions/types.ts export type Grant = string | { right: string; scope: string[] }; - export interface DesiredPermission { key: string; domainType: DomainType; domainId: number; grants: Grant[] } + export interface DesiredPermission { + key: string; + domainType: DomainType; + domainId: number; + grants: Grant[]; + } // ConfigContext gains: groupRole(input), groupTypeRole(input) // evaluateConfig now returns BOTH resource declarations and permission declarations. ``` @@ -273,10 +324,11 @@ git commit -m "feat(permissions): grant-tuple model + set reconciliation (baseli describe("permission declarations", () => { it("collects groupRole / groupTypeRole with validated grants", async () => { const mod = (ct: any) => { - ct.groupTypeRole({ key: "leiter_tpl", id: 8, grants: [ - "churchgroup:view group", - { right: "churchdb:view group", scope: ["kids_area"] }, - ]}); + ct.groupTypeRole({ + key: "leiter_tpl", + id: 8, + grants: ["churchgroup:view group", { right: "churchdb:view group", scope: ["kids_area"] }], + }); ct.groupRole({ key: "kids_lead", id: 2882, grants: ["churchgroup:edit group members"] }); }; const { permissions } = await evaluateConfig(mod); // evaluateConfig now returns {resources, permissions} @@ -285,13 +337,16 @@ describe("permission declarations", () => { expect(permissions[1]).toMatchObject({ key: "kids_lead", domainType: "group_role", domainId: 2882 }); }); it("rejects a non-numeric id and an empty right name", async () => { - await expect(evaluateConfig((ct: any) => ct.groupRole({ key: "x", id: "nope", grants: [] }))) - .rejects.toThrow(/id.*number/i); - await expect(evaluateConfig((ct: any) => ct.groupRole({ key: "x", id: 1, grants: [""] }))) - .rejects.toThrow(/grant/i); + await expect( + evaluateConfig((ct: any) => ct.groupRole({ key: "x", id: "nope", grants: [] })), + ).rejects.toThrow(/id.*number/i); + await expect( + evaluateConfig((ct: any) => ct.groupRole({ key: "x", id: 1, grants: [""] })), + ).rejects.toThrow(/grant/i); }); }); ``` + (Adjust the existing `evaluateConfig` import/usages in this file to the new `{ resources, permissions }` return.) - [ ] **Step 2: Run to verify it fails** @@ -304,8 +359,10 @@ Expected: FAIL — `groupRole`/`groupTypeRole` not defined; `evaluateConfig` ret Create `src/permissions/types.ts` with the interfaces above. In `src/config/context.ts`: + - Add a `permissions: DesiredPermission[]` accumulator alongside `resources` in `createContext`. - Add `groupRole` and `groupTypeRole` to `ConfigContext` and the returned `ct`: + ```ts const definePermission = (domainType: DomainType) => (input: { key: string; id: number; grants: Grant[] }): void => { if (typeof input.key !== "string" || !input.key) throw new Error(`${domainType} declaration missing a string "key".`); @@ -324,7 +381,9 @@ In `src/config/context.ts`: groupRole: definePermission("group_role"), groupTypeRole: definePermission("group_type_role"), ``` + (Share the existing `seen` set so keys stay globally unique across resources and permissions.) + - Change `createContext` to return `{ ct, resources, permissions }` and `evaluateConfig` to return `{ resources, permissions }`. Update `validateReferences` call site accordingly. In `src/config/load.ts`: `loadConfig` returns `{ resources, permissions }`. Update `src/commands/plan.ts` and `src/commands/apply.ts` to destructure (they currently do `const desired = await loadConfig(...)`) — thread `permissions` through (used in Task 5/6). Keep existing resource behavior identical. @@ -346,13 +405,15 @@ git commit -m "feat(permissions): groupRole/groupTypeRole DSL + threaded permiss ## Task 4: Scope resolver (scope keys → dataId[]) **Files:** + - Create: `src/permissions/scope.ts` - Test: `tests/permission-scope.test.ts` **Interfaces:** + - Produces: ```ts - export function resolveScope(scopeKeys: string[], state: State): number[]; // logical group keys → group ids, sorted + export function resolveScope(scopeKeys: string[], state: State): number[]; // logical group keys → group ids, sorted ``` - Consumes: `State` (`src/state/state.js`). @@ -361,15 +422,20 @@ git commit -m "feat(permissions): groupRole/groupTypeRole DSL + threaded permiss - [ ] **Step 1: Write the failing test** `tests/permission-scope.test.ts`: + ```ts import { describe, it, expect } from "vitest"; import { resolveScope } from "../src/permissions/scope.js"; import type { State } from "../src/state/state.js"; -const state: State = { version: 1, host: "h", resources: { - kids_area: { type: "group", id: 42, key: "kids_area", fields: {}, adoptedAt: "t", updatedAt: "t" }, - other: { type: "group", id: 7, key: "other", fields: {}, adoptedAt: "t", updatedAt: "t" }, -}}; +const state: State = { + version: 1, + host: "h", + resources: { + kids_area: { type: "group", id: 42, key: "kids_area", fields: {}, adoptedAt: "t", updatedAt: "t" }, + other: { type: "group", id: 7, key: "other", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, +}; describe("resolveScope", () => { it("maps managed group keys to sorted ids", () => { @@ -397,7 +463,9 @@ export function resolveScope(scopeKeys: string[], state: State): number[] { for (const key of scopeKeys) { const m = state.resources[key]; if (!m || m.type !== "group") { - throw new Error(`Scope key "${key}" does not resolve to a managed group. Declare/adopt it, or use a group already under management.`); + throw new Error( + `Scope key "${key}" does not resolve to a managed group. Declare/adopt it, or use a group already under management.`, + ); } ids.push(m.id); } @@ -422,21 +490,31 @@ git commit -m "feat(permissions): scope-key → group dataId resolver" ## Task 5: Build the permission plan (desired tuples + actuals + diff + validation) **Files:** + - Create: `src/permissions/plan.ts` - Test: `tests/permission-plan.test.ts` **Interfaces:** + - Produces: ```ts - export interface PermissionPlanItem { key: string; domainType: DomainType; domainId: number; diff: GrantDiff } - export function desiredTuples(p: DesiredPermission, state: State): GrantTuple[]; // resolve names+scope, validate domain rules + export interface PermissionPlanItem { + key: string; + domainType: DomainType; + domainId: number; + diff: GrantDiff; + } + export function desiredTuples(p: DesiredPermission, state: State): GrantTuple[]; // resolve names+scope, validate domain rules export async function buildPermissionPlan( - client: Pick, state: State, permissions: DesiredPermission[], + client: Pick, + state: State, + permissions: DesiredPermission[], ): Promise<{ items: PermissionPlanItem[]; fetchErrors: string[] }>; ``` - Consumes: `resolveAuthId` (T1), `normalizeActual`/`diffGrants`/`GrantTuple` (T2), `resolveScope` (T4), `DesiredPermission`/`DomainType` (T3). **Domain-rule validation in `desiredTuples`** (throw a clear error): + - A scoped grant (`{right, scope}`) requires the catalog entry's `scopeField` to be non-null; a bare-string grant on a right whose `scopeField` is non-null is allowed (means "unscoped/all") — permitted. - `type: "revoke"` is only produced for `group_role` (MVP only emits `grant`; revoke support is a later extension — do NOT emit revoke here, but keep the tuple `type` field). - `group_type_role` + `authId >= 10000` → throw (write rule: those domains require authId < 10000). @@ -446,44 +524,77 @@ git commit -m "feat(permissions): scope-key → group dataId resolver" - [ ] **Step 1: Write the failing test** `tests/permission-plan.test.ts`: + ```ts import { describe, it, expect, vi } from "vitest"; import { desiredTuples, buildPermissionPlan } from "../src/permissions/plan.js"; import type { State } from "../src/state/state.js"; -const state: State = { version: 1, host: "h", resources: { - kids_area: { type: "group", id: 42, key: "kids_area", fields: {}, adoptedAt: "t", updatedAt: "t" }, -}}; +const state: State = { + version: 1, + host: "h", + resources: { + kids_area: { type: "group", id: 42, key: "kids_area", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, +}; describe("desiredTuples", () => { it("resolves names and scope to tuples", () => { const tuples = desiredTuples( - { key: "t", domainType: "group_type_role", domainId: 8, grants: [ - "churchgroup:view group", // authId 1104, unscoped - { right: "churchgroup:view group", scope: ["kids_area"] }, // authId 1104, dataId [42] - ]}, state); + { + key: "t", + domainType: "group_type_role", + domainId: 8, + grants: [ + "churchgroup:view group", // authId 1104, unscoped + { right: "churchgroup:view group", scope: ["kids_area"] }, // authId 1104, dataId [42] + ], + }, + state, + ); expect(tuples).toEqual([ { authId: 1104, dataId: [], type: "grant" }, { authId: 1104, dataId: [42], type: "grant" }, ]); }); it("rejects authId >= 10000 on group_type_role", () => { - expect(() => desiredTuples({ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchdb:+see persons"] }, state)) - .toThrow(/10000/); // churchdb:+see persons is authId 10101 + expect(() => + desiredTuples( + { key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchdb:+see persons"] }, + state, + ), + ).toThrow(/10000/); // churchdb:+see persons is authId 10101 }); }); describe("buildPermissionPlan", () => { it("diffs desired vs actual (bulk fetch filtered to managed domainIds)", async () => { - const client = { get: vi.fn(async () => [ - { domainType: "group_type_role", domainId: 8, authId: 1104, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, - { domainType: "group_type_role", domainId: 99, authId: 1, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, // unmanaged domainId → ignored - ]) }; - const { items, fetchErrors } = await buildPermissionPlan(client as never, state, - [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:view group"] }]); + const client = { + get: vi.fn(async () => [ + { + domainType: "group_type_role", + domainId: 8, + authId: 1104, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, + { + domainType: "group_type_role", + domainId: 99, + authId: 1, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, // unmanaged domainId → ignored + ]), + }; + const { items, fetchErrors } = await buildPermissionPlan(client as never, state, [ + { key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:view group"] }, + ]); expect(fetchErrors).toEqual([]); - expect(items[0].diff.toPut).toEqual([]); // 1104 unscoped already present - expect(items[0].diff.toDelete).toEqual([]); // domainId 99 is unmanaged → invisible + expect(items[0].diff.toPut).toEqual([]); // 1104 unscoped already present + expect(items[0].diff.toDelete).toEqual([]); // domainId 99 is unmanaged → invisible }); }); ``` @@ -501,17 +612,31 @@ import { CtApiError } from "../api/ctClient.js"; import type { State } from "../state/state.js"; import { resolveAuthId } from "./catalog.js"; import { resolveScope } from "./scope.js"; -import { normalizeActual, diffGrants, type GrantTuple, type GrantDiff, type DomainType, type RawPermission } from "./grants.js"; +import { + normalizeActual, + diffGrants, + type GrantTuple, + type GrantDiff, + type DomainType, + type RawPermission, +} from "./grants.js"; import type { DesiredPermission } from "./types.js"; -export interface PermissionPlanItem { key: string; domainType: DomainType; domainId: number; diff: GrantDiff } +export interface PermissionPlanItem { + key: string; + domainType: DomainType; + domainId: number; + diff: GrantDiff; +} export function desiredTuples(p: DesiredPermission, state: State): GrantTuple[] { return p.grants.map((g) => { const name = typeof g === "string" ? g : g.right; const entry = resolveAuthId(name); - if ((p.domainType === "group_type_role") && entry.authId >= 10000) { - throw new Error(`${p.domainType} "${p.key}": "${name}" (authId ${entry.authId}) is not writable — ${p.domainType} requires authId < 10000.`); + if (p.domainType === "group_type_role" && entry.authId >= 10000) { + throw new Error( + `${p.domainType} "${p.key}": "${name}" (authId ${entry.authId}) is not writable — ${p.domainType} requires authId < 10000.`, + ); } const dataId = typeof g === "string" ? [] : resolveScope(g.scope, state); return { authId: entry.authId, dataId, type: "grant" as const }; @@ -519,7 +644,9 @@ export function desiredTuples(p: DesiredPermission, state: State): GrantTuple[] } export async function buildPermissionPlan( - client: Pick, state: State, permissions: DesiredPermission[], + client: Pick, + state: State, + permissions: DesiredPermission[], ): Promise<{ items: PermissionPlanItem[]; fetchErrors: string[] }> { const items: PermissionPlanItem[] = []; const fetchErrors: string[] = []; @@ -538,11 +665,17 @@ export async function buildPermissionPlan( const all = byType.get(p.domainType); if (all == null) continue; // fetch failed for this domainType — recorded above const actual = normalizeActual(all.filter((r) => (r as { domainId?: number }).domainId === p.domainId)); - items.push({ key: p.key, domainType: p.domainType, domainId: p.domainId, diff: diffGrants(desiredTuples(p, state), actual) }); + items.push({ + key: p.key, + domainType: p.domainType, + domainId: p.domainId, + diff: diffGrants(desiredTuples(p, state), actual), + }); } return { items, fetchErrors }; } ``` + (Add `domainId` to `RawPermission` in `grants.ts` — `domainId: number` — so the filter typechecks.) - [ ] **Step 4: Run to verify it passes** @@ -562,15 +695,20 @@ git commit -m "feat(permissions): build permission plan (tuples, bulk actuals, m ## Task 6: Apply permissions + render + wire into commands + catalog helper **Files:** + - Create: `src/permissions/apply.ts`, `src/permissions/render.ts` - Modify: `src/commands/plan.ts`, `src/commands/apply.ts` (render + execute the permission plan) - Modify: `src/commands/get.ts` (add `permissions-catalog`) - Test: `tests/permission-apply.test.ts` **Interfaces:** + - Produces: ```ts - export async function applyPermissionPlan(items: PermissionPlanItem[], client: Pick): Promise<{ granted: number; deleted: number }>; + export async function applyPermissionPlan( + items: PermissionPlanItem[], + client: Pick, + ): Promise<{ granted: number; deleted: number }>; export function renderPermissionPlan(items: PermissionPlanItem[]): string; ``` - Consumes: `PermissionPlanItem` (T5), `assertNotPeople`. @@ -582,6 +720,7 @@ git commit -m "feat(permissions): build permission plan (tuples, bulk actuals, m - [ ] **Step 1: Write the failing test** `tests/permission-apply.test.ts`: + ```ts import { describe, it, expect, vi } from "vitest"; import { applyPermissionPlan } from "../src/permissions/apply.js"; @@ -589,17 +728,37 @@ import { applyPermissionPlan } from "../src/permissions/apply.js"; describe("applyPermissionPlan", () => { it("PUTs each grant and DELETEs each removed tuple with the array dataId body", async () => { const request = vi.fn(async () => ({})); - const res = await applyPermissionPlan([{ - key: "t", domainType: "group_type_role", domainId: 8, - diff: { - toPut: [{ authId: 1104, dataId: [42], type: "grant" }, { authId: 1101, dataId: [], type: "grant" }], - toDelete: [{ authId: 2000, dataId: [], type: "grant" }], - }, - }], { request } as never); + const res = await applyPermissionPlan( + [ + { + key: "t", + domainType: "group_type_role", + domainId: 8, + diff: { + toPut: [ + { authId: 1104, dataId: [42], type: "grant" }, + { authId: 1101, dataId: [], type: "grant" }, + ], + toDelete: [{ authId: 2000, dataId: [], type: "grant" }], + }, + }, + ], + { request } as never, + ); expect(res).toEqual({ granted: 2, deleted: 1 }); - expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { authId: 1104, dataId: [42], type: "grant" }); - expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { authId: 1101, type: "grant" }); // no dataId when unscoped - expect(request).toHaveBeenCalledWith("DELETE", "/permissions/group_type_role/8", { authId: 2000, type: "grant" }); + expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { + authId: 1104, + dataId: [42], + type: "grant", + }); + expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { + authId: 1101, + type: "grant", + }); // no dataId when unscoped + expect(request).toHaveBeenCalledWith("DELETE", "/permissions/group_type_role/8", { + authId: 2000, + type: "grant", + }); }); }); ``` @@ -625,18 +784,27 @@ function body(t: GrantTuple): Record { } export async function applyPermissionPlan( - items: PermissionPlanItem[], client: Pick, + items: PermissionPlanItem[], + client: Pick, ): Promise<{ granted: number; deleted: number }> { - let granted = 0, deleted = 0; + let granted = 0, + deleted = 0; for (const item of items) { const path = `/permissions/${item.domainType}/${item.domainId}`; assertNotPeople(path); - for (const t of item.diff.toPut) { await client.request("PUT", path, body(t)); granted++; } - for (const t of item.diff.toDelete) { await client.request("DELETE", path, body(t)); deleted++; } + for (const t of item.diff.toPut) { + await client.request("PUT", path, body(t)); + granted++; + } + for (const t of item.diff.toDelete) { + await client.request("DELETE", path, body(t)); + deleted++; + } } return { granted, deleted }; } ``` + `render.ts`: format each item as `group_type_role #8: +2 grant(s), -1 revoke(s)` with per-tuple lines (reuse `picocolors` like `engine/render.ts`). - [ ] **Step 4: Run to verify it passes** @@ -667,11 +835,12 @@ git commit -m "feat(permissions): apply grants (PUT/DELETE) + plan/apply wiring ## Task 7: Live round-trip + domain-rule tests (gated, DEV-only) **Files:** + - Test: `tests/permission.integration.test.ts` (opt-in, `CT_LIVE=1`, **DEV instance only**) - [ ] **Step 1: Write the gated integration test** -`describe.runIf(process.env.CT_LIVE === "1")`: against a **dev** instance, pick a disposable `group_type_role` domainId (`CT_PERM_FIXTURE_ID`), read its grants, and assert `buildPermissionPlan` for a config that declares its *current* user-authored grants is a no-op (`toPut`/`toDelete` empty) — i.e. read→diff is drift-free. Do NOT write in the read-only assertion. A second, explicitly-guarded block may PUT+DELETE a single throwaway grant and assert idempotency, but only when `CT_LIVE_WRITE=1` AND the host is not production. +`describe.runIf(process.env.CT_LIVE === "1")`: against a **dev** instance, pick a disposable `group_type_role` domainId (`CT_PERM_FIXTURE_ID`), read its grants, and assert `buildPermissionPlan` for a config that declares its _current_ user-authored grants is a no-op (`toPut`/`toDelete` empty) — i.e. read→diff is drift-free. Do NOT write in the read-only assertion. A second, explicitly-guarded block may PUT+DELETE a single throwaway grant and assert idempotency, but only when `CT_LIVE_WRITE=1` AND the host is not production. - [ ] **Step 2: Confirm it SKIPS by default** @@ -690,6 +859,7 @@ git commit -m "test(permissions): gated DEV-only round-trip + domain-rule checks ## Task 8: Build asset copy, docs, example **Files:** + - Modify: `package.json` (ensure `catalog.json` ships to `dist/permissions/`) or `tsup.config` — verify the built `ct` binary can read the catalog. If tsup doesn't copy JSON, add a copy step or `import` the JSON so it's bundled. **Verify `node dist/index.js get permissions-catalog` works after build.** - Create: `docs/permissions.md` - Create: `examples/permissions.config.ts` @@ -719,6 +889,7 @@ git commit -m "docs(permissions): guide + example; ensure catalog ships in build ## Self-Review **Spec coverage (#13 section of the design spec):** + - DSL declares named grants on `group_role`/`group_type_role`; names resolve to authId → Tasks 1, 3, 5. ✓ - `ct plan` shows accurate grant create/delete diffs vs actual; unmanaged domain objects invisible → Tasks 5 (managed-guard), 6 (render/wiring). ✓ - `ct apply` reconciles via PUT/DELETE; re-run is a no-op → Tasks 2 (set reconciliation), 6 (apply). ✓ diff --git a/docs/superpowers/specs/2026-07-07-phase-4-apply-destroy-design.md b/docs/superpowers/specs/2026-07-07-phase-4-apply-destroy-design.md index 90aee5b..feacc95 100644 --- a/docs/superpowers/specs/2026-07-07-phase-4-apply-destroy-design.md +++ b/docs/superpowers/specs/2026-07-07-phase-4-apply-destroy-design.md @@ -26,7 +26,7 @@ each successful action (crash-safe / resumable). 2. **Destroy-protection = a config `preventDestroy` lifecycle flag** plus a typed confirmation. A targeted destroy of a flagged resource hard-fails until the flag (or the whole declaration) is removed from config. -3. **Backup = a JSON snapshot** of the current *actual* ChurchTools values of all +3. **Backup = a JSON snapshot** of the current _actual_ ChurchTools values of all managed resources, written to `backups/ct-backup-.json` beside the state file, before any write. Directory overridable via `--backup-dir` / `CT_BACKUP_DIR`. @@ -99,15 +99,15 @@ Extend `AdoptableResource`: Add read+write entries for the new types. **Provisional** managed fields (to be confirmed against the live API — fetch one of each with `ct get`, adjust): -| type | collection path | update | provisional managed fields | -| ------------------- | --------------------------- | ------ | ----------------------------------- | -| `campus` | `/campuses` | PUT | `name`, `shortName` | -| `group` | `/groups` | PATCH | `name`, `groupTypeId`, `groupStatusId` | -| `group-type` | `/group/grouptypes` | PUT | `name`, `nameTranslated` | -| `age-group` | `/group/agegroups` | PUT | `name`, `sortKey` | -| `target-group` | `/group/targetgroups` | PUT | `name`, `sortKey` | +| type | collection path | update | provisional managed fields | +| ------------------- | --------------------------- | ------ | ---------------------------------------- | +| `campus` | `/campuses` | PUT | `name`, `shortName` | +| `group` | `/groups` | PATCH | `name`, `groupTypeId`, `groupStatusId` | +| `group-type` | `/group/grouptypes` | PUT | `name`, `nameTranslated` | +| `age-group` | `/group/agegroups` | PUT | `name`, `sortKey` | +| `target-group` | `/group/targetgroups` | PUT | `name`, `sortKey` | | `relationship-type` | `/person/relationshiptypes` | PUT | `name`, `degreeForward`, `degreeReverse` | -| `group-role` | `/group/roles` | PUT | `name`, `groupTypeId` | +| `group-role` | `/group/roles` | PUT | `name`, `groupTypeId` | Provisional field sets are a registry-local detail; the engine is field-agnostic, so refining them later touches only this file (+ its test). @@ -182,14 +182,14 @@ Add `commands/apply.ts` and `commands/destroy.ts`; remove both entries from ## Guardrail → mechanism map -| Guardrail (issue #6) | Mechanism | -| -------------------------------- | --------------------------------------------------------------- | -| Confirmation before any change | `confirm` (apply) / `confirmTyped` (destroy) | -| Automatic backup before apply | `writeBackup(actual)` before the first write | -| People/memberships never touched | structural-only registry + `assertNotPeople` denylist | +| Guardrail (issue #6) | Mechanism | +| -------------------------------- | -------------------------------------------------------------------- | +| Confirmation before any change | `confirm` (apply) / `confirmTyped` (destroy) | +| Automatic backup before apply | `writeBackup(actual)` before the first write | +| People/memberships never touched | structural-only registry + `assertNotPeople` denylist | | Rate-limit + retry on writes | existing `fetchWithRetry` (429 retried; 5xx/network not, for writes) | -| State updated after each action | `save` after every create/update/edge/delete | -| Destroy-protection | `preventDestroy` flag + explicit `--target` + typed confirm | +| State updated after each action | `save` after every create/update/edge/delete | +| Destroy-protection | `preventDestroy` flag + explicit `--target` + typed confirm | Rate-limit/retry needs **no code change**: `fetchWithRetry` already retries `429` for every method (safe — rejected before processing) and deliberately does not diff --git a/docs/superpowers/specs/2026-07-08-phase-5-permissions-autogroups-blueprints-design.md b/docs/superpowers/specs/2026-07-08-phase-5-permissions-autogroups-blueprints-design.md index 5b8adc6..2eb6bb8 100644 --- a/docs/superpowers/specs/2026-07-08-phase-5-permissions-autogroups-blueprints-design.md +++ b/docs/superpowers/specs/2026-07-08-phase-5-permissions-autogroups-blueprints-design.md @@ -22,9 +22,16 @@ The instance is a **write-safe dev box**, so round-trip integration tests may ge - **Permission catalog** (`GET /permissions/global`): `module → { "right name": true | number[] }`. `true` = global right; `number[]` = the scoped `dataId`s the right is granted for. - **Raw assignment tuple** (`GET /permissions/group_role`, `/permissions/group_type_role`) confirmed: ```json - { "domainType": "group_role", "domainId": 2787, "authId": 113, "dataId": 3, - "isInherited": false, "type": "grant", "reason": null, - "meta": { "modifiedDate": "2026-04-21T11:50:57Z", "modifiedPid": 1 } } + { + "domainType": "group_role", + "domainId": 2787, + "authId": 113, + "dataId": 3, + "isInherited": false, + "type": "grant", + "reason": null, + "meta": { "modifiedDate": "2026-04-21T11:50:57Z", "modifiedPid": 1 } + } ``` - `group_type_role` rows carry `authId: 10101` (> 10000). **This falsifies issue #13's claim that `authId < 10000` for `group_type_role`.** Domain rules will be derived empirically, not from the issue text. - `meta.modifiedPid: -1` = system-authored baseline grant (seen live on `group_type_role` domainId 8). These re-add themselves. @@ -41,15 +48,15 @@ The instance is a **write-safe dev box**, so round-trip integration tests may ge ## Architecture — synthetic sub-resource fields -The engine already has the pattern both features need: the opt-in `parents` set-field (`src/engine/hierarchy.ts`). `parents` is a *pseudo-field* — not a real API column — folded into the diff on both the desired and actual sides in `build.ts`, then routed at apply time to a dedicated endpoint (`PUT/DELETE /groups/{id}/parents/{pid}`) instead of the resource body (`execute.ts` `applyParentEdges`). +The engine already has the pattern both features need: the opt-in `parents` set-field (`src/engine/hierarchy.ts`). `parents` is a _pseudo-field_ — not a real API column — folded into the diff on both the desired and actual sides in `build.ts`, then routed at apply time to a dedicated endpoint (`PUT/DELETE /groups/{id}/parents/{pid}`) instead of the resource body (`execute.ts` `applyParentEdges`). Both new features are the same shape: -| Feature | Attaches to | Field kind | Actual source | Write routing | -|---|---|---|---|---| -| `parents` (exists) | group | set of keys | `GET /groups/hierarchies` | `PUT/DELETE /groups/{id}/parents/{pid}` | -| **grants** (#13) | group-role / group-type-role | set of tuples | `GET /permissions/{domainType}` | `PUT/DELETE /permissions/{domainType}/{domainId}` | -| **dynamic** (#14) | group | object (ruleset + status) | `GET /dynamicgroups/{id}/ruleset` + `/status` | `PUT …/ruleset` then `PUT …/status` | +| Feature | Attaches to | Field kind | Actual source | Write routing | +| ------------------ | ---------------------------- | ------------------------- | --------------------------------------------- | ------------------------------------------------- | +| `parents` (exists) | group | set of keys | `GET /groups/hierarchies` | `PUT/DELETE /groups/{id}/parents/{pid}` | +| **grants** (#13) | group-role / group-type-role | set of tuples | `GET /permissions/{domainType}` | `PUT/DELETE /permissions/{domainType}/{domainId}` | +| **dynamic** (#14) | group | object (ruleset + status) | `GET /dynamicgroups/{id}/ruleset` + `/status` | `PUT …/ruleset` then `PUT …/status` | ### The refactor @@ -69,14 +76,20 @@ The `assertNotPeople` guard extends to every new write path. ### DSL ```ts -ct.group({ key: "all_mainz", name: "Alle Mainz", groupTypeId: 1, +ct.group({ + key: "all_mainz", + name: "Alle Mainz", + groupTypeId: 1, dynamic: { - status: "active", // "active" | "manual" | "inactive" | "none" (=demote) - ruleset: q.and( // typed builder → JSONLogic (Phase 2) - q.eq("ctgroup.campusId", campus("mainz")), // name→id resolution for var values - q.eq("person.isArchived", false)), + status: "active", // "active" | "manual" | "inactive" | "none" (=demote) + ruleset: q.and( + // typed builder → JSONLogic (Phase 2) + q.eq("ctgroup.campusId", campus("mainz")), // name→id resolution for var values + q.eq("person.isArchived", false), + ), // …or an opaque blob, or { ref: "./rulesets/all_mainz.json" } (Phase 1) — interchangeable - }}) + }, +}); ``` - `dynamic` is an **object-field** on a group, opt-in like `parents`: `undefined` = not managed as a dynamic group; present = managed. It carries the full `RuleSet` (`{ description, shorty, personIdFieldName, importance, query, process }`) plus `status`. @@ -109,10 +122,16 @@ Both the typed builder and an opaque blob compile to the **same normalized JSONL ### DSL ```ts -ct.groupRole({ key: "kids_lead", id: 51, - grants: ["churchgroup:view group", "churchgroup:edit group members"] }); -ct.groupTypeRole({ key: "leiter_tpl", id: 8, - grants: [{ right: "churchdb:view group", scope: ["kids_area"] }] }); // scope keys → dataId[] +ct.groupRole({ + key: "kids_lead", + id: 51, + grants: ["churchgroup:view group", "churchgroup:edit group members"], +}); +ct.groupTypeRole({ + key: "leiter_tpl", + id: 8, + grants: [{ right: "churchdb:view group", scope: ["kids_area"] }], +}); // scope keys → dataId[] ``` - `grants` is a **set-field** on a role domain object. A grant is either a bare `"module:right"` string (global) or `{ right, scope: [key…] }` (scoped; scope keys resolve to `dataId[]`). @@ -147,7 +166,10 @@ const kidsArea = (campus: string) => { ct.group({ key: `${campus}_kids_0_3`, name: `${campus} · Kids 0–3`, parents: [`${campus}_kids_lead`] }); // … }; -for (const c of ["mainz", "berlin", "koblenz"]) { ct.campus({ key: c, name: `Campus ${c}` }); kidsArea(c); } +for (const c of ["mainz", "berlin", "koblenz"]) { + ct.campus({ key: c, name: `Campus ${c}` }); + kidsArea(c); +} ``` Deliverables (mostly non-code): diff --git a/examples/campus-blueprint.config.ts b/examples/campus-blueprint.config.ts index c0a8ca1..7dba279 100644 --- a/examples/campus-blueprint.config.ts +++ b/examples/campus-blueprint.config.ts @@ -21,7 +21,11 @@ const CAMPUSES = ["mainz", "berlin"] as const; function kidsArea(ct: ConfigContext, campus: string): void { const lead = `${campus}_kids_lead`; ct.group({ key: lead, name: `${campus} · Kids Leitung`, groupType: "ministry_team", campus, parents: [] }); - for (const [suffix, label] of [["0_3", "0–3"], ["4_6", "4–6"], ["checkin", "Check-in"]] as const) { + for (const [suffix, label] of [ + ["0_3", "0–3"], + ["4_6", "4–6"], + ["checkin", "Check-in"], + ] as const) { ct.group({ key: `${campus}_kids_${suffix}`, name: `${campus} · Kids ${label}`, diff --git a/examples/portable.config.ts b/examples/portable.config.ts index ae1060e..92553c3 100644 --- a/examples/portable.config.ts +++ b/examples/portable.config.ts @@ -25,8 +25,20 @@ export default (ct: ConfigContext): void => { // Group type + campus by name — no numeric ids. `groupType: "ministry_team"` resolves against // the live /group/grouptypes catalog on whichever host you apply to. - ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", groupType: "ministry_team", campus: "mainz", parents: [] }); - ct.group({ key: "mainz_kids_team", name: "Mainz · Kids Team", groupType: "ministry_team", campus: "mainz", parents: ["mainz_kids_lead"] }); + ct.group({ + key: "mainz_kids_lead", + name: "Mainz · Kids Leitung", + groupType: "ministry_team", + campus: "mainz", + parents: [], + }); + ct.group({ + key: "mainz_kids_team", + name: "Mainz · Kids Team", + groupType: "ministry_team", + campus: "mainz", + parents: ["mainz_kids_lead"], + }); // A dynamic auto-group whose ruleset filters by campus — again by reference, not id. ct.group({ diff --git a/scripts/regenerate-permission-catalog.ts b/scripts/regenerate-permission-catalog.ts index 89fa7a2..a8fa92e 100644 --- a/scripts/regenerate-permission-catalog.ts +++ b/scripts/regenerate-permission-catalog.ts @@ -62,7 +62,10 @@ function cookieHeader(res: Response): string { const raw = (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() ?? (res.headers.get("set-cookie") ? [res.headers.get("set-cookie") as string] : []); - return raw.map((c) => c.split(";")[0]).filter(Boolean).join("; "); + return raw + .map((c) => c.split(";")[0]) + .filter(Boolean) + .join("; "); } async function main(): Promise { @@ -78,8 +81,13 @@ async function main(): Promise { if (!cookie) throw new Error("Login succeeded but no session cookie was returned."); // 2. Read the instance CT version (for the catalog's provenance stamp). - const infoRes = await fetch(`${host}/api/info`, { headers: { Accept: "application/json", Cookie: cookie } }); - const infoBody = (await infoRes.json().catch(() => ({}))) as { data?: { version?: string }; version?: string }; + const infoRes = await fetch(`${host}/api/info`, { + headers: { Accept: "application/json", Cookie: cookie }, + }); + const infoBody = (await infoRes.json().catch(() => ({}))) as { + data?: { version?: string }; + version?: string; + }; const ctVersion = infoBody.data?.version ?? infoBody.version ?? "unknown"; // 3. Fetch the permission master data from the legacy AJAX endpoint. @@ -96,7 +104,9 @@ async function main(): Promise { const master = (await res.json()) as MasterData; const authTable = master.data?.auth_table ?? master.auth_table; if (!authTable || typeof authTable !== "object") { - throw new Error("Unexpected response: no data.auth_table in getMasterData. Is the endpoint/shape unchanged?"); + throw new Error( + "Unexpected response: no data.auth_table in getMasterData. Is the endpoint/shape unchanged?", + ); } // 4. Flatten `auth_table[module][right]` → `"module:right" → CatalogEntry`, preserving iteration order. diff --git a/src/api/ctClient.ts b/src/api/ctClient.ts index 3cce5e6..608bc9f 100644 --- a/src/api/ctClient.ts +++ b/src/api/ctClient.ts @@ -184,7 +184,12 @@ export class CtClient { const pageMeta = envelope?.meta; meta = pageMeta ?? meta; const pagination = pageMeta?.pagination; - if (pageItems.length === 0 || !pagination || pagination.current === undefined || pagination.lastPage === undefined) { + if ( + pageItems.length === 0 || + !pagination || + pagination.current === undefined || + pagination.lastPage === undefined + ) { break; } if (pagination.current >= pagination.lastPage) { @@ -195,6 +200,26 @@ export class CtClient { return { data: items, meta }; } + /** + * ONE request's raw envelope — the parsed `data` alongside the `meta` that carries CT's + * pagination block (#100). {@link request} unwraps `data` and drops `meta`, which is exactly what + * makes a single-page read indistinguishable from a complete one; `ct get raw` needs both to tell + * "this endpoint returned everything" from "this endpoint returned CT's default first page". + * `data` is the envelope's `data` when present, else the whole body (CT is inconsistent about the + * envelope), so a single-object endpoint round-trips unchanged. + */ + async getRaw(path: string): Promise<{ data: T; meta?: CtMeta }> { + const parsed = await this.requestEnvelope("GET", path); + if (parsed === undefined) { + return { data: undefined as T }; + } + if (Array.isArray(parsed)) { + return { data: parsed as T }; + } + const envelope = parsed as { data?: T; meta?: CtMeta }; + return { data: (envelope.data ?? envelope) as T, meta: envelope.meta }; + } + /** * Shared fetch + parse for {@link request} and {@link getAll}: performs the * HTTP call, throws a status/body-carrying {@link CtApiError} on failure, @@ -250,6 +275,50 @@ export class CtClient { } } + /** + * POST a form body to a LEGACY (non-`/api`) ChurchTools endpoint, riding the same session (#105). + * + * ChurchTools does not expose the permission master data over REST — the permission editor reads it + * from `index.php?q=churchauth/ajax`. This is the only door to it, so it is a narrow, deliberate + * escape hatch from the REST surface rather than a general-purpose method: it takes an `index.php` + * query name, not an arbitrary URL, and it is a read in practice (the one caller performs + * `getMasterData`). The CSRF header is sent because this is a POST, exactly as the browser does. + */ + async legacyPostForm(query: string, form: Record): Promise { + if (!this.cookie) { + throw new CtApiError("Not authenticated — run `ct auth login` first", 401, null); + } + if (!this.csrfToken) { + await this.refreshCsrfToken(); + } + const url = `${this.config.host}/index.php?q=${encodeURIComponent(query)}`; + const res = await fetchWithRetry( + url, + { + method: "POST", + headers: { + Accept: "application/json", + Cookie: this.cookie, + "CSRF-Token": this.csrfToken ?? "", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(form).toString(), + }, + // Not retried on 5xx: a legacy POST is not declared idempotent, and a 429 is still safe to retry. + { isIdempotent: false }, + ); + this.captureCookie(res); + if (!res.ok) { + throw new CtApiError(`POST index.php?q=${query} failed`, res.status, await safeBody(res)); + } + const text = await res.text(); + try { + return JSON.parse(text) as T; + } catch { + throw new CtApiError(`POST index.php?q=${query} returned a non-JSON body`, res.status, text); + } + } + private async refreshCsrfToken(): Promise { // A plain authenticated GET: it rides the session cookie and GET skips the CSRF branch in // request(), so this cannot recurse — and it reuses request()'s envelope unwrap + guarded 2xx @@ -294,7 +363,30 @@ async function safeBody(res: Response): Promise { } /** Append `page`/`limit` query params, respecting any query string the caller already has. */ -function withPageParams(path: string, page: number, limit: number): string { +export function withPageParams(path: string, page: number, limit: number): string { const separator = path.includes("?") ? "&" : "?"; return `${path}${separator}page=${page}&limit=${limit}`; } + +/** True when the caller's path already carries its own `page`/`limit` — a deliberate single-page probe. */ +export function hasOwnPageParams(path: string): boolean { + const query = path.split("?")[1]; + return query !== undefined && /(^|&)(page|limit)=/.test(query); +} + +/** + * Whether a pagination envelope says MORE rows exist than the response carried (#100). Both signals + * are checked because CT populates them inconsistently across endpoints: `current < lastPage` is the + * authoritative one, and `total > count` catches an endpoint that reports a total without page + * numbers. No pagination block at all ⇒ the endpoint is not a paged list, so nothing is missing. + */ +export function hasMorePages(meta: CtMeta | undefined, received: number): boolean { + const p = meta?.pagination; + if (!p) { + return false; + } + if (p.current !== undefined && p.lastPage !== undefined && p.current < p.lastPage) { + return true; + } + return p.total !== undefined && p.total > received; +} diff --git a/src/auth/tokenStore.ts b/src/auth/tokenStore.ts index ba02975..8a92e78 100644 --- a/src/auth/tokenStore.ts +++ b/src/auth/tokenStore.ts @@ -60,16 +60,7 @@ export function parseCredentials(raw: string): Credentials | null { } async function keychainSet(account: string, value: string): Promise { - await run("security", [ - "add-generic-password", - "-U", - "-s", - KEYCHAIN_SERVICE, - "-a", - account, - "-w", - value, - ]); + await run("security", ["add-generic-password", "-U", "-s", KEYCHAIN_SERVICE, "-a", account, "-w", value]); } /** diff --git a/src/commands/adopt-grants.ts b/src/commands/adopt-grants.ts index 025058c..326a81c 100644 --- a/src/commands/adopt-grants.ts +++ b/src/commands/adopt-grants.ts @@ -1,16 +1,25 @@ +import { appendFile } from "node:fs/promises"; import { Command } from "commander"; import { authedSession } from "../api/session.js"; +import type { CtClient } from "../api/ctClient.js"; import { resolveConfig } from "../config.js"; import { prepareEnv } from "../env/context.js"; import { assertNotPeople } from "../engine/guard.js"; -import { emitAdoptedGrants } from "../permissions/adopt.js"; +import { buildAdoptedGrants, type AdoptedGrantsBlock } from "../permissions/adopt.js"; import type { DomainType, RawPermission } from "../permissions/grants.js"; -import { loadState } from "../state/state.js"; +import { fetchPermissionRows, type PermissionReader } from "../permissions/fetch.js"; +import { loadHostCatalog } from "../permissions/catalog-store.js"; +import { declarability, decodeGroupsWithRoles, type RoleInstance } from "../coverage/report.js"; +import { slug } from "../resources/registry.js"; +import { loadState, type State } from "../state/state.js"; import { info, warn } from "../ui.js"; interface AdoptGrantsOptions { state?: string; env?: string; + group?: string; + allDeclarable?: boolean; + write?: string; } /** Accept the DSL's `group_role` and the hyphenated CLI-friendly `group-role`; reject anything else. */ @@ -23,55 +32,250 @@ function normalizeDomainType(raw: string): DomainType { } /** - * `ct adopt grants ` — read the live permission rows for a domain and print - * a paste-ready `ct.groupRole` / `ct.groupTypeRole` / `ct.status` config block. Grants are NOT state-tracked, so - * this prints config only; it never writes the state file (contrast `ct adopt `). + * `ct adopt grants` — read live permission rows and print paste-ready config blocks. Grants are NOT + * state-tracked, so this prints config only; it never writes the state file (contrast `ct adopt`). + * + * Single form (unchanged): `ct adopt grants group_role 44675`. + * + * Bulk forms (#104): adopting the declarable estate of a real instance meant 44 invocations and 44 + * manual pastes, each needing its `key` renamed and its emitted numeric `id:` swapped for the portable + * `group` + `role` pair — exactly the two edits a human forgets on the 30th paste. So: + * + * - `--group ` emits every role instance of one group, `--all-declarable` every declarable + * one on the host; + * - the portable `group` + `role` form is emitted by default whenever the group is managed, and the + * key is derived from (group key, role name) rather than being `group_role_44675`; + * - a block that would REVOKE live grants is never emitted silently in bulk — it is skipped and + * summarised, because the WARNING footer that protects the single form cannot protect a 44-block + * paste that nobody reads to the end. */ 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)", + "Print paste-ready grants config block(s) from live permission rows (does not write state). " + + "One domain, or bulk via --group / --all-declarable.", ) - .argument("", "group_role | group_type_role | status") - .argument("", "the domainId of the permission domain object") + .argument("[domainType]", "group_role | group_type_role | status") + .argument("[domainId]", "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", ) .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") - .action(async (rawType: string, rawId: string, _localOpts: AdoptGrantsOptions, command: Command) => { - // `adopt` (the parent) also declares `-s/--state` and `-e/--env` for its own ` ` - // action. Commander does not merge a same-named parent+subcommand option into either level's - // plain `.opts()` (both come up empty for it); only `optsWithGlobals()` walks the whole - // command chain and merges correctly — read from there, not the local `opts` parameter (#51). - const opts = command.optsWithGlobals() as 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 + .option("--group ", "bulk: every role instance of this group (managed key or numeric id)") + .option("--all-declarable", "bulk: every role instance on the host whose grants are declarable today") + .option("--write ", "append the emitted block(s) to this file instead of printing to stdout") + .action( + async ( + rawType: string | undefined, + rawId: string | undefined, + _localOpts: AdoptGrantsOptions, + command: Command, + ) => { + // `adopt` (the parent) also declares `-s/--state` and `-e/--env` for its own ` ` + // action. Commander does not merge a same-named parent+subcommand option into either level's + // plain `.opts()` (both come up empty for it); only `optsWithGlobals()` walks the whole + // command chain and merges correctly — read from there, not the local `opts` parameter (#51). + const opts = command.optsWithGlobals() as AdoptGrantsOptions; + const bulk = opts.group !== undefined || opts.allDeclarable === true; + if (bulk && (rawType !== undefined || rawId !== undefined)) { + throw new Error( + "Specify either a pair or a bulk selector (--group / --all-declarable), not both.", + ); + } + if (opts.group !== undefined && opts.allDeclarable) { + throw new Error("Specify only one of: --group, --all-declarable."); + } - // 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 cmdEnv = await prepareEnv(opts); - const config = await resolveConfig(); - const statePath = cmdEnv.statePath; - const state = await loadState(statePath, config.host); + // 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 cmdEnv = await prepareEnv(opts); + const config = await resolveConfig(); + const statePath = cmdEnv.statePath; + const state = await loadState(statePath, config.host); + // Bulk selection runs the same declarability verdict as `ct coverage`, so it needs this + // host's catalog for the same reason (#105): under the bundled one, `--all-declarable` + // silently SKIPS role instances `ct plan` would manage, filed under an authId the active + // catalog can name perfectly well. + const hostCatalog = await loadHostCatalog(config.host); + if (hostCatalog) info(`permission catalog: ${hostCatalog}`); + const { client } = await authedSession(); - const { client } = await authedSession(); - const rows = await client.get(path); + const emitted = bulk + ? await emitBulk(client, state, opts) + : [await emitSingle(client, state, rawType, rawId)]; - const block = emitAdoptedGrants({ domainType, domainId, rows, state }); + info(`Grants are not state-tracked — this prints config only and does NOT write ${statePath}.`); + const text = `${emitted.map((e) => e.block).join("\n\n")}\n`; + if (opts.write) { + await appendFile(opts.write, text, "utf8"); + info(`Appended ${emitted.length} block(s) to ${opts.write}. Run \`ct plan\` before applying.`); + } else { + info("Paste the block(s) below into your config, then run `ct plan`:"); + process.stdout.write(text); + } + if (emitted.some((e) => e.omitted > 0)) { + 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.", + ); + } + }, + ); +} + +/** The original single-domain form: `ct adopt grants `. */ +async function emitSingle( + client: PermissionReader, + state: State, + rawType: string | undefined, + rawId: string | undefined, +): Promise { + if (rawType === undefined || rawId === undefined) { + throw new Error( + "Specify , or a bulk selector (--group / --all-declarable).", + ); + } + 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 + const rows = await fetchPermissionRows(client, path); + return buildAdoptedGrants({ domainType, domainId, rows, state }); +} + +/** + * Bulk emission (#104). Selects role instances, then emits each one in the portable form. + * + * Blocks that would revoke live grants are dropped and summarised rather than printed: in bulk the + * per-block WARNING header stops being a safeguard and becomes noise the reader scrolls past. + */ +async function emitBulk( + client: PermissionReader & Pick, + state: State, + opts: AdoptGrantsOptions, +): Promise { + const [groupRows, roleDefRows] = await Promise.all([ + // `?include[]=roles` turns one role lookup per group into a handful of paged calls (#103). + client.getAll>("/groups?include[]=roles"), + client.getAll>("/group/roles"), + ]); + const roleNamesById = new Map(); + for (const r of roleDefRows.data) { + const id = Number(r.id); + if (Number.isFinite(id) && typeof r.name === "string") roleNamesById.set(id, r.name); + } + const groups = decodeGroupsWithRoles(groupRows.data, roleNamesById); + // Guarded read (see permissions/fetch.ts): a silent first page here would drop most role instances + // into the "no authored grants" bucket, which reads identically to a correct run. + const permissions = await fetchPermissionRows(client, "/permissions/group_role"); + const rowsByDomainId = new Map(); + for (const row of permissions) { + const list = rowsByDomainId.get(row.domainId); + if (list) list.push(row); + else rowsByDomainId.set(row.domainId, [row]); + } + + const managedKeyByGroupId = new Map(); + for (const r of Object.values(state.resources)) { + if (r.type === "group") managedKeyByGroupId.set(r.id, r.key); + } - 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.", + let candidates: RoleInstance[]; + if (opts.group !== undefined) { + const groupId = resolveGroupSelector(opts.group, groups, state); + candidates = groups.filter((g) => g.id === groupId).flatMap((g) => g.roles); + if (candidates.length === 0) { + throw new Error(`--group "${opts.group}" resolved to group #${groupId}, which has no role instances.`); + } + } else { + candidates = groups.flatMap((g) => g.roles); + } + + const blocks: AdoptedGrantsBlock[] = []; + const skippedUndeclarable: string[] = []; + const skippedWouldRevoke: string[] = []; + let skippedEmpty = 0; + + for (const role of candidates) { + const rows = rowsByDomainId.get(role.domainId) ?? []; + const verdict = declarability(rows); + if (verdict.grantCount === 0) { + skippedEmpty += 1; + continue; // nothing authored on this domain — an empty block is not worth a paste + } + const label = `${role.groupName} / ${role.roleName} (domainId ${role.domainId})`; + // Skipped in EVERY bulk mode, not just --all-declarable: a role instance with a grant on a + // dimension ct has no resource for can only be written as a host-specific number, and a bulk paste + // is exactly where that quietly becomes a cross-environment misgrant. The single form + // (`ct adopt grants group_role `) still emits it, deliberately, one domain at a time. + if (!verdict.declarable) { + skippedUndeclarable.push( + `${label}: blocked by ${[...verdict.blockedBy, ...verdict.unknownAuthIds.map((a) => `authId ${a}`)].join(", ")}`, ); + continue; + } + const groupKey = managedKeyByGroupId.get(role.groupId); + const built = buildAdoptedGrants({ + domainType: "group_role", + domainId: role.domainId, + rows, + state, + domain: groupKey ? { group: groupKey, role: role.roleName } : undefined, + key: groupKey ? `${groupKey}_${slug(role.roleName)}` : undefined, }); + if (built.omitted > 0) { + skippedWouldRevoke.push(`${label}: ${built.omitted} live grant(s) cannot be expressed as config`); + continue; + } + blocks.push(built); + } + + if (blocks.length === 0 && skippedWouldRevoke.length === 0 && skippedUndeclarable.length === 0) { + throw new Error("No role instance with authored grants matched — nothing to emit."); + } + + info( + `${blocks.length} block(s) emitted · ${skippedWouldRevoke.length} skipped (would revoke live grants) · ` + + `${skippedUndeclarable.length} skipped (not declarable) · ${skippedEmpty} skipped (no authored grants)`, + ); + // Never a silent cap: what was NOT emitted is listed, so "44 blocks" can't quietly mean "44 of 59". + for (const line of skippedWouldRevoke) { + warn(`skipped ${line} — adopt the missing scope target(s), then re-run for this domain`); + } + for (const line of skippedUndeclarable) { + warn( + `skipped ${line} — own the rest with \`preserveUnknown: []\` (#102), or emit it ` + + `deliberately with the single form`, + ); + } + return blocks; +} + +/** Resolve `--group` to a live group id: numeric id, adopted-state logical key, or live name/slug. */ +function resolveGroupSelector( + raw: string, + groups: Array<{ id: number; name: string }>, + state: State, +): number { + const trimmed = raw.trim(); + if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10); + const managed = state.resources[trimmed]; + if (managed && managed.type === "group") return managed.id; + const bySlug = groups.filter((g) => slug(g.name) === slug(trimmed)); + const candidates = bySlug.length > 0 ? bySlug : groups.filter((g) => g.name === trimmed); + if (candidates.length === 0) { + throw new Error( + `--group "${raw}": not adopted (no state entry) and no live group matches (checked by slug and exact name).`, + ); + } + if (candidates.length > 1) { + const listed = candidates.map((c) => `${JSON.stringify(c.name)} (#${c.id})`).join(", "); + throw new Error(`--group "${raw}" is ambiguous: ${candidates.length} live groups match — ${listed}.`); + } + return candidates[0]!.id; } diff --git a/src/commands/adopt-group.ts b/src/commands/adopt-group.ts index 83ad116..f268c75 100644 --- a/src/commands/adopt-group.ts +++ b/src/commands/adopt-group.ts @@ -20,7 +20,7 @@ import type { DynamicStatus } from "../engine/types.js"; import { RESOURCES, configSnippet, fromInformation, slug } from "../resources/registry.js"; import { ReverseResolver, type RoleCatalogEntry } from "../resolve/reverse.js"; import type { RefKind } from "../resolve/refs.js"; -import { portablizeRuleset } from "../config/query-refs.js"; +import { formatPortablizeWarnings, portablizeRuleset, scanUnportablized } from "../config/query-refs.js"; import { loadState, saveState, upsert, type State } from "../state/state.js"; import { success, info, warn, out } from "../ui.js"; @@ -32,7 +32,9 @@ interface AdoptGroupOptions { type?: string; childrenOf?: string; withDynamic?: boolean; + /** Commander's negatable `--no-portable-rulesets`: true unless the flag was passed (#101). */ portableRulesets?: boolean; + strictRulesets?: boolean; } const GROUP_SPEC = RESOURCES.group!; @@ -184,8 +186,17 @@ export function adoptGroupCommand(): Command { ) .option( "--portable-rulesets", - "when capturing rulesets (--with-dynamic), rewrite managed entity ids in the query into portable " + - "logical ref markers; unmanaged ids stay numeric with a warning (opt-in; #76)", + "(deprecated — this is the default since #101) rewrite managed entity ids into portable logical refs", + ) + .option( + "--no-portable-rulesets", + "capture rulesets verbatim: keep this host's numeric entity ids instead of rewriting the managed " + + "ones into portable logical ref markers (#76/#101 — portablization is the default)", + ) + .option( + "--strict-rulesets", + "refuse to write a ruleset that still contains an unportablized (host-specific) id, instead of " + + "writing it with a warning (#101)", ) .action(async (ids: string[], _localOpts: AdoptGroupOptions, command: Command) => { // `adopt` (the parent) also declares `-k/--key`, `-s/--state`, `-e/--env`, and `--dry-run` — @@ -254,10 +265,14 @@ export function adoptGroupCommand(): Command { // NOT a simple id→key map — a `role.id`/`groupTypeRoleId` is group-type-scoped and role names are // not globally unique, so we fetch the (groupTypeId, name) catalog plus the group-type id→key map // and let portablizeRuleset emit (group-type, role-name) markers (fixes #86's `role-def` mapping). + // + // Portablization is ON by default since #101 (`--no-portable-rulesets` opts out): leaving a + // capture host-specific fails SILENTLY on the next host — CT does not validate the ids inside a + // ruleset, so the auto-group just collects the wrong people while `ct plan` stays green. const portableCatalogMaps: Partial>> = {}; let roleCatalog: Map | undefined; let groupTypeIdToKey: Map | undefined; - if (opts.withDynamic && opts.portableRulesets) { + if (opts.withDynamic && opts.portableRulesets !== false) { portableCatalogMaps.campus = await reverse.idToKeyByKind("campus"); portableCatalogMaps["group-type"] = await reverse.idToKeyByKind("group-type"); groupTypeIdToKey = portableCatalogMaps["group-type"]; @@ -282,7 +297,7 @@ export function adoptGroupCommand(): Command { if (captured) { const relPath = `rulesets/${key}.json`; let rulesetToWrite = captured.normalizedRuleset; - if (opts.portableRulesets) { + if (opts.portableRulesets !== false) { // Managed group ids come from state (no catalog for `group`), including any group this // same run already adopted; the master-data kinds come from the catalog maps above. const groupMap = new Map(); @@ -295,10 +310,37 @@ export function adoptGroupCommand(): Command { groupTypeIdToKey, }); rulesetToWrite = ruleset; + // Report every dimension left numeric, with its reason (#101). The old output said only + // "left N unmanaged id(s) numeric", which named neither the dimension nor the fix — so a + // capture that silently froze prod's ids into a cross-host file looked like a clean run. if (warnings.length > 0) { + const lines = formatPortablizeWarnings(warnings); + if (opts.strictRulesets) { + throw new Error( + `--strict-rulesets: ${relPath} would contain ${warnings.length} unportablized ` + + `(host-specific) id(s), so nothing was written:\n` + + lines.map((l) => ` ${l}`).join("\n"), + ); + } + warn( + `${relPath} keeps ${warnings.length} host-specific id(s) — NOT portable to another host:`, + ); + for (const line of lines) info(` ${line}`); + } + } else { + // Verbatim capture (--no-portable-rulesets): every entity id in the file is this host's. + // Say so once per ruleset rather than let the opt-out quietly imply the ids are fine. + const left = scanUnportablized(captured.normalizedRuleset); + if (left.length > 0) { + if (opts.strictRulesets) { + throw new Error( + `--strict-rulesets with --no-portable-rulesets: ${relPath} would contain ` + + `${left.length} host-specific id(s) and nothing would rewrite them.`, + ); + } warn( - `left ${warnings.length} unmanaged id(s) numeric in ${key}.json — ` + - `operational/unmanaged refs, not portable (escape hatch)`, + `${relPath} captured verbatim (--no-portable-rulesets): ${left.length} host-specific ` + + `id(s) kept as-is — NOT portable to another host.`, ); } } diff --git a/src/commands/apply.ts b/src/commands/apply.ts index de79272..668ad59 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -13,6 +13,7 @@ import { writeBackup } from "../engine/backup.js"; import { renderPlan } from "../engine/render.js"; import { summarize } from "../engine/types.js"; import { buildPermissionPlan } from "../permissions/plan.js"; +import { loadHostCatalog } from "../permissions/catalog-store.js"; import { renderPermissionPlan } from "../permissions/render.js"; import { applyPermissionPlan } from "../permissions/apply.js"; import { confirm, confirmEnv } from "../ui/prompt.js"; @@ -44,10 +45,7 @@ export function applyCommand(): Command { .option("-c, --config ", "config file (or set CT_CONFIG)") .option("-s, --state ", "state file (or set CT_STATE)") .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") - .option( - "--confirm-env ", - "confirm a protected env non-interactively (must match --env exactly)", - ) + .option("--confirm-env ", "confirm a protected env non-interactively (must match --env exactly)") .option("--backup-dir ", "directory for the pre-apply backup (or set CT_BACKUP_DIR)") .option("-y, --auto-approve", "skip the confirmation prompt") .option( @@ -59,6 +57,11 @@ export function applyCommand(): Command { const config = await resolveConfig(); const configPath = resolveConfigPath(opts.config); const statePath = cmdEnv.statePath; + // A per-instance permission catalog this repo committed for THIS host wins over the bundled + // one (#105) — same precedence AND same ordering as `ct plan`, so the two never disagree about + // what a right is (config evaluation validates scope dimensions against the active catalog). + const hostCatalog = await loadHostCatalog(config.host); + if (hostCatalog) info(`permission catalog: ${hostCatalog}`); const { resources: desired, permissions, configDir } = await loadConfig(configPath); const state = await loadState(statePath, config.host); @@ -67,11 +70,13 @@ export function applyCommand(): Command { const resolver = new Resolver({ client, state, desired, host: config.host }); // Independent fetches: the resource plan and the permission plan (whose instance-wide // /permissions/ reads are slow) run concurrently rather than back-to-back. - const [{ plan, actual, fetchErrors }, { items: permItems, fetchErrors: permFetchErrors, warnings: permWarnings }] = - await Promise.all([ - buildPlan(client, state, desired, { configDir, resolver }), - buildPermissionPlan(client, state, permissions, desired, resolver, client.version ?? undefined), - ]); + const [ + { plan, actual, fetchErrors }, + { items: permItems, fetchErrors: permFetchErrors, warnings: permWarnings }, + ] = await Promise.all([ + buildPlan(client, state, desired, { configDir, resolver }), + buildPermissionPlan(client, state, permissions, desired, resolver, client.version ?? undefined), + ]); // Permission catalog warnings (#25): stale-version / unknown-authId (untouched, never revoked). for (const w of permWarnings) warn(w); @@ -99,10 +104,7 @@ export function applyCommand(): Command { } const s = summarize(plan); - const permChangeCount = permItems.reduce( - (n, i) => n + i.diff.toPut.length + i.diff.toDelete.length, - 0, - ); + const permChangeCount = permItems.reduce((n, i) => n + i.diff.toPut.length + i.diff.toDelete.length, 0); const changeCount = s.create + s.update + permChangeCount; if (changeCount === 0) { success("No changes to apply."); @@ -125,11 +127,7 @@ export function applyCommand(): Command { return; } - const backupPath = await writeBackup( - resolveBackupDir(opts.backupDir, statePath), - config.host, - actual, - ); + const backupPath = await writeBackup(resolveBackupDir(opts.backupDir, statePath), config.host, actual); info(`Backup written: ${backupPath}`); const result = await executePlan(plan, { client, state, statePath, save: saveState }); @@ -155,7 +153,9 @@ export function applyCommand(): Command { `${permResult.failed.length} permission write(s) failed — re-run to resume (grant reconciliation is idempotent):`, ); for (const f of permResult.failed) { - info(` ${f.method} ${f.path} (authId ${f.authId}${f.dataId.length ? ` dataId ${f.dataId.join(",")}` : ""}): ${f.message}`); + info( + ` ${f.method} ${f.path} (authId ${f.authId}${f.dataId.length ? ` dataId ${f.dataId.join(",")}` : ""}): ${f.message}`, + ); } process.exitCode = 1; return; @@ -163,6 +163,24 @@ export function applyCommand(): Command { if (opts.refresh) { await runPostApplyHooks(plan, state, client); + } else { + // The auto-group model is the single most surprising thing about a green apply (#105): the + // ruleset is written and activated, but ChurchTools computes membership on its own schedule, + // so a freshly created auto-group is legitimately EMPTY right now. Saying so costs one line + // and removes the "did it work?" that otherwise follows every first apply. + const dynamicKeys = plan.items + .filter( + (i) => + i.action !== "no-op" && i.action !== "delete" && i.changes.some((c) => c.field === "dynamic"), + ) + .map((i) => i.key); + if (dynamicKeys.length > 0) { + info( + `${dynamicKeys.length} dynamic group(s) written and activated — ChurchTools materializes their ` + + `membership on its own schedule, so they may be empty for now. Force it with ` + + `\`ct refresh --group ${dynamicKeys[0]}\` (or re-run apply with --refresh).`, + ); + } } }); } diff --git a/src/commands/coverage.ts b/src/commands/coverage.ts new file mode 100644 index 0000000..608dbdb --- /dev/null +++ b/src/commands/coverage.ts @@ -0,0 +1,141 @@ +/** + * `ct coverage` (#103) — the audit "what exists on this host that I am not managing, and could I + * manage it?", built into the tool instead of hand-rolled per consumer repo. + * + * Reads only; writes nothing, touches no state. The joins it performs are the ones every repo was + * doing by hand (`?include[]=roles` + `/dynamicgroups` + `/permissions/group_role`, minus inherited + * rows); the verdict it computes — whether a role instance's grants are declarable — is the part only + * `ct` can produce, because it needs the scope-dimension knowledge that lives in this codebase. + */ +import { Command } from "commander"; +import { authedSession } from "../api/session.js"; +import type { CtClient } from "../api/ctClient.js"; +import { resolveConfig } from "../config.js"; +import { prepareEnv } from "../env/context.js"; +import { + buildCoverageReport, + decodeGroupsWithRoles, + renderCoverage, + renderRoleInstances, + type CoverageReport, +} from "../coverage/report.js"; +import { fetchPermissionRows, type PermissionReader } from "../permissions/fetch.js"; +import { loadHostCatalog } from "../permissions/catalog-store.js"; +import { slug } from "../resources/registry.js"; +import { loadState, type State } from "../state/state.js"; +import { info, out } from "../ui.js"; + +interface CoverageOptions { + state?: string; + env?: string; + json?: boolean; + type?: string; + declarable?: boolean; + blocked?: boolean; +} + +/** + * `?include[]=roles` is the whole reason this command is cheap: without it, auditing role instances + * means one `/groups/{id}/roles` call per group (645 of them on eqrm prod). With it, the same data + * arrives in the paged group list. + */ +const GROUPS_WITH_ROLES = "/groups?include[]=roles"; + +export function coverageCommand(): Command { + return new Command("coverage") + .description("Report what ChurchTools has that the config does not manage, and what is declarable") + .option("-s, --state ", "state file (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option("--json", "emit the full report as JSON (for CI gates)") + .option("--type ", "restrict the role-instance detail to one group type") + .option("--declarable", "list only role instances whose grants could be adopted today") + .option("--blocked", "list only role instances blocked by an undeclarable scope dimension") + .action(async (opts: CoverageOptions) => { + const cmdEnv = await prepareEnv(opts); + const config = await resolveConfig(); + const state = await loadState(cmdEnv.statePath, config.host); + // The declarability verdict is computed from the catalog's authIds and scope dimensions, so it + // must read the SAME catalog `ct plan` does for this host (#105) — otherwise a right the + // committed capture can name is reported here as "blocked by authId N" (failing a `--json` CI + // gate) while `ct plan` manages it without complaint. + const hostCatalog = await loadHostCatalog(config.host); + if (hostCatalog) info(`permission catalog: ${hostCatalog}`); + const { client } = await authedSession(); + + const report = await collectCoverage(client, config.host, state); + + let instances = report.roleInstances; + if (opts.type) { + const wanted = opts.type.trim(); + const typeIds = new Set( + report.byType + .filter((t) => t.name === wanted || slug(t.name) === slug(wanted)) + .map((t) => t.groupTypeId), + ); + if (typeIds.size === 0) { + throw new Error( + `--type "${opts.type}": no group type matches (checked by name and slug against this host).`, + ); + } + instances = instances.filter((r) => typeIds.has(r.groupTypeId)); + } + if (opts.declarable) instances = instances.filter((r) => r.verdict.declarable); + if (opts.blocked) instances = instances.filter((r) => !r.verdict.declarable); + + if (opts.json) { + out({ ...report, roleInstances: instances }); + return; + } + + if (cmdEnv.name) info(`env: ${cmdEnv.name} · host: ${config.host} · state: ${cmdEnv.statePath}`); + process.stdout.write(`${renderCoverage(report)}\n`); + if (opts.type || opts.declarable || opts.blocked) { + process.stdout.write( + instances.length > 0 ? `\n${renderRoleInstances(instances)}\n` : "\nNo role instances match.\n", + ); + } + }); +} + +/** Fetch everything the report needs. Split out from the action so it is reusable and mockable. */ +export async function collectCoverage( + client: PermissionReader & Pick, + host: string, + state: State, +): Promise { + const [groupRows, groupTypeRows, dynamicRows, roleDefRows] = await Promise.all([ + client.getAll>(GROUPS_WITH_ROLES), + client.getAll>("/group/grouptypes"), + client.getAll>("/dynamicgroups"), + client.getAll>("/group/roles"), + ]); + // Same guarded read the planner performs: one request while the endpoint stays un-paged, and a + // proper paging pass if it ever does paginate — never a silent first page (see permissions/fetch.ts). + const groupRolePermissions = await fetchPermissionRows(client, "/permissions/group_role"); + + const groupTypeNames = new Map(); + for (const row of groupTypeRows.data) { + const id = Number(row.id); + if (Number.isFinite(id) && typeof row.name === "string") groupTypeNames.set(id, row.name); + } + const roleNamesById = new Map(); + for (const row of roleDefRows.data) { + const id = Number(row.id); + if (Number.isFinite(id) && typeof row.name === "string") roleNamesById.set(id, row.name); + } + const dynamicGroupIds = new Set(); + for (const row of dynamicRows.data) { + // `/dynamicgroups` rows are keyed by the GROUP id they belong to; tolerate either spelling. + const id = Number(row.id ?? row.groupId); + if (Number.isFinite(id)) dynamicGroupIds.add(id); + } + + return buildCoverageReport({ + host, + state, + groups: decodeGroupsWithRoles(groupRows.data, roleNamesById), + groupTypeNames, + dynamicGroupIds, + groupRolePermissions: Array.isArray(groupRolePermissions) ? groupRolePermissions : [], + }); +} diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts index 2ef905b..dc354c8 100644 --- a/src/commands/destroy.ts +++ b/src/commands/destroy.ts @@ -91,7 +91,9 @@ async function fetchParentEdges( } return edges; } catch (err) { - warn(`Failed to fetch group hierarchies for destroy ordering: ${formatError(err)}. Falling back to tier-only order.`); + warn( + `Failed to fetch group hierarchies for destroy ordering: ${formatError(err)}. Falling back to tier-only order.`, + ); return new Map(); } } @@ -119,10 +121,7 @@ export function destroyCommand(): Command { .requiredOption("--target ", "logical key(s) to destroy (repeatable or comma-separated)") .option("-s, --state ", "state file (or set CT_STATE)") .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") - .option( - "--confirm-env ", - "confirm a protected env non-interactively (must match --env exactly)", - ) + .option("--confirm-env ", "confirm a protected env non-interactively (must match --env exactly)") .option("--backup-dir ", "directory for the pre-destroy backup (or set CT_BACKUP_DIR)") .option( "--force", @@ -166,7 +165,10 @@ export function destroyCommand(): Command { // Backup: fetch each target's current actual values via the same fetchActual as plan/apply // (404 → skip: already gone in CT, nothing to back up). A non-404 failure must ABORT before // any DELETE — proceeding would irreversibly delete a target with no backup of its state. - const { actual, fetchErrors } = await fetchActual(client, ordered.map((k) => state.resources[k]!)); + const { actual, fetchErrors } = await fetchActual( + client, + ordered.map((k) => state.resources[k]!), + ); if (fetchErrors.length > 0) { error( `Backup fetch failed for: ${fetchErrors.join("; ")}. ` + @@ -243,7 +245,9 @@ export async function runDeleteLoop(ctx: DeleteLoopCtx): Promise { if (err instanceof CtApiError && err.status === 404) { delete state.resources[key]; await save(statePath, state); - success(`${managed.type}.${key} (#${managed.id}) already deleted in ChurchTools — removed from state`); + success( + `${managed.type}.${key} (#${managed.id}) already deleted in ChurchTools — removed from state`, + ); continue; } // Same formatter the top-level handler uses (#50) so a non-404 CtApiError's HTTP status + diff --git a/src/commands/get.ts b/src/commands/get.ts index ab9041c..2a5f508 100644 --- a/src/commands/get.ts +++ b/src/commands/get.ts @@ -1,8 +1,15 @@ import { Command } from "commander"; import { authedSession } from "../api/session.js"; +import { + hasMorePages, + hasOwnPageParams, + withPageParams, + type CtClient, + type CtMeta, +} from "../api/ctClient.js"; import { prepareEnvHost } from "../env/context.js"; import { CATALOG } from "../permissions/catalog.js"; -import { info, out } from "../ui.js"; +import { info, out, warn } from "../ui.js"; interface ResourceSpec { path: string; @@ -93,13 +100,109 @@ export function getCommand(): Command { cmd .command("raw ") - .description("GET an arbitrary API path, e.g. `ct get raw /groups/42`") + .description("GET an arbitrary API path, e.g. `ct get raw /groups/42` (list endpoints are paginated)") .option("-e, --env ", "environment profile from ct.envs.json (targets that host)") - .action(async (path: string, opts: { env?: string }) => { + .option( + "--no-paginate", + "issue exactly one request instead of following pagination (warns if rows were left behind)", + ) + .option("--page ", "fetch exactly this page (implies --no-paginate)") + .action(async (path: string, opts: RawOptions) => { await prepareEnvHost(opts); // #22: wire the env's host/token before authenticating const { client } = await authedSession(); - out(await client.get(path.startsWith("/") ? path : `/${path}`)); + const target = path.startsWith("/") ? path : `/${path}`; + await getRaw(client, target, opts); }); return cmd; } + +interface RawOptions { + env?: string; + /** Commander's negatable `--no-paginate`: true unless the flag was passed. */ + paginate?: boolean; + page?: string; +} + +/** + * `ct get raw ` (#100). Before this, raw issued ONE plain request and printed whatever came + * back — which for any CT list endpoint is its default first page (10 rows) with no hint that the + * rest exist. That made raw disagree with the typed commands by hundreds of rows on the same path, + * and the output (a valid-looking JSON array) gave the reader no reason to doubt it. + * + * So raw now follows pagination like the typed commands, while staying an honest escape hatch: + * + * - a probe request runs FIRST, with no paging params added. A non-array body (`/groups/42`, + * `/whoami`) is printed as-is — paging params are never appended to an endpoint that isn't a + * list, so no path can start 400ing because raw got clever. + * - an array body whose `meta.pagination` says more rows exist is re-read through `getAll`. + * - `--no-paginate` / `--page ` / a caller-supplied `page=`/`limit=` in the path keep the + * single-request behaviour for deliberate probing — and then a dropped-rows WARNING is loud, + * because silence is the one thing this must never do again. + */ +export async function getRaw( + client: Pick, + target: string, + opts: RawOptions, +): Promise { + let page: number | undefined; + if (opts.page !== undefined) { + if (!/^\d+$/.test(String(opts.page).trim()) || Number.parseInt(String(opts.page), 10) < 1) { + throw new Error(`Invalid --page "${opts.page}" — expected a positive integer.`); + } + page = Number.parseInt(String(opts.page), 10); + } + // A path that already carries page/limit is the caller hand-rolling their own paging. Combining it + // with --page produced `?limit=50&page=2&limit=100` — a duplicated param whose winner is the + // server's parsing rule, so the returned window's size was not predictable from what was typed. + // Rejected rather than silently picking one: both spellings are explicit, so neither may be dropped. + if (page !== undefined && hasOwnPageParams(target)) { + throw new Error( + `--page ${page} conflicts with the page/limit already in the path — ` + + `keep the query string's own paging, or drop it and use --page.`, + ); + } + const single = opts.paginate === false || page !== undefined || hasOwnPageParams(target); + + if (single) { + const path = page !== undefined ? withPageParams(target, page, DEFAULT_RAW_PAGE_LIMIT) : target; + const { data, meta } = await client.getRaw(path); + out(data); + reportRows(data, meta); + return; + } + + const probe = await client.getRaw(target); + if (!Array.isArray(probe.data) || !hasMorePages(probe.meta, probe.data.length)) { + out(probe.data); + reportRows(probe.data, probe.meta); + return; + } + const all = await client.getAll(target); + out(all.data); + reportRows(all.data, all.meta); +} + +/** Page size for `--page `, matching the client's own default so page N means the same thing. */ +const DEFAULT_RAW_PAGE_LIMIT = 100; + +/** Echo the row count on stderr, and WARN whenever rows were left behind (never silent — #100). */ +function reportRows(data: unknown, meta: CtMeta | undefined): void { + if (!Array.isArray(data)) { + return; // single object — pagination does not apply + } + const total = meta?.pagination?.total; + if (hasMorePages(meta, data.length)) { + warn( + `INCOMPLETE: returned ${data.length} of ${total ?? "more"} row(s) — this endpoint is paginated ` + + `and only part of it was fetched. Drop --page/--no-paginate (or the page=/limit= in the path) ` + + `to fetch every page.`, + ); + return; + } + info( + total !== undefined && total !== data.length + ? `${data.length} of ${total} total` + : `${data.length} total`, + ); +} diff --git a/src/commands/permissions.ts b/src/commands/permissions.ts new file mode 100644 index 0000000..f7e3553 --- /dev/null +++ b/src/commands/permissions.ts @@ -0,0 +1,77 @@ +/** + * `ct permissions catalog` (#105) — inspect, and REFRESH, the name↔authId permission catalog. + * + * The refresh is the point: the catalog ships as a snapshot of one ChurchTools version, and until now + * the only way to update it was `npm run regenerate:permission-catalog` inside the ct-cli repo. A + * consumer repo therefore could not act on the staleness warning its own plans printed. This command + * captures the catalog from the repo's OWN instance into `.ct/permission-catalog..json`, which + * every subsequent plan/apply against that host loads in preference to the bundled one. + */ +import { Command } from "commander"; +import { authedSession } from "../api/session.js"; +import { resolveConfig } from "../config.js"; +import { prepareEnvHost } from "../env/context.js"; +import { CATALOG, CATALOG_META } from "../permissions/catalog.js"; +import { + capturePermissionCatalog, + hostCatalogPath, + loadHostCatalog, + writeHostCatalog, +} from "../permissions/catalog-store.js"; +import { info, success, warn } from "../ui.js"; + +interface CatalogOptions { + env?: string; + refresh?: boolean; +} + +export function permissionsCommand(): Command { + const cmd = new Command("permissions").description("Inspect or refresh the permission catalog"); + + cmd + .command("catalog") + .description( + "Show the active permission catalog's provenance, or capture a fresh one for this instance " + + "with --refresh (writes .ct/permission-catalog..json)", + ) + .option("-e, --env ", "environment profile from ct.envs.json (targets that host)") + .option("--refresh", "capture the catalog from the live instance and write it for this host") + .action(async (opts: CatalogOptions) => { + await prepareEnvHost(opts); + const config = await resolveConfig(); + + if (!opts.refresh) { + // Offline: report which catalog WOULD be used for this host, and where it came from. + const loaded = await loadHostCatalog(config.host); + info( + loaded + ? `Active catalog: ${loaded} (per-instance capture)` + : `Active catalog: bundled with this ct release (no ${hostCatalogPath(config.host)})`, + ); + if (CATALOG_META) { + info( + ` captured from ${CATALOG_META.capturedFrom} · ChurchTools ${CATALOG_META.ctVersion} · ` + + `${CATALOG_META.capturedAt} · ${Object.keys(CATALOG).length} rights`, + ); + } + if (!loaded) { + info( + ` refresh it for this instance: \`ct permissions catalog --refresh${opts.env ? ` --env ${opts.env}` : ""}\``, + ); + } + return; + } + + const { client } = await authedSession(); + const catalog = await capturePermissionCatalog(client); + const path = await writeHostCatalog(config.host, catalog); + const meta = catalog.$meta as { rightCount: number; ctVersion: string }; + success(`Wrote ${path} — ${meta.rightCount} rights · ChurchTools ${meta.ctVersion} · ${config.host}`); + info( + "Commit this file: every plan/apply against this host will use it instead of the bundled catalog.", + ); + warn("Review the diff before committing — an authId that MOVED changes what a declared right grants."); + }); + + return cmd; +} diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 063b101..0f0ecb3 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -9,6 +9,7 @@ import { Resolver } from "../resolve/resolver.js"; import { renderPlan } from "../engine/render.js"; import { summarize } from "../engine/types.js"; import { buildPermissionPlan } from "../permissions/plan.js"; +import { loadHostCatalog } from "../permissions/catalog-store.js"; import { renderPermissionPlan } from "../permissions/render.js"; import { info, warn, out } from "../ui.js"; @@ -36,6 +37,12 @@ export function planCommand(): Command { const cmdEnv = await prepareEnv(opts); const config = await resolveConfig(); const configPath = resolveConfigPath(opts.config); + // A per-instance permission catalog this repo committed for THIS host wins over the one bundled + // with the release (#105). Loaded BEFORE loadConfig, not just before the plan: config evaluation + // validates `preserveUnknown` dimensions against the active catalog's KNOWN_SCOPE_FIELDS, so + // loading it later would validate against the bundled catalog and plan against the captured one. + const hostCatalog = await loadHostCatalog(config.host); + if (hostCatalog) info(`permission catalog: ${hostCatalog}`); const { resources: desired, permissions, configDir } = await loadConfig(configPath); // loadState already refuses a host mismatch (state.ts) — no second guard needed here. const state = await loadState(cmdEnv.statePath, config.host); @@ -45,11 +52,13 @@ export function planCommand(): Command { // instance means each master-data catalog is fetched at most once (cache is Promise-keyed). const resolver = new Resolver({ client, state, desired, host: config.host }); // Independent fetches run concurrently (see commands/apply.ts). - const [{ plan, fetchErrors }, { items: permItems, fetchErrors: permFetchErrors, warnings: permWarnings }] = - await Promise.all([ - buildPlan(client, state, desired, { configDir, resolver }), - buildPermissionPlan(client, state, permissions, desired, resolver, client.version ?? undefined), - ]); + const [ + { plan, fetchErrors }, + { items: permItems, fetchErrors: permFetchErrors, warnings: permWarnings }, + ] = await Promise.all([ + buildPlan(client, state, desired, { configDir, resolver }), + buildPermissionPlan(client, state, permissions, desired, resolver, client.version ?? undefined), + ]); // "Changes present" for --detailed-exitcode / the JSON summary: anything `ct apply` would // actually act on — a resource item whose action isn't a no-op, OR a permission item with a // grant/revoke to write. Drift by itself does NOT count: an item can carry `drift` while diff --git a/src/commands/refresh.ts b/src/commands/refresh.ts new file mode 100644 index 0000000..7a81a49 --- /dev/null +++ b/src/commands/refresh.ts @@ -0,0 +1,131 @@ +/** + * `ct refresh` (#105) — ask ChurchTools to materialize a dynamic group's membership NOW. + * + * `ct apply` writes the ruleset and flips the status; ChurchTools computes the membership on its own + * schedule. So a freshly created auto-group is legitimately EMPTY after a green apply, which reads as + * a failure to anyone who does not know the model. `ct apply --refresh` only covers dynamic groups + * CHANGED in that run, so it cannot re-evaluate an existing group and does nothing at all on a no-op + * plan — leaving no lever for "it's empty and I want to know whether the ruleset is wrong". + * + * Scope is deliberately per-group: `POST /dynamicgroups/{id}/refresh`. ChurchTools also exposes a + * legacy scheduler ping (`GET /?q=cron&standby=true`) that the admin UI's cron page hits, but that + * runs EVERY due scheduled job on the instance — far beyond auto-groups — so `ct` documents it (see + * docs/runbook-manual-surface.md) and never fires it. + */ +import { Command } from "commander"; +import { authedSession } from "../api/session.js"; +import type { CtClient } from "../api/ctClient.js"; +import { CtApiError } from "../api/ctClient.js"; +import { resolveConfig } from "../config.js"; +import { prepareEnv } from "../env/context.js"; +import { assertNotPeople } from "../engine/guard.js"; +import { loadState, type ManagedResource, type State } from "../state/state.js"; +import { error, info, success, warn } from "../ui.js"; + +interface RefreshOptions { + state?: string; + env?: string; + group?: string; + all?: boolean; +} + +/** The per-group counts CT returns from POST /dynamicgroups/{id}/refresh. */ +interface RefreshResult { + created: number; + updated: number; + deleted: number; +} + +export function refreshCommand(): Command { + return new Command("refresh") + .description( + "Ask ChurchTools to re-evaluate a managed dynamic group's membership now (does not change config)", + ) + .option("-s, --state ", "state file (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option("--group ", "refresh this managed group only") + .option("--all", "refresh every managed dynamic group (required to fan out — this changes membership)") + .action(async (opts: RefreshOptions) => { + if (!opts.group && !opts.all) { + throw new Error( + "Specify --group for one group, or --all to refresh every managed dynamic group. " + + "Refreshing recomputes membership, so the fan-out is never the default.", + ); + } + if (opts.group && opts.all) { + throw new Error("Specify only one of: --group, --all."); + } + + const cmdEnv = await prepareEnv(opts); + const config = await resolveConfig(); + const state = await loadState(cmdEnv.statePath, config.host); + const { client } = await authedSession(); + + const targets = await selectTargets(client, state, opts.group); + if (targets.length === 0) { + info("No managed dynamic groups to refresh."); + return; + } + + let failed = 0; + for (const target of targets) { + const path = `/dynamicgroups/${target.id}/refresh`; + assertNotPeople(path); + try { + const res = await client.request("POST", path); + const r = res?.[0]; + success( + r + ? `refreshed ${target.key} (#${target.id}): +${r.created} ~${r.updated} -${r.deleted}` + : `refreshed ${target.key} (#${target.id})`, + ); + } catch (err) { + failed += 1; + error( + `Failed to refresh ${target.key} (#${target.id}): ${ + err instanceof CtApiError ? `HTTP ${err.status}` : (err as Error).message + }`, + ); + } + } + if (failed > 0) process.exitCode = 1; + }); +} + +/** + * Which managed groups to refresh. Refuses a group that is not an auto-group on this host rather than + * POSTing to an endpoint that will 404 — "this group has no ruleset" is the answer the caller needs. + */ +async function selectTargets( + client: Pick, + state: State, + groupKey: string | undefined, +): Promise { + const dynamicIds = new Set(); + const { data } = await client.getAll>("/dynamicgroups"); + for (const row of data) { + const id = Number(row.id ?? row.groupId); + if (Number.isFinite(id)) dynamicIds.add(id); + } + + if (groupKey !== undefined) { + const managed = state.resources[groupKey]; + if (!managed || managed.type !== "group") { + throw new Error( + `--group "${groupKey}" is not a managed group in this state file. Adopt or declare it first.`, + ); + } + if (!dynamicIds.has(managed.id)) { + throw new Error( + `--group "${groupKey}" (#${managed.id}) is not a dynamic group on this host — there is no ruleset to evaluate.`, + ); + } + return [managed]; + } + + const all = Object.values(state.resources).filter((r) => r.type === "group" && dynamicIds.has(r.id)); + // Only ever the MANAGED ones: `ct` never touches a group the config does not own, and the + // all-groups /dynamicgroups/refresh endpoint (huge blast radius) is deliberately never called. + if (all.length > 0) warn(`Refreshing ${all.length} managed dynamic group(s) — this recomputes membership.`); + return all; +} diff --git a/src/config/context.ts b/src/config/context.ts index b4f9733..e4daf81 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -15,7 +15,8 @@ import { basename } from "node:path"; import { fileURLToPath } from "node:url"; import type { DesiredResource, DynamicSpec, DynamicStatus } from "../engine/types.js"; import type { DomainType } from "../permissions/grants.js"; -import type { DesiredPermission, Grant } from "../permissions/types.js"; +import type { DesiredPermission, Grant, PreserveUnknown } from "../permissions/types.js"; +import { KNOWN_SCOPE_FIELDS } from "../permissions/catalog.js"; import { GROUP_STATUS_NO_CATALOG, isRef, ref, refKey, type Ref } from "../resolve/refs.js"; import { normalizeScopeEntry } from "../permissions/scope.js"; import { conventionalRulesetRef, knownFields } from "../resources/registry.js"; @@ -130,6 +131,12 @@ export interface PermissionInput { /** `status`: the PERSON status by name/key (`/statuses`) — sugars into a Ref-valued domainId (#90). */ personStatus?: string; grants: Grant[]; + /** + * Opt in to keeping live grants this declaration does not mention, instead of revoking them (#102). + * `true` for all of them, or a list of scope dimensions (`["cc_html_template"]`) to keep the escape + * hatch's blast radius to the dimensions you consciously excluded. See {@link PreserveUnknown}. + */ + preserveUnknown?: PreserveUnknown; } /** Logical id-field sugar for declarations: a named string field → a Ref-valued numeric id field. @@ -184,7 +191,9 @@ function resolveDomainInput(domainType: DomainType, input: PermissionInput): num if (input.personStatus !== undefined) { if (hasId) throw bothError('"personStatus"'); if (typeof input.personStatus !== "string" || !input.personStatus) - throw new Error(`${domainType} "${input.key}": "personStatus" must be a non-empty person-status key.`); + throw new Error( + `${domainType} "${input.key}": "personStatus" must be a non-empty person-status key.`, + ); return ref.personStatus(input.personStatus); } } else { @@ -206,6 +215,43 @@ function resolveDomainInput(domainType: DomainType, input: PermissionInput): num return input.id; } +/** + * Validate and normalise `preserveUnknown` (#102). Every failure is an eval-time error, because the + * whole feature is an escape hatch from a destructive default: a typo'd dimension that silently + * preserves nothing is indistinguishable from "nothing needed preserving" — right up until an apply + * revokes 41 live grants. `undefined` (the strict default) passes straight through. + */ +function normalizePreserveUnknown( + domainType: DomainType, + input: PermissionInput, +): PreserveUnknown | undefined { + const value = input.preserveUnknown; + if (value === undefined || value === false) return undefined; // false ≡ the default; don't carry it + if (value === true) return true; + if (!Array.isArray(value) || value.some((d) => typeof d !== "string" || d.length === 0)) { + throw new Error( + `${domainType} "${input.key}": "preserveUnknown" must be true or an array of scope dimension ` + + `names (e.g. ["cc_html_template"]).`, + ); + } + if (value.length === 0) { + throw new Error( + `${domainType} "${input.key}": "preserveUnknown: []" preserves nothing — omit it for the strict ` + + `default, or name the dimensions to leave alone.`, + ); + } + const unknown = value.filter((d) => !KNOWN_SCOPE_FIELDS.has(d)); + if (unknown.length > 0) { + throw new Error( + `${domainType} "${input.key}": "preserveUnknown" names ${unknown.length === 1 ? "a scope dimension" : "scope dimensions"} ` + + `no right in the permission catalog scopes by: ${unknown.join(", ")}. A dimension that matches ` + + `nothing would preserve nothing and read exactly like "there was nothing to preserve" — check ` + + `the spelling against \`ct get permissions-catalog\`.`, + ); + } + return [...new Set(value)]; +} + export interface ConfigContext { campus(input: ResourceInput): void; group(input: ResourceInput): void; @@ -272,7 +318,16 @@ function desugarDynamic(type: string, key: string, dynamic: unknown): DynamicSpe } function toDesired(type: string, input: ResourceInput, location?: string): DesiredResource { - const { key, parent, parents, dependsOn = [], preventDestroy, dynamic, allowDuplicateName, ...fields } = input; + const { + key, + parent, + parents, + dependsOn = [], + preventDestroy, + dynamic, + allowDuplicateName, + ...fields + } = input; if (!key || typeof key !== "string") { throw new Error(`${type} declaration is missing a string "key".`); } @@ -466,7 +521,8 @@ export function createContext(): { ); } seenDomains.set(domainKey, input.key); - permissions.push({ key: input.key, domainType, domainId, grants }); + const preserveUnknown = normalizePreserveUnknown(domainType, input); + permissions.push({ key: input.key, domainType, domainId, grants, preserveUnknown }); }; const definePermission = (domainType: DomainType) => diff --git a/src/config/query-refs.ts b/src/config/query-refs.ts index 75b09c0..28c9177 100644 --- a/src/config/query-refs.ts +++ b/src/config/query-refs.ts @@ -63,12 +63,71 @@ const ROLE_VAR = "role.id"; */ const ROLE_FIELD_NAME = "groupTypeRoleId"; -/** An id left numeric because no managed logical key mapped to it — collected, not thrown (escape hatch). */ +/** + * Why an id could not be portablized (#101). ChurchTools treats a ruleset as opaque JSON and does + * not validate the ids inside it, so a ruleset carrying prod's `ctgroup.id` applied to dev does not + * error — the auto-group simply collects the wrong people, and `ct plan` stays green because the + * ruleset round-trips byte-identically against the host it was written for. Naming the reason is what + * turns that silent wrongness into a known risk. + */ +export type PortablizeReason = + /** The dimension HAS a logical form, but no managed resource / catalog row carries this id. */ + | "unmanaged" + /** A `role.id`/`groupTypeRoleId` whose row is absent from the `/group/roles` catalog. */ + | "role-unknown" + /** A role resolved, but the group TYPE it belongs to is not managed, so no portable pair exists. */ + | "role-group-type-unmanaged" + /** The dimension has no logical reference form at all (e.g. group statuses — no REST catalog). */ + | "no-ref-kind" + /** + * Reported by {@link scanUnportablized} only: the id sits in an entity position and is therefore + * not portable, and that is ALL that was checked. The rewrite reasons above each assert a fact + * about the host (this group is unmanaged / no such role row exists) that a positional scan with no + * state, no catalogs and no network never established — claiming one of them here would print a + * confident falsehood, and a remedy that fails, for an id whose group IS adopted. + */ + | "left-numeric"; + +/** An id left numeric — collected, not thrown (the escape hatch), and reported with its reason (#101). */ export interface PortablizeWarning { var: string; id: number; + reason: PortablizeReason; + /** Human-readable explanation, ready to print. Never a bare restatement of {@link reason}. */ + detail: string; +} + +/** + * Entity-bearing ChurchQuery vars with NO logical reference form, and why (#101). These are reported + * as left-numeric so a cross-host ruleset's real risk surface is complete — without them, adoption + * would claim "everything portablized" while a `groupStatusId` sat frozen at prod's value. + * + * Deliberately a closed list rather than "every var we don't recognise": a `person.age > 18` operand + * is a literal, not an id, and reporting it would bury the real findings in noise. + */ +const UNPORTABLE_ENTITY_VARS: Readonly> = { + "ctgroup.groupStatusId": "group statuses have no REST catalog (#67) — no logical form exists", +}; + +/** + * The reason text for an id no logical key mapped to, per kind. + * + * Deliberately ID-FREE: {@link formatPortablizeWarnings} merges every id sharing a (var, reason) into + * ONE line and prints the ids itself, so a detail naming one specific id would be stamped across all + * of them — "12, 34, 56 left numeric — group #12 is not under management" reads as if the remedy for + * #12 covers the other two. + */ +function unmanagedDetail(kind: RefKind): string { + return kind === "group" + ? "not under management — `ct adopt group ` for each (then re-adopt) makes them portable" + : `no managed ${kind} on this host carries these ids`; } +/** The scan-mode detail: says only what a positional scan can actually know. See "left-numeric". */ +const LEFT_NUMERIC_DETAIL = + "host-specific id(s) frozen into a cross-host ruleset — re-adopt the group with " + + "`--with-dynamic` to rewrite them into logical references (it reports what, if anything, blocks each)"; + export interface PortablizeOptions { /** Per-kind numeric-id → logical-key maps, supplied by the caller (from state/catalogs). Deterministic. */ idToKeyByKind: Partial>>; @@ -82,6 +141,12 @@ export interface PortablizeOptions { * key. A role whose group type is unmanaged (no key) is left numeric with a warning (escape hatch). */ groupTypeIdToKey?: Map; + /** + * Report-only mode ({@link scanUnportablized}): no maps are supplied, so every reason degrades to + * the neutral `left-numeric` rather than asserting an unchecked one. Internal — callers that + * actually rewrite never set it. + */ + scanOnly?: boolean; } export interface PortablizeResult { @@ -114,17 +179,29 @@ function varNameOf(node: unknown): string | undefined { */ export function portablizeRuleset( ruleset: Record, - { idToKeyByKind, roleCatalog, groupTypeIdToKey }: PortablizeOptions, + { idToKeyByKind, roleCatalog, groupTypeIdToKey, scanOnly }: PortablizeOptions, ): PortablizeResult { const warnings: PortablizeWarning[] = []; - const marker = (kind: RefKind, key: string): SimpleRef => ({ __ctRef: true, kind, key } as SimpleRef); + /** Push either the checked reason, or — in scan mode — the only one a positional scan earned. */ + const warnLeftNumeric = ( + varName: string, + id: number, + checked: () => Omit, + ) => + warnings.push({ + var: varName, + id, + ...(scanOnly ? { reason: "left-numeric" as const, detail: LEFT_NUMERIC_DETAIL } : checked()), + }); + + const marker = (kind: RefKind, key: string): SimpleRef => ({ __ctRef: true, kind, key }) as SimpleRef; const mapScalar = (value: unknown, kind: RefKind, varName: string): unknown => { if (typeof value !== "number") return value; // booleans/strings/nulls are literals, never entity ids const key = idToKeyByKind[kind]?.get(value); if (key !== undefined) return marker(kind, key); - warnings.push({ var: varName, id: value }); + warnLeftNumeric(varName, value, () => ({ reason: "unmanaged", detail: unmanagedDetail(kind) })); return value; }; @@ -143,7 +220,22 @@ export function portablizeRuleset( role: entry.name, } as GroupTypeRoleRef; } - warnings.push({ var: varName, id: value }); + // Two genuinely different failures, kept apart: the role is unknown to `/group/roles` at all, or + // it resolved but its group type is unmanaged. They need different fixes, so they get different + // reasons rather than one "could not portablize". + warnLeftNumeric(varName, value, () => + entry + ? { + reason: "role-group-type-unmanaged", + detail: + "the role's group type is not managed — adopt that group type to make the " + + "(group-type, role) pair portable", + } + : { + reason: "role-unknown", + detail: "no /group/roles row on this host carries these groupTypeRoleIds", + }, + ); return value; }; @@ -166,6 +258,19 @@ export function portablizeRuleset( // Group-type-scoped role var (#76): rewrite siblings through the role catalog, not VAR_REF_KINDS. return node.map((el, i) => (i === varIdx ? el : mapOperand(el, (v) => mapRoleScalar(v, ROLE_VAR)))); } + const unportable = UNPORTABLE_ENTITY_VARS[varName]; + if (unportable !== undefined) { + // A known ENTITY var with no logical form: nothing to rewrite, but it is still a + // host-specific id frozen into a cross-host file, so it is reported rather than swallowed. + node.forEach((el, i) => { + if (i === varIdx) return; + for (const v of Array.isArray(el) ? el : [el]) { + if (typeof v === "number") { + warnings.push({ var: varName, id: v, reason: "no-ref-kind", detail: unportable }); + } + } + }); + } // Unknown var (escape hatch) — recurse structurally, leaving its numeric ids untouched. } return node.map(walk); @@ -175,10 +280,7 @@ export function portablizeRuleset( for (const [k, v] of Object.entries(node as Record)) { // `handleMembership.groupTypeRoleId` sits outside the query, so it has no `{ var }` leaf to key // off — rewrite it by object-key match, through the same role catalog as the `role.id` operand. - out[k] = - k === ROLE_FIELD_NAME && typeof v === "number" - ? mapRoleScalar(v, ROLE_FIELD_NAME) - : walk(v); + out[k] = k === ROLE_FIELD_NAME && typeof v === "number" ? mapRoleScalar(v, ROLE_FIELD_NAME) : walk(v); } return out; } @@ -187,3 +289,56 @@ export function portablizeRuleset( return { ruleset: walk(ruleset) as Record, warnings }; } + +/** + * Find every host-specific numeric id still sitting in an entity position of a ruleset (#101). + * + * This is {@link portablizeRuleset}'s reporting half without the rewrite: it takes a ruleset as + * AUTHORED (logical `{ __ctRef }` markers still un-resolved, plain numbers still plain) and returns + * what would not survive a move to another host. `ct plan` runs it over every declared dynamic group + * so an un-portablized ruleset is a visible, recurring risk rather than a green plan that quietly + * collects the wrong people on the wrong instance — the payload of an auto-group is group + * membership, which in this domain is exactly what carries permission grants. + * + * It reports by POSITION, not by lookup: any number left in a known entity var's operand is + * unportable by construction, because a portablized one would be a `{ __ctRef }` marker instead. So + * it needs no catalogs, no state and no network, and is safe to run on every plan. + */ +export function scanUnportablized(ruleset: unknown): PortablizeWarning[] { + const { warnings } = portablizeRuleset((ruleset ?? {}) as Record, { + // No id→key maps and no role catalog: every numeric entity id therefore fails to map and is + // reported, while an already-portable `{ __ctRef }` marker is not a number and is never flagged. + idToKeyByKind: {}, + // …which also means the "unmanaged" / "role-unknown" verdicts the rewrite path computes were + // never actually checked here — `scanOnly` degrades them to `left-numeric` so this scan states + // only what position proves. Without it, `ct plan` tells someone whose group IS adopted that it + // is not under management, and hands them a `ct adopt` that fails. + scanOnly: true, + }); + return warnings; +} + +/** + * One line per (var, reason), naming the ids — the shape both `ct adopt --with-dynamic` and + * `ct plan` print (#101). Grouped so a ruleset with 30 unmanaged group ids is one readable line, not + * thirty; ids are sorted so the output is stable across runs and diffable in CI logs. + * + * The `detail` is part of the grouping key, not just the reason: merging ids under ONE detail is only + * honest while that detail holds for all of them, so a detail that ever names a specific id splits + * into its own line instead of being stamped across the group (details are kept id-free above). + */ +export function formatPortablizeWarnings(warnings: readonly PortablizeWarning[]): string[] { + const grouped = new Map(); + for (const w of warnings) { + const k = `${w.var} ${w.reason} ${w.detail}`; + const hit = grouped.get(k); + if (hit) { + if (!hit.ids.includes(w.id)) hit.ids.push(w.id); + } else { + grouped.set(k, { var: w.var, detail: w.detail, ids: [w.id] }); + } + } + return [...grouped.values()].map( + (g) => `${g.var}: ${g.ids.sort((a, b) => a - b).join(", ")} left numeric — ${g.detail}`, + ); +} diff --git a/src/coverage/report.ts b/src/coverage/report.ts new file mode 100644 index 0000000..6d80335 --- /dev/null +++ b/src/coverage/report.ts @@ -0,0 +1,313 @@ +/** + * Coverage: what this host has that the config does not manage, and what could be managed (#103). + * + * Every consumer repo was reinventing this audit by hand — joining `/groups?include[]=roles`, + * `/dynamicgroups` and `/permissions/group_role`, then diffing the lot against the state file. Two + * things make it worth owning here rather than leaving to each repo: + * + * - `?include[]=roles` is a non-obvious trick that turns one role lookup per group into a handful of + * paged calls, and nobody finds it without digging; + * - getting it wrong is easy and QUIET. Forgetting the `isInherited` filter inflates the authored + * grant count (714 vs 590 on eqrm prod) and makes several role instances look unmanageable that + * are not. + * + * The DECLARABILITY verdict is the valuable half, and `ct` is the only thing that can compute it: it + * needs to know which scope dimensions have a logical reference form (#98), which are numeric but + * host-independent, and which are module data with no resource behind them at all. + * + * This module is pure — it takes already-fetched rows and returns a report. The command wrapper does + * the I/O, so the whole verdict is unit-testable without a network. + */ +import { KNOWN_AUTH_IDS, SCOPE_FIELD_BY_AUTH_ID } from "../permissions/catalog.js"; +import { normalizeActual, type RawPermission } from "../permissions/grants.js"; +import { ALL_SCOPE_SENTINEL, SCOPE_REF_KIND } from "../permissions/scope.js"; +import { fromInformation } from "../resources/registry.js"; +import type { State } from "../state/state.js"; + +/** + * Scope dimensions whose numeric dataIds mean the same thing on EVERY ChurchTools instance, so a + * numeric literal is portable and the grant is declarable without a logical reference form. + * `cc_securitylevel` is the enumeration 1/2/3 — not a resource, but not host-specific either. + */ +const NUMERIC_UNIVERSAL_SCOPE_FIELDS: ReadonlySet = new Set(["cc_securitylevel"]); + +/** A live (group, role) permission domain — the granularity declarability is decided at. */ +export interface RoleInstance { + /** `groups[].roles[].id` — the `group_role` domainId a `ct.groupRole` declaration targets. */ + domainId: number; + groupId: number; + groupName: string; + groupTypeId: number; + roleName: string; +} + +export interface GroupRow { + id: number; + name: string; + groupTypeId: number; + roles: RoleInstance[]; +} + +/** Why a role instance's grants cannot all be expressed as config today. */ +export interface DeclarabilityVerdict { + declarable: boolean; + /** Authored, non-inherited grants on this domain. */ + grantCount: number; + /** Scope dimensions with no declarable form — the reason it is blocked, named. */ + blockedBy: string[]; + /** Live authIds the permission catalog cannot name (a stale catalog, or a foreign right). */ + unknownAuthIds: number[]; +} + +export interface RoleInstanceCoverage extends RoleInstance { + managedGroupKey: string | null; + verdict: DeclarabilityVerdict; +} + +export interface TypeCoverage { + groupTypeId: number; + name: string; + total: number; + managed: number; + dynamic: number; + /** Unmanaged groups that nevertheless carry authored grants — the real "you are missing this" number. */ + unmanagedWithGrants: number; +} + +export interface CoverageReport { + host: string; + groups: { total: number; managed: number; dynamic: number; managedDynamic: number }; + grants: { + authored: number; + roleInstances: number; + declarable: number; + blocked: number; + blockingDimensions: string[]; + }; + byType: TypeCoverage[]; + roleInstances: RoleInstanceCoverage[]; +} + +export interface CoverageInput { + host: string; + state: State; + groups: GroupRow[]; + /** Group type id → display name, from `/group/grouptypes`. */ + groupTypeNames: Map; + /** Ids from `/dynamicgroups` — which groups are auto-groups on this host. */ + dynamicGroupIds: Set; + /** Every row of `/permissions/group_role`, unfiltered (inherited rows are dropped here). */ + groupRolePermissions: RawPermission[]; +} + +/** + * Decide whether a role instance's live grants could be declared as config today, and if not, which + * scope dimensions block it. + * + * A grant is declarable when its right is nameable AND its scope can be written portably: + * - unscoped rights always are; + * - the `-1` ALL sentinel always is (CT reads it back verbatim on every dimension); + * - a dimension with a logical reference form (#98: group, campus, group type, department) is; + * - a numeric-but-universal dimension (`cc_securitylevel`) is. + * Everything else — calendar categories, HTML templates, wiki categories, OAuth clients — names + * module data this tool has no resource for, so it blocks the whole instance under the strict + * ownership default. (With `preserveUnknown` (#102) those same instances become declarable while + * leaving the module grants alone; the verdict below is the strict one, and the command surfaces the + * blocking dimensions precisely so they can be passed to `preserveUnknown`.) + */ +export function declarability(rows: RawPermission[]): DeclarabilityVerdict { + // `normalizeActual` is what makes the count right: it drops the self-re-adding system baseline and + // every inherited row, so the verdict is about ADMIN-AUTHORED grants — the only ones ct ever owns. + const tuples = normalizeActual(rows).filter((t) => t.type === "grant"); + const blockedBy = new Set(); + const unknownAuthIds = new Set(); + for (const t of tuples) { + if (!KNOWN_AUTH_IDS.has(t.authId)) { + unknownAuthIds.add(t.authId); + continue; + } + const scopeField = SCOPE_FIELD_BY_AUTH_ID.get(t.authId) ?? null; + if (scopeField === null) continue; + if (t.dataId.every((id) => id === ALL_SCOPE_SENTINEL)) continue; + if (SCOPE_REF_KIND[scopeField] !== undefined) continue; + if (NUMERIC_UNIVERSAL_SCOPE_FIELDS.has(scopeField)) continue; + blockedBy.add(scopeField); + } + return { + declarable: blockedBy.size === 0 && unknownAuthIds.size === 0, + grantCount: tuples.length, + blockedBy: [...blockedBy].sort(), + unknownAuthIds: [...unknownAuthIds].sort((a, b) => a - b), + }; +} + +/** Build the whole report from already-fetched rows. Pure: no client, no state mutation. */ +export function buildCoverageReport(input: CoverageInput): CoverageReport { + const { host, state, groups, groupTypeNames, dynamicGroupIds, groupRolePermissions } = input; + + // group id → managed logical key, and the same for role-instance domainIds. + const managedGroupKeyById = new Map(); + for (const r of Object.values(state.resources)) { + if (r.type === "group") managedGroupKeyById.set(r.id, r.key); + } + + const rowsByDomainId = new Map(); + for (const row of groupRolePermissions) { + const list = rowsByDomainId.get(row.domainId); + if (list) list.push(row); + else rowsByDomainId.set(row.domainId, [row]); + } + + const roleInstances: RoleInstanceCoverage[] = []; + // Declarability is per (group, role), NOT per group: on a real instance one group routinely has two + // declarable roles and one blocked one, and reporting at group granularity hides exactly that. + for (const group of groups) { + for (const role of group.roles) { + const rows = rowsByDomainId.get(role.domainId) ?? []; + const verdict = declarability(rows); + if (verdict.grantCount === 0) continue; // no authored grants → nothing to declare, nothing to report + roleInstances.push({ + ...role, + managedGroupKey: managedGroupKeyById.get(group.id) ?? null, + verdict, + }); + } + } + + const groupsWithGrants = new Set(roleInstances.map((r) => r.groupId)); + const byType = new Map(); + for (const group of groups) { + let t = byType.get(group.groupTypeId); + if (!t) { + t = { + groupTypeId: group.groupTypeId, + name: groupTypeNames.get(group.groupTypeId) ?? `#${group.groupTypeId}`, + total: 0, + managed: 0, + dynamic: 0, + unmanagedWithGrants: 0, + }; + byType.set(group.groupTypeId, t); + } + t.total += 1; + const managed = managedGroupKeyById.has(group.id); + if (managed) t.managed += 1; + if (dynamicGroupIds.has(group.id)) t.dynamic += 1; + if (!managed && groupsWithGrants.has(group.id)) t.unmanagedWithGrants += 1; + } + + const blockingDimensions = new Set(); + for (const r of roleInstances) for (const d of r.verdict.blockedBy) blockingDimensions.add(d); + + return { + host, + groups: { + total: groups.length, + managed: groups.filter((g) => managedGroupKeyById.has(g.id)).length, + dynamic: groups.filter((g) => dynamicGroupIds.has(g.id)).length, + managedDynamic: groups.filter((g) => dynamicGroupIds.has(g.id) && managedGroupKeyById.has(g.id)).length, + }, + grants: { + authored: roleInstances.reduce((n, r) => n + r.verdict.grantCount, 0), + roleInstances: roleInstances.length, + declarable: roleInstances.filter((r) => r.verdict.declarable).length, + blocked: roleInstances.filter((r) => !r.verdict.declarable).length, + blockingDimensions: [...blockingDimensions].sort(), + }, + byType: [...byType.values()].sort((a, b) => a.name.localeCompare(b.name)), + roleInstances: roleInstances.sort( + (a, b) => a.groupName.localeCompare(b.groupName) || a.roleName.localeCompare(b.roleName), + ), + }; +} + +/** + * Decode `/groups?include[]=roles` rows into the shape the report wants. Defensive about the role + * row's own field names: the domainId is `roles[].id`, but the role NAME is only sometimes carried + * inline — when it is not, `groupTypeRoleId` is joined against the `/group/roles` catalog. A role + * whose name cannot be recovered either way still counts (it has a domainId and grants); it is just + * labelled by its id, rather than being dropped from the audit. + */ +export function decodeGroupsWithRoles( + rows: Array>, + roleNamesById: Map, +): GroupRow[] { + const out: GroupRow[] = []; + for (const row of rows) { + const id = Number(row.id); + if (!Number.isFinite(id)) continue; + const groupTypeId = Number(fromInformation(row, "groupTypeId")); + const rawRoles = Array.isArray(row.roles) ? (row.roles as Array>) : []; + const roles: RoleInstance[] = []; + for (const r of rawRoles) { + const domainId = Number(r?.id); + if (!Number.isFinite(domainId)) continue; + const groupTypeRoleId = Number(r?.groupTypeRoleId); + const inlineName = typeof r?.name === "string" && r.name.length > 0 ? r.name : undefined; + const roleName = + inlineName ?? + (Number.isFinite(groupTypeRoleId) ? roleNamesById.get(groupTypeRoleId) : undefined) ?? + `role #${domainId}`; + roles.push({ + domainId, + groupId: id, + groupName: typeof row.name === "string" ? row.name : `#${id}`, + groupTypeId: Number.isFinite(groupTypeId) ? groupTypeId : -1, + roleName, + }); + } + out.push({ + id, + name: typeof row.name === "string" ? row.name : `#${id}`, + groupTypeId: Number.isFinite(groupTypeId) ? groupTypeId : -1, + roles, + }); + } + return out; +} + +/** Render the report the way the issue sketched it: totals, a per-type table, then the verdict. */ +export function renderCoverage(report: CoverageReport): string { + const lines: string[] = []; + const g = report.groups; + lines.push( + `${g.total} groups · ${g.managed} managed · ${report.grants.authored} authored grants over ` + + `${report.grants.roleInstances} role instances`, + ); + lines.push(""); + const nameWidth = Math.max(12, ...report.byType.map((t) => t.name.length)); + lines.push( + `${"by type".padEnd(nameWidth)} ${"total".padStart(5)} ${"managed".padStart(7)} ` + + `${"dynamic".padStart(7)} unmanaged+grants`, + ); + for (const t of report.byType) { + lines.push( + ` ${t.name.padEnd(nameWidth - 2)} ${String(t.total).padStart(5)} ${String(t.managed).padStart(7)} ` + + `${String(t.dynamic).padStart(7)} ${String(t.unmanagedWithGrants).padStart(16)}`, + ); + } + lines.push(""); + lines.push( + `grants: ${report.grants.declarable} role instance(s) declarable · ${report.grants.blocked} blocked`, + ); + if (report.grants.blockingDimensions.length > 0) { + lines.push(` blocked by: ${report.grants.blockingDimensions.join(", ")}`); + lines.push( + ` (a blocked instance becomes declarable with \`preserveUnknown: [, …]\` — see #102)`, + ); + } + return lines.join("\n"); +} + +/** The per-role-instance detail lines, printed under `--verbose`/`--declarable`. */ +export function renderRoleInstances(instances: readonly RoleInstanceCoverage[]): string { + return instances + .map((r) => { + const managed = r.managedGroupKey ? `managed as "${r.managedGroupKey}"` : "unmanaged"; + const verdict = r.verdict.declarable + ? "declarable" + : `blocked by ${[...r.verdict.blockedBy, ...r.verdict.unknownAuthIds.map((a) => `authId ${a}`)].join(", ")}`; + return ` ${r.groupName} / ${r.roleName} (domainId ${r.domainId}, ${r.verdict.grantCount} grant(s), ${managed}): ${verdict}`; + }) + .join("\n"); +} diff --git a/src/engine/dynamic.ts b/src/engine/dynamic.ts index fe17a4d..0753f90 100644 --- a/src/engine/dynamic.ts +++ b/src/engine/dynamic.ts @@ -61,7 +61,7 @@ function dropReadOnly(rule: Record): Record { /** Canonicalise a ruleset for diffing: unwrap array/PUT envelope, drop timestamps; strip labels + coerce scalars WITHIN the query subtree only. */ export function normalizeRuleset(rule: unknown): Record { let r: unknown = rule ?? {}; - if (Array.isArray(r)) r = r[0] ?? {}; // GET returns a single-element [RuleSet] + if (Array.isArray(r)) r = r[0] ?? {}; // GET returns a single-element [RuleSet] let obj = (r ?? {}) as Record; if (obj.dynamicGroupRuleSet && typeof obj.dynamicGroupRuleSet === "object") { // Defensive: tolerate the `{ dynamicGroupRuleSet: ... }` wrapper if one is ever fed through @@ -91,13 +91,16 @@ export function normalizeRuleset(rule: unknown): Record { * The single source of truth for the PUT envelope — every writer (apply path, live-gated tests) * must go through this so the envelope can't drift out of sync again. */ -export function putRulesetBody( - ruleset: Record, -): { dynamicGroupRuleSet: [Record] } { +export function putRulesetBody(ruleset: Record): { + dynamicGroupRuleSet: [Record]; +} { return { dynamicGroupRuleSet: [ruleset] }; } -export interface NormalizedDynamic { status: DynamicStatus; ruleset: Record } +export interface NormalizedDynamic { + status: DynamicStatus; + ruleset: Record; +} export function normalizeDynamic(spec: { status: DynamicStatus; ruleset: unknown }): NormalizedDynamic { return { status: spec.status, ruleset: normalizeRuleset(spec.ruleset) }; @@ -109,7 +112,11 @@ export function normalizeDynamic(spec: { status: DynamicStatus; ruleset: unknown * so a config is portable regardless of where `ct` is invoked. Missing/unreadable/invalid-JSON * ref files raise a clear error naming the group and the resolved path instead of a raw ENOENT. */ -export function resolveRulesetRef(ruleset: unknown, baseDir: string = process.cwd(), groupKey?: string): unknown { +export function resolveRulesetRef( + ruleset: unknown, + baseDir: string = process.cwd(), + groupKey?: string, +): unknown { if (ruleset && typeof ruleset === "object" && typeof (ruleset as { ref?: unknown }).ref === "string") { const ref = (ruleset as { ref: string }).ref; const p = resolve(baseDir, ref); @@ -118,12 +125,16 @@ export function resolveRulesetRef(ruleset: unknown, baseDir: string = process.cw try { raw = readFileSync(p, "utf8"); } catch (err) { - throw new Error(`${where}: cannot read ruleset ref "${ref}" (resolved to ${p}): ${(err as Error).message}`); + throw new Error( + `${where}: cannot read ruleset ref "${ref}" (resolved to ${p}): ${(err as Error).message}`, + ); } try { return JSON.parse(raw); } catch (err) { - throw new Error(`${where}: ruleset ref "${ref}" (resolved to ${p}) is not valid JSON: ${(err as Error).message}`); + throw new Error( + `${where}: ruleset ref "${ref}" (resolved to ${p}) is not valid JSON: ${(err as Error).message}`, + ); } } return ruleset; diff --git a/src/engine/execute.ts b/src/engine/execute.ts index 5131d20..22b2242 100644 --- a/src/engine/execute.ts +++ b/src/engine/execute.ts @@ -94,7 +94,10 @@ function snapshotFromChanges(base: Record, changes: FieldChange } async function applySyntheticFields( - client: Pick, state: State, id: number, changes: FieldChange[], + client: Pick, + state: State, + id: number, + changes: FieldChange[], ): Promise { for (const c of changes) { const f = syntheticField(c.field); @@ -207,7 +210,12 @@ export async function executePlan(plan: Plan, deps: ExecuteDeps): Promise re.test(path))) { - throw new Error( - `Refusing to write to "${path}": people/memberships are never managed by this tool.`, - ); + throw new Error(`Refusing to write to "${path}": people/memberships are never managed by this tool.`); } } diff --git a/src/engine/render.ts b/src/engine/render.ts index 638ca54..848596d 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -88,7 +88,9 @@ export function renderPlan(plan: Plan): string { if (fetchFailed.length > 0) { lines.push(""); - lines.push(pc.yellow("Fetch failed (could not read from ChurchTools — diff unavailable, left untouched):")); + lines.push( + pc.yellow("Fetch failed (could not read from ChurchTools — diff unavailable, left untouched):"), + ); for (const item of fetchFailed) { lines.push(` ? ${item.type}.${item.key} (#${item.id}) — fetch failed (${item.detail ?? "error"})`); } diff --git a/src/engine/synthetic.ts b/src/engine/synthetic.ts index b6e0fe6..c39b389 100644 --- a/src/engine/synthetic.ts +++ b/src/engine/synthetic.ts @@ -15,6 +15,7 @@ import { deepEqual } from "./plan.js"; import { mapConcurrent } from "../util/concurrency.js"; import { info, warn, formatError } from "../ui.js"; import { normalizeDynamic, normalizeRuleset, putRulesetBody, resolveRulesetRef } from "./dynamic.js"; +import { formatPortablizeWarnings, scanUnportablized } from "../config/query-refs.js"; import type { DynamicStatus } from "./types.js"; /** How many dynamic groups to fetch (ruleset + status) from ChurchTools at once. Mirrors build.ts. */ @@ -145,7 +146,9 @@ const dynamicField: SyntheticField = { return [`dynamic ${managed.key} (#${managed.id}): ${formatError(err)}`]; } try { - const statusRes = await client.get<{ dynamicGroupStatus?: string }>(`/dynamicgroups/${managed.id}/status`); + const statusRes = await client.get<{ dynamicGroupStatus?: string }>( + `/dynamicgroups/${managed.id}/status`, + ); a.dynamic = { status: (statusRes?.dynamicGroupStatus ?? "none") as DynamicStatus, ruleset: normalizeRuleset(ruleset), @@ -162,10 +165,23 @@ const dynamicField: SyntheticField = { // ({ status: "none", ruleset: {} }). The docs tell users to KEEP the dynamic block when // demoting, so their authored ruleset is still present here — but folding it would diff // forever against the sentinel actual. Collapsing both sides makes a demoted group converge. - const dynamic = - d.dynamic.status === "none" - ? { status: "none" as DynamicStatus, ruleset: {} } - : normalizeDynamic({ status: d.dynamic.status, ruleset: resolveRulesetRef(d.dynamic.ruleset, configDir, d.key) }); + if (d.dynamic.status === "none") { + return { ...d, fields: { ...d.fields, dynamic: { status: "none" as DynamicStatus, ruleset: {} } } }; + } + const resolvedRuleset = resolveRulesetRef(d.dynamic.ruleset, configDir, d.key); + // Un-portablized ids report at PLAN time too (#101), not only at adoption. A ruleset carrying + // another host's ids round-trips byte-identically against the host it was written for, so the + // plan is green and the damage — an auto-group collecting the wrong people — is invisible until + // someone notices the membership. Warn, never fail: the numeric form stays a valid escape hatch. + const unportable = scanUnportablized(resolvedRuleset); + if (unportable.length > 0) { + warn( + `dynamic group "${d.key}": ruleset carries ${unportable.length} host-specific id(s) — ` + + `not portable to another instance:`, + ); + for (const line of formatPortablizeWarnings(unportable)) info(` ${line}`); + } + const dynamic = normalizeDynamic({ status: d.dynamic.status, ruleset: resolvedRuleset }); return { ...d, fields: { ...d.fields, dynamic } }; }); return { desired: augmented, errors }; diff --git a/src/env/context.ts b/src/env/context.ts index 06e4da9..ca9623b 100644 --- a/src/env/context.ts +++ b/src/env/context.ts @@ -53,10 +53,7 @@ async function wireEnv(opts: EnvOpts, env: NodeJS.ProcessEnv): Promise { +export async function prepareEnv(opts: EnvOpts, env: NodeJS.ProcessEnv = process.env): Promise { const profile = await wireEnv(opts, env); const statePath = resolveStatePath(opts.state, env, profile?.statePath); return { name: profile?.name ?? null, protected: profile?.protected ?? false, statePath }; diff --git a/src/env/envs.ts b/src/env/envs.ts index 9f8338c..a8da2a1 100644 --- a/src/env/envs.ts +++ b/src/env/envs.ts @@ -87,9 +87,7 @@ async function loadEnvsFile(path: string): Promise> { try { parsed = JSON.parse(raw); } catch (err) { - throw new Error( - `Malformed environments file ${path}: not valid JSON (${(err as Error).message}).`, - ); + throw new Error(`Malformed environments file ${path}: not valid JSON (${(err as Error).message}).`); } return validateEnvsFile(parsed, path); } diff --git a/src/index.ts b/src/index.ts index e816e5a..eab581c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,9 @@ import { authCommand } from "./commands/auth.js"; import { getCommand } from "./commands/get.js"; import { adoptCommand } from "./commands/adopt.js"; import { stateCommand } from "./commands/state.js"; +import { coverageCommand } from "./commands/coverage.js"; +import { permissionsCommand } from "./commands/permissions.js"; +import { refreshCommand } from "./commands/refresh.js"; import { planCommand } from "./commands/plan.js"; import { applyCommand } from "./commands/apply.js"; import { destroyCommand } from "./commands/destroy.js"; @@ -25,6 +28,9 @@ export function buildProgram(): Command { program.addCommand(getCommand()); program.addCommand(adoptCommand()); program.addCommand(stateCommand()); + program.addCommand(coverageCommand()); + program.addCommand(permissionsCommand()); + program.addCommand(refreshCommand()); program.addCommand(planCommand()); program.addCommand(applyCommand()); program.addCommand(destroyCommand()); diff --git a/src/permissions/README.md b/src/permissions/README.md index ae4df85..34219fd 100644 --- a/src/permissions/README.md +++ b/src/permissions/README.md @@ -21,9 +21,24 @@ changes materially. ```json { - "$meta": { "capturedFrom": "eqrm.church.tools", "ctVersion": "3.134.0", "capturedAt": "2026-07-08", "rightCount": 187 }, - "churchgroup:view group": { "authId": 1104, "scopeField": "cdb_gruppe", "revocable": false, "desc": "View group incl. its group members" }, - "churchcore:administer settings": { "authId": 1, "scopeField": null, "revocable": false, "desc": "Edit system settings" } + "$meta": { + "capturedFrom": "eqrm.church.tools", + "ctVersion": "3.134.0", + "capturedAt": "2026-07-08", + "rightCount": 187 + }, + "churchgroup:view group": { + "authId": 1104, + "scopeField": "cdb_gruppe", + "revocable": false, + "desc": "View group incl. its group members" + }, + "churchcore:administer settings": { + "authId": 1, + "scopeField": null, + "revocable": false, + "desc": "Edit system settings" + } } ``` diff --git a/src/permissions/adopt.ts b/src/permissions/adopt.ts index 84dd025..b0f4ec1 100644 --- a/src/permissions/adopt.ts +++ b/src/permissions/adopt.ts @@ -90,13 +90,41 @@ function collapse(tuples: GrantTuple[]): CollapsedGrant[] { * 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: { +export interface AdoptGrantsArgs { domainType: DomainType; domainId: number; rows: RawPermission[]; state: State; -}): string { - const { domainType, domainId, rows, state } = args; + /** + * The PORTABLE domain form (#104): the managed group's logical key + the role name, which the + * resolver maps back to this host's pairing domainId at plan time. Emitted instead of the numeric + * `id:` whenever the group is managed — the numeric id is host-specific, so it is exactly the edit a + * human forgets on the 30th paste. Absent ⇒ fall back to `id:`. + */ + domain?: { group: string; role: string }; + /** Logical key for the emitted block. Defaults to `_` (the pre-#104 shape). */ + key?: string; +} + +/** What {@link buildAdoptedGrants} produced, so a BULK caller can decide whether it is safe to emit. */ +export interface AdoptedGrantsBlock { + block: string; + /** + * How many LIVE grants were left as comments. Non-zero means applying the block as-is would REVOKE + * them — the one outcome the WARNING footer exists to prevent, and the reason bulk mode skips such + * blocks instead of printing 44 of them and hoping every comment gets read. + */ + omitted: number; + /** Pre-existing deny rows on this domain; preserved by the reconciler, never emitted. */ + revokes: number; +} + +export function emitAdoptedGrants(args: AdoptGrantsArgs): string { + return buildAdoptedGrants(args).block; +} + +export function buildAdoptedGrants(args: AdoptGrantsArgs): AdoptedGrantsBlock { + const { domainType, domainId, rows, state, domain, key } = args; const normalized = normalizeActual(rows); const grants = normalized.filter((t) => t.type === "grant"); const revokes = normalized.filter((t) => t.type !== "grant"); @@ -105,8 +133,20 @@ export function emitAdoptedGrants(args: { 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 (key !== undefined) { + body.push(` key: ${JSON.stringify(key)},`); + } else { + body.push( + ` key: "${domainType}_${domainId}", // a logical key, unique across the config — rename to taste`, + ); + } + if (domain) { + // Portable form: resolved per host at plan time, so the same block applies to dev and prod. + body.push(` group: ${JSON.stringify(domain.group)},`); + body.push(` role: ${JSON.stringify(domain.role)},`); + } else { + body.push(` id: ${domainId}, // host-specific — adopt the group to emit the portable group + role form`); + } if (grants.length === 0) { body.push(" grants: [], // no user-authored grants on this domain (baseline/inherited rows excluded)"); @@ -127,9 +167,15 @@ export function emitAdoptedGrants(args: { // 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( + `// 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); @@ -144,7 +190,7 @@ export function emitAdoptedGrants(args: { lines.push("// config is not supported yet (see issue #25 stretch goal)."); } - return lines.join("\n"); + return { block: lines.join("\n"), omitted, revokes: revokes.length }; } /** The lines emitted for one collapsed grant, plus whether a LIVE grant was left as a comment @@ -162,11 +208,7 @@ interface GrantLinesResult { * reject (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, -): GrantLinesResult { +function grantLines(g: CollapsedGrant, rev: Map, state: State): 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 @@ -198,8 +240,12 @@ function grantLines( const out: string[] = []; let omitted = false; if (g.hasUnscoped) { - 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( + ` // 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; } @@ -248,7 +294,9 @@ function grantLines( // Reached only for a dimension with NO logical form at all (`cc_securitylevel`, `oauth_client`, // …). A catalog-only dimension (`cdb_bereich`) already got its own NOTE above — emitting this // line there too would contradict it ("not a group, use numbers" vs "portable form exists"). - out.push(` // "${entry.name}" scopes by "${entry.scopeField}", not a group — using its numeric dataId(s) directly.`); + out.push( + ` // "${entry.name}" scopes by "${entry.scopeField}", not a group — using its numeric dataId(s) directly.`, + ); } out.push(` { right: ${JSON.stringify(entry.name)}, scope: [${entries.join(", ")}] },`); } @@ -275,8 +323,12 @@ function grantLines( // 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( + ` // 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; } @@ -293,7 +345,9 @@ function grantLines( } 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 */] },`); + out.push( + ` // { right: ${JSON.stringify(entry.name)}, scope: [/* adopt the group(s) above first */] },`, + ); } return { lines: out, omitted }; } @@ -306,7 +360,9 @@ function grantLines( 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.`); + out.push( + ` // ${g.dataIds.join(", ")} — the catalog may be stale. Regenerate it, then re-adopt.`, + ); omitted = true; } if (g.hasUnscoped) { diff --git a/src/permissions/apply.ts b/src/permissions/apply.ts index 1ecffbe..837fc3d 100644 --- a/src/permissions/apply.ts +++ b/src/permissions/apply.ts @@ -22,7 +22,9 @@ function body(t: GrantTuple): Record { if (t.pending) { // A pending tuple's dataId is unknown until it is re-resolved against post-execute state. // Reaching here means re-resolution was skipped — refuse rather than emit a silent GLOBAL grant. - throw new Error(`Grant scoped to "${t.scopeKey}" was not re-resolved before apply — refusing to write it without a dataId.`); + throw new Error( + `Grant scoped to "${t.scopeKey}" was not re-resolved before apply — refusing to write it without a dataId.`, + ); } const b: Record = { authId: t.authId, type: t.type }; if (t.dataId.length) b.dataId = t.dataId; // omit when unscoped diff --git a/src/permissions/catalog-store.ts b/src/permissions/catalog-store.ts new file mode 100644 index 0000000..4614432 --- /dev/null +++ b/src/permissions/catalog-store.ts @@ -0,0 +1,165 @@ +/** + * The per-instance permission catalog (#105). + * + * The name↔authId catalog is not exposed by the REST API, so it ships as a JSON snapshot captured + * from ONE ChurchTools version. Every plan against an instance on a different version printed: + * + * ! Permission catalog was captured from ChurchTools 3.134.0 but this instance runs 3.135.2. + * … regenerate it with `npm run regenerate:permission-catalog` + * + * …naming a script that lives in the ct-cli repo. A consumer repo could not act on it short of + * opening a PR here and waiting for a release, so the warning was unactionable exactly where it was + * printed, and it printed on every single plan — which trains people to ignore it, including on the + * plans where a stale authId would actually matter. + * + * The fix is to let a consumer repo capture the catalog for ITS OWN host and commit the result: + * `ct permissions catalog --refresh` writes `.ct/permission-catalog..json`, and every + * plan/apply against that host loads it in preference to the bundled snapshot. The bundled catalog + * stays the fallback, so nothing changes for a repo that never runs the refresh. + */ +import { readFile, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { CtClient } from "../api/ctClient.js"; +import { useCatalog, type CatalogEntry } from "./catalog.js"; + +/** Directory a consumer repo commits its per-instance captures into, beside the config/state files. */ +export const CATALOG_DIR = ".ct"; + +/** Host → filename component. Keeps one file per instance, so dev and prod captures coexist. */ +export function hostSlug(host: string): string { + return host + .replace(/^https?:\/\//, "") + .replace(/\/+$/, "") + .replace(/[^a-zA-Z0-9._-]/g, "_"); +} + +export function hostCatalogPath(host: string, dir: string = CATALOG_DIR): string { + return join(dir, `permission-catalog.${hostSlug(host)}.json`); +} + +/** + * Load the per-instance catalog for `host`, if one has been committed, and make it the active one. + * Returns the path it loaded, or `null` when there is none (the bundled catalog stays active). + * + * A malformed file THROWS rather than silently falling back: a repo that committed a capture is + * relying on it, and quietly planning against a different catalog than the author thinks is in use is + * the failure mode this whole feature exists to remove. + */ +export async function loadHostCatalog(host: string, dir: string = CATALOG_DIR): Promise { + const path = hostCatalogPath(host, dir); + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (err) { + if ((err as { code?: string }).code === "ENOENT") return null; + throw err; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`Malformed permission catalog ${path}: not valid JSON (${(err as Error).message}).`); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`Malformed permission catalog ${path}: expected a JSON object at the top level.`); + } + assertCatalogShape(parsed as Record, path); + useCatalog(parsed, { perInstance: true }); + return path; +} + +/** + * Check every right entry, not just the top level. "It parsed as an object" is far too weak a gate + * for a file that decides what a permission NAME means: an entry missing its `authId` still resolves + * truthily, so `resolveAuthId` hands back `{ authId: undefined }`, the tuple matches no actual, and + * `ct apply` PUTs a permission row with `authId: undefined`. A hand-edited, half-merged or + * future-shaped capture must fail here, loudly, rather than three layers downstream on a write. + */ +function assertCatalogShape(parsed: Record, path: string): void { + for (const [name, value] of Object.entries(parsed)) { + if (name === "$meta") continue; // reserved provenance key — not a right (see catalog.ts) + const bad = (why: string): never => { + throw new Error(`Malformed permission catalog ${path}: right "${name}" ${why}.`); + }; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + bad("is not an object"); + } + const entry = value as Record; + if (typeof entry.authId !== "number" || !Number.isFinite(entry.authId)) { + bad("has no numeric authId"); + } + if (entry.scopeField !== null && typeof entry.scopeField !== "string") { + bad("has a scopeField that is neither a string nor null"); + } + } +} + +/** The legacy master-data shape (`auth_table[module][right]`) — see `capturePermissionCatalog`. */ +interface RawRight { + id: number; + datenfeld?: string | null; + bezeichnung?: string | null; + isRevocable?: boolean | number; +} +interface MasterData { + data?: { auth_table?: Record> }; + auth_table?: Record>; +} + +/** + * Capture the catalog from a live instance and return it in `catalog.json`'s exact schema. + * + * The data comes from the legacy AJAX endpoint the permission editor itself uses — the catalog is + * genuinely absent from the REST API, so there is no cleaner source. Any authenticated session that + * can open Settings → Permissions can call it, which is why a consumer repo can now do this for + * itself. One read; it never writes to the instance. + */ +export async function capturePermissionCatalog( + client: Pick, +): Promise> { + const master = (await client.legacyPostForm("churchauth/ajax", { func: "getMasterData" })) as MasterData; + const authTable = master?.data?.auth_table ?? master?.auth_table; + if (!authTable || typeof authTable !== "object") { + throw new Error( + "Unexpected response from churchauth/ajax getMasterData: no data.auth_table. The legacy endpoint " + + "or its shape may have changed — the bundled catalog is still in use.", + ); + } + const rights: Record = {}; + for (const [moduleName, moduleRights] of Object.entries(authTable)) { + for (const [rightName, raw] of Object.entries(moduleRights)) { + const field = raw.datenfeld; + rights[`${moduleName}:${rightName}`] = { + authId: raw.id, + scopeField: field && String(field).length > 0 ? String(field) : null, + revocable: Boolean(raw.isRevocable), + desc: raw.bezeichnung ? String(raw.bezeichnung) : "", + }; + } + } + const host = client.host.replace(/^https?:\/\//, ""); + return { + // Reserved provenance key, split off by catalog.ts and never seen as a right. + $meta: { + capturedFrom: host, + ctVersion: client.version ?? "unknown", + capturedAt: new Date().toISOString().slice(0, 10), + rightCount: Object.keys(rights).length, + source: "POST /index.php?q=churchauth/ajax func=getMasterData", + regenerate: `ct permissions catalog --refresh (writes ${hostCatalogPath(client.host)})`, + }, + ...rights, + }; +} + +/** Write a capture to this host's per-instance path, creating `.ct/` if needed. Returns the path. */ +export async function writeHostCatalog( + host: string, + catalog: Record, + dir: string = CATALOG_DIR, +): Promise { + await mkdir(dir, { recursive: true }); + const path = hostCatalogPath(host, dir); + await writeFile(path, `${JSON.stringify(catalog, null, 1)}\n`, "utf8"); + return path; +} diff --git a/src/permissions/catalog.json b/src/permissions/catalog.json index e14884f..b66e78a 100644 --- a/src/permissions/catalog.json +++ b/src/permissions/catalog.json @@ -1,1132 +1,1132 @@ { - "$meta": { - "capturedFrom": "eqrm.church.tools", - "ctVersion": "3.134.0", - "capturedAt": "2026-07-08", - "rightCount": 187, - "source": "POST /index.php?q=churchauth/ajax func=getMasterData", - "regenerate": "npm run regenerate:permission-catalog (see src/permissions/README.md)" - }, - "churchcore:administer settings": { - "authId": 1, - "scopeField": null, - "revocable": false, - "desc": "Edit system settings" - }, - "churchcore:edit public profiles": { - "authId": 10, - "scopeField": null, - "revocable": false, - "desc": "Manage public profiles" - }, - "churchcore:view website": { - "authId": 11, - "scopeField": null, - "revocable": false, - "desc": "View overview" - }, - "churchcore:edit website staff": { - "authId": 12, - "scopeField": null, - "revocable": false, - "desc": "Manage users" - }, - "churchcore:edit website releases": { - "authId": 13, - "scopeField": null, - "revocable": false, - "desc": "Manage permissions" - }, - "churchcore:administer persons": { - "authId": 2, - "scopeField": null, - "revocable": false, - "desc": "Manage permissions" - }, - "churchcore:view logfile": { - "authId": 3, - "scopeField": null, - "revocable": false, - "desc": "View log" - }, - "churchcore:edit languages": { - "authId": 9, - "scopeField": "cc_language", - "revocable": false, - "desc": "Edit translations" - }, - "churchcore:edit translations masterdata": { - "authId": 5, - "scopeField": null, - "revocable": false, - "desc": "Manage languages" - }, - "churchcore:simulate persons": { - "authId": 7, - "scopeField": null, - "revocable": false, - "desc": "Simulate persons, change password and block access" - }, - "churchcore:invite persons": { - "authId": 6, - "scopeField": null, - "revocable": false, - "desc": "Invite person to ChurchTools" - }, - "churchcore:administer church html templates": { - "authId": 16, - "scopeField": null, - "revocable": false, - "desc": "Manage church HTML templates" - }, - "churchcore:use church html templates": { - "authId": 17, - "scopeField": "cc_html_template", - "revocable": false, - "desc": "Use church HTML templates" - }, - "churchcore:view links": { - "authId": 19, - "scopeField": "cc_further_link", - "revocable": false, - "desc": "View the entries in the links-widget" - }, - "churchcore:login to external system": { - "authId": 18, - "scopeField": "oauth_client", - "revocable": false, - "desc": "Log in to external system with ChurchTools" - }, - "churchcore:administer custom modules": { - "authId": 15, - "scopeField": null, - "revocable": false, - "desc": "Manage extensions" - }, - "churchcheckin:view": { - "authId": 601, - "scopeField": null, - "revocable": false, - "desc": "View \"Check-in\"" - }, - "churchcheckin:create person": { - "authId": 602, - "scopeField": null, - "revocable": false, - "desc": "Create people" - }, - "churchcheckin:edit masterdata": { - "authId": 699, - "scopeField": null, - "revocable": false, - "desc": "Manage printer" - }, - "churchdb:view": { - "authId": 101, - "scopeField": null, - "revocable": false, - "desc": "View \"People\"" - }, - "churchdb:view station": { - "authId": 124, - "scopeField": "cdb_station", - "revocable": false, - "desc": "View campus" - }, - "churchdb:view alldata": { - "authId": 102, - "scopeField": "cdb_bereich", - "revocable": false, - "desc": "View people of a department" - }, - "churchdb:security level person": { - "authId": 125, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "View person data - up to the selected security level" - }, - "churchdb:security level view own data": { - "authId": 131, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "View your own personal data - up to the selected security level" - }, - "churchdb:security level edit own data": { - "authId": 132, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "Edit own personal data - up to the selected security level" - }, - "churchdb:create person": { - "authId": 119, - "scopeField": null, - "revocable": false, - "desc": "Create people" - }, - "churchdb:write access": { - "authId": 111, - "scopeField": null, - "revocable": false, - "desc": "Edit people" - }, - "churchdb:delete persons": { - "authId": 129, - "scopeField": null, - "revocable": false, - "desc": "Delete persons" - }, - "churchdb:push/pull archive": { - "authId": 118, - "scopeField": null, - "revocable": false, - "desc": "Archive people" - }, - "churchdb:view archive": { - "authId": 116, - "scopeField": null, - "revocable": false, - "desc": "View archive of people" - }, - "churchdb:view statistics": { - "authId": 106, - "scopeField": null, - "revocable": false, - "desc": "View person statistics" - }, - "churchdb:view person tags": { - "authId": 107, - "scopeField": null, - "revocable": false, - "desc": "View tags of people " - }, - "churchdb:view comments": { - "authId": 113, - "scopeField": "cdb_comment_viewer", - "revocable": false, - "desc": "View comments of people" - }, - "churchdb:export data": { - "authId": 112, - "scopeField": null, - "revocable": false, - "desc": "Export data of people" - }, - "churchdb:edit bulkletter": { - "authId": 128, - "scopeField": null, - "revocable": false, - "desc": "Create bulk letters" - }, - "churchdb:create print labels": { - "authId": 130, - "scopeField": null, - "revocable": false, - "desc": "Create labels" - }, - "churchdb:send sms": { - "authId": 117, - "scopeField": null, - "revocable": false, - "desc": "Use SMS interface" - }, - "churchdb:view person history": { - "authId": 108, - "scopeField": null, - "revocable": false, - "desc": "View person history" - }, - "churchdb:edit relations": { - "authId": 109, - "scopeField": null, - "revocable": false, - "desc": "Edit relations" - }, - "churchdb:view memberliste": { - "authId": 122, - "scopeField": null, - "revocable": false, - "desc": "View member list" - }, - "churchdb:complex filter": { - "authId": 120, - "scopeField": null, - "revocable": false, - "desc": "Use \"additional filters\"" - }, - "churchdb:administer global filters": { - "authId": 127, - "scopeField": null, - "revocable": false, - "desc": "Administer global filters" - }, - "churchdb:edit masterdata": { - "authId": 199, - "scopeField": null, - "revocable": false, - "desc": "Edit master data" - }, - "churchdb:+see group": { - "authId": 10102, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "View group infos" - }, - "churchdb:+edit group infos": { - "authId": 10122, - "scopeField": null, - "revocable": false, - "desc": "Edit group info" - }, - "churchdb:+edit group basic settings": { - "authId": 10116, - "scopeField": null, - "revocable": false, - "desc": "Edit basic group settings (group status, visibility, registration)" - }, - "churchdb:+admin group member fields": { - "authId": 10117, - "scopeField": null, - "revocable": false, - "desc": "Manage group member fields" - }, - "churchdb:+edit group hierarchy": { - "authId": 10114, - "scopeField": null, - "revocable": false, - "desc": "Edit group hierarchy" - }, - "churchdb:+create group": { - "authId": 10115, - "scopeField": "cdb_gruppentyp", - "revocable": false, - "desc": "Create and copy subgroups" - }, - "churchdb:+see hidden group": { - "authId": 10119, - "scopeField": null, - "revocable": false, - "desc": "View group when visibility is set to \"Hidden\"" - }, - "churchdb:+see group tags": { - "authId": 10105, - "scopeField": null, - "revocable": false, - "desc": "View tags of group" - }, - "churchdb:+see persons": { - "authId": 10101, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "View group members" - }, - "churchdb:+edit person fields of group members": { - "authId": 10106, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "Edit person info fields of group members" - }, - "churchdb:+see group member fields": { - "authId": 10126, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "View group member fields" - }, - "churchdb:+edit group member fields": { - "authId": 10133, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "Edit group member fields" - }, - "churchdb:+see own group member fields": { - "authId": 10132, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "View your own group member fields" - }, - "churchdb:+edit own group member fields": { - "authId": 10125, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "Edit your own group member fields" - }, - "churchdb:+edit basic group memberships": { - "authId": 10108, - "scopeField": null, - "revocable": false, - "desc": "Edit group memberships (role, status, notes, etc.)" - }, - "churchdb:+add person": { - "authId": 10107, - "scopeField": null, - "revocable": false, - "desc": "Add group members" - }, - "churchdb:+remove from group": { - "authId": 10118, - "scopeField": null, - "revocable": false, - "desc": "Remove group members" - }, - "churchdb:+exit group": { - "authId": 10136, - "scopeField": null, - "revocable": false, - "desc": "Exit the group" - }, - "churchdb:+invite person": { - "authId": 10103, - "scopeField": null, - "revocable": false, - "desc": "Invite group members to ChurchTools" - }, - "churchdb:+export group members": { - "authId": 10112, - "scopeField": null, - "revocable": false, - "desc": "Allowed to export group members" - }, - "churchdb:+mail group members": { - "authId": 10113, - "scopeField": null, - "revocable": false, - "desc": "Contact group members" - }, - "churchdb:+see tags": { - "authId": 10104, - "scopeField": null, - "revocable": false, - "desc": "View tags of group members" - }, - "churchdb:+admin routines": { - "authId": 10135, - "scopeField": null, - "revocable": false, - "desc": "Manage routines" - }, - "churchdb:+admin followup": { - "authId": 10123, - "scopeField": null, - "revocable": false, - "desc": "Plan follow-ups" - }, - "churchdb:+do followup": { - "authId": 10110, - "scopeField": null, - "revocable": false, - "desc": "Carry out follow-ups" - }, - "churchdb:+admin meetings": { - "authId": 10124, - "scopeField": null, - "revocable": false, - "desc": "Edit settings for group meetings" - }, - "churchdb:+do group meeting": { - "authId": 10111, - "scopeField": null, - "revocable": false, - "desc": "View and execute group meetings" - }, - "churchdb:+create notes": { - "authId": 10134, - "scopeField": null, - "revocable": false, - "desc": "Create notes" - }, - "churchdb:+view history": { - "authId": 10128, - "scopeField": null, - "revocable": false, - "desc": "View history of group members" - }, - "churchdb:+admin group chat": { - "authId": 10121, - "scopeField": null, - "revocable": false, - "desc": "Manage group chat" - }, - "churchdb:+get emails": { - "authId": 10109, - "scopeField": null, - "revocable": false, - "desc": "Receive e-mail for changes" - }, - "churchdb:+admin posts": { - "authId": 10129, - "scopeField": null, - "revocable": false, - "desc": "Manage posts" - }, - "churchdb:+create post group intern": { - "authId": 10130, - "scopeField": null, - "revocable": true, - "desc": "Create posts: For group members" - }, - "churchdb:+create post group visible": { - "authId": 10131, - "scopeField": null, - "revocable": true, - "desc": "Create posts: With visibility of the group" - }, - "churchgroup:view": { - "authId": 1101, - "scopeField": null, - "revocable": false, - "desc": "View \"Groups\"" - }, - "churchgroup:view group": { - "authId": 1104, - "scopeField": "cdb_gruppe", - "revocable": false, - "desc": "View group incl. its group members" - }, - "churchgroup:edit group": { - "authId": 1105, - "scopeField": "cdb_gruppe", - "revocable": false, - "desc": "Edit group" - }, - "churchgroup:delete group": { - "authId": 1106, - "scopeField": "cdb_gruppe", - "revocable": false, - "desc": "Delete group" - }, - "churchgroup:create groups of grouptype": { - "authId": 1107, - "scopeField": "cdb_gruppentyp", - "revocable": false, - "desc": "Create groups of grouptype" - }, - "churchgroup:view groups of grouptype": { - "authId": 1108, - "scopeField": "cdb_gruppentyp", - "revocable": false, - "desc": "View groups of grouptype - except hidden ones" - }, - "churchgroup:edit groups of grouptype": { - "authId": 1109, - "scopeField": "cdb_gruppentyp", - "revocable": false, - "desc": "Edit groups of grouptype - except hidden ones" - }, - "churchgroup:delete groups of grouptype": { - "authId": 1110, - "scopeField": "cdb_gruppentyp", - "revocable": false, - "desc": "Delete groups of grouptype - except hidden ones" - }, - "churchgroup:edit group memberships of grouptype": { - "authId": 1111, - "scopeField": "cdb_gruppentyp", - "revocable": false, - "desc": "Edit group memberships of groups of a group type" - }, - "churchgroup:edit group memberships of group": { - "authId": 1112, - "scopeField": "cdb_gruppe", - "revocable": false, - "desc": "Edit group memberships of a group" - }, - "churchgroup:administer groups": { - "authId": 1113, - "scopeField": null, - "revocable": false, - "desc": "Administer groups - i.e. create, edit, delete, and more" - }, - "churchgroup:security level group": { - "authId": 1114, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "View group infos - up to the selected security level" - }, - "churchgroup:view group history": { - "authId": 1102, - "scopeField": null, - "revocable": false, - "desc": "View history of group members" - }, - "churchgroup:view group tags": { - "authId": 1103, - "scopeField": null, - "revocable": false, - "desc": "View group tags" - }, - "churchgroup:administer global views": { - "authId": 1115, - "scopeField": null, - "revocable": false, - "desc": "Manage global saved views" - }, - "churchgroup:edit masterdata": { - "authId": 1116, - "scopeField": null, - "revocable": false, - "desc": "Manage module settings" - }, - "churchcal:view": { - "authId": 401, - "scopeField": null, - "revocable": false, - "desc": "View \"Calendar\"" - }, - "churchcal:view category": { - "authId": 403, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "View single calendar " - }, - "churchcal:edit category": { - "authId": 404, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "Create / edit events in single calendars" - }, - "churchcal:edit calendar entry template": { - "authId": 410, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "Create and edit appointment templates in individual calendars." - }, - "churchcal:assistance mode": { - "authId": 409, - "scopeField": null, - "revocable": false, - "desc": "Create bookings for others" - }, - "churchcal:create personal category": { - "authId": 407, - "scopeField": null, - "revocable": false, - "desc": "Create personal calendar" - }, - "churchcal:admin personal category": { - "authId": 406, - "scopeField": null, - "revocable": false, - "desc": "Administer personal calendar" - }, - "churchcal:create group category": { - "authId": 408, - "scopeField": null, - "revocable": false, - "desc": "Create group calendar" - }, - "churchcal:admin group category": { - "authId": 405, - "scopeField": null, - "revocable": false, - "desc": "Administer group calendar" - }, - "churchcal:admin church category": { - "authId": 402, - "scopeField": null, - "revocable": false, - "desc": "administer church calendar" - }, - "churchcal:admin appointment tags": { - "authId": 411, - "scopeField": null, - "revocable": false, - "desc": "Administer appointment tags" - }, - "churchresource:view": { - "authId": 201, - "scopeField": null, - "revocable": false, - "desc": "View \"Resources\"" - }, - "churchresource:view resource": { - "authId": 205, - "scopeField": "cr_resource", - "revocable": false, - "desc": "View Resource" - }, - "churchresource:create bookings": { - "authId": 206, - "scopeField": "cr_resource", - "revocable": false, - "desc": "Create own booking requests" - }, - "churchresource:create virtual bookings": { - "authId": 204, - "scopeField": null, - "revocable": false, - "desc": "Create requests for resources with multiple bookings" - }, - "churchresource:administer bookings": { - "authId": 202, - "scopeField": "cr_resource", - "revocable": false, - "desc": "Manage bookings" - }, - "churchresource:assistance mode": { - "authId": 203, - "scopeField": null, - "revocable": false, - "desc": "Create bookings for others" - }, - "churchresource:edit masterdata": { - "authId": 299, - "scopeField": null, - "revocable": false, - "desc": "Edit master data" - }, - "churchservice:view": { - "authId": 301, - "scopeField": null, - "revocable": false, - "desc": "View \"Events\"" - }, - "churchservice:view servicegroup": { - "authId": 304, - "scopeField": "cs_servicegroup", - "revocable": false, - "desc": "See roles of individual role categories" - }, - "churchservice:edit servicegroup": { - "authId": 305, - "scopeField": "cs_servicegroup", - "revocable": false, - "desc": "Edit roles of individual role categories" - }, - "churchservice:view history": { - "authId": 302, - "scopeField": null, - "revocable": false, - "desc": "View request history" - }, - "churchservice:view events": { - "authId": 306, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "View events from individual calendars" - }, - "churchservice:edit events": { - "authId": 303, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "Create, edit, delete events" - }, - "churchservice:edit template": { - "authId": 309, - "scopeField": null, - "revocable": false, - "desc": "Edit event templates" - }, - "churchservice:manage absent": { - "authId": 307, - "scopeField": null, - "revocable": false, - "desc": "View and manage absences for all people" - }, - "churchservice:view fact": { - "authId": 321, - "scopeField": "cs_fact", - "revocable": false, - "desc": "View facts" - }, - "churchservice:edit fact": { - "authId": 308, - "scopeField": "cs_fact", - "revocable": false, - "desc": "Edit facts" - }, - "churchservice:export facts": { - "authId": 322, - "scopeField": null, - "revocable": false, - "desc": "Export facts" - }, - "churchservice:view agenda": { - "authId": 331, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "View orders for single calendar" - }, - "churchservice:edit agenda": { - "authId": 332, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "Edit orders for single calendar" - }, - "churchservice:edit agenda templates": { - "authId": 333, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "Edit order templates for single calendar" - }, - "churchservice:view songcategory": { - "authId": 313, - "scopeField": "cs_songcategory", - "revocable": false, - "desc": "View single song category" - }, - "churchservice:edit songcategory": { - "authId": 312, - "scopeField": "cs_songcategory", - "revocable": false, - "desc": "Edit songs and upload files" - }, - "churchservice:view song statistics": { - "authId": 314, - "scopeField": null, - "revocable": false, - "desc": "view song statistics" - }, - "churchservice:use ccli": { - "authId": 341, - "scopeField": null, - "revocable": false, - "desc": "Is allowed to use CCLI in \"Events\"" - }, - "churchservice:edit masterdata": { - "authId": 399, - "scopeField": null, - "revocable": false, - "desc": "Edit master data" - }, - "churchservice:+view service": { - "authId": 10301, - "scopeField": null, - "revocable": false, - "desc": "View role assignments" - }, - "churchservice:+entry free": { - "authId": 10302, - "scopeField": null, - "revocable": false, - "desc": "Register for free roles" - }, - "churchservice:+entry all": { - "authId": 10303, - "scopeField": null, - "revocable": false, - "desc": "Planner, that means also edit role assignments" - }, - "churchservice:+edit service": { - "authId": 10304, - "scopeField": null, - "revocable": false, - "desc": "Add and remove roles to/from event" - }, - "churchservice:+admin service": { - "authId": 10305, - "scopeField": null, - "revocable": false, - "desc": "Edit role master data" - }, - "churchservice:+see workload": { - "authId": 10306, - "scopeField": null, - "revocable": false, - "desc": "View utilization" - }, - "churchservice:+see history": { - "authId": 10307, - "scopeField": null, - "revocable": false, - "desc": "View history of role assignments" - }, - "churchservice:+see reason": { - "authId": 10308, - "scopeField": null, - "revocable": false, - "desc": "View decline and commitment comments" - }, - "churchservice:+attach file": { - "authId": 10309, - "scopeField": null, - "revocable": false, - "desc": "Upload files to events" - }, - "churchservice:+edit info": { - "authId": 10310, - "scopeField": null, - "revocable": false, - "desc": "Edit event info" - }, - "churchservice:+edit event": { - "authId": 10311, - "scopeField": null, - "revocable": false, - "desc": "Edit event" - }, - "churchservice:+edit agenda": { - "authId": 10313, - "scopeField": null, - "revocable": false, - "desc": "Edit order" - }, - "churchservice:+view absence": { - "authId": 10321, - "scopeField": null, - "revocable": false, - "desc": "View absence" - }, - "churchservice:+start event chat": { - "authId": 10322, - "scopeField": null, - "revocable": false, - "desc": "Start Event Chat" - }, - "churchwiki:view": { - "authId": 501, - "scopeField": null, - "revocable": false, - "desc": "View \"Wiki\"" - }, - "churchwiki:view category": { - "authId": 502, - "scopeField": "cc_wikicategory", - "revocable": false, - "desc": "View single wiki category" - }, - "churchwiki:edit category": { - "authId": 503, - "scopeField": "cc_wikicategory", - "revocable": false, - "desc": "Edit single wiki category" - }, - "churchwiki:edit masterdata": { - "authId": 599, - "scopeField": null, - "revocable": false, - "desc": "Edit master data" - }, - "churchreport:view": { - "authId": 701, - "scopeField": null, - "revocable": false, - "desc": "View \"Report\"" - }, - "churchreport:view query": { - "authId": 702, - "scopeField": "crp_query", - "revocable": false, - "desc": "Call query" - }, - "churchreport:edit masterdata": { - "authId": 799, - "scopeField": null, - "revocable": false, - "desc": "Edit master data" - }, - "finance:view": { - "authId": 901, - "scopeField": null, - "revocable": false, - "desc": "View Finances " - }, - "finance:view accounting period": { - "authId": 902, - "scopeField": "cf_accounting_periods", - "revocable": false, - "desc": "View accounting years and clients" - }, - "finance:edit accounting period": { - "authId": 903, - "scopeField": "cf_accounting_periods", - "revocable": false, - "desc": "Creating and Editing transactions for the Accounting Year and Client" - }, - "finance:edit masterdata": { - "authId": 999, - "scopeField": null, - "revocable": false, - "desc": "Edit master data" - }, - "finance:+see cost center budgets": { - "authId": 10904, - "scopeField": null, - "revocable": false, - "desc": "View budgets in associated cost centers" - }, - "finance:+see cost centers": { - "authId": 10901, - "scopeField": null, - "revocable": false, - "desc": "View transactions and budgets in associated cost centers" - }, - "finance:+see donators in cost centers": { - "authId": 10903, - "scopeField": null, - "revocable": false, - "desc": "View donors in associated cost centers" - }, - "finance:+allow posting": { - "authId": 10902, - "scopeField": null, - "revocable": false, - "desc": "Create transactions for linked cost centers" - }, - "churchsync:view": { - "authId": 1001, - "scopeField": null, - "revocable": false, - "desc": "Sync view and administer" - }, - "jpmFlowManagerPreview:view": { - "authId": 2080, - "scopeField": null, - "revocable": false, - "desc": "View \"Flow 2.0 (Beta)\"" - }, - "jpmFlowManagerPreview:view custom category": { - "authId": 2081, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "View categories" - }, - "jpmFlowManagerPreview:create custom category": { - "authId": 2082, - "scopeField": null, - "revocable": false, - "desc": "Create categories" - }, - "jpmFlowManagerPreview:edit custom category": { - "authId": 2083, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Edit categories" - }, - "jpmFlowManagerPreview:delete custom category": { - "authId": 2084, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Delete categories" - }, - "jpmFlowManagerPreview:view custom data": { - "authId": 2085, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "View data in category" - }, - "jpmFlowManagerPreview:create custom data": { - "authId": 2086, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Create data in category" - }, - "jpmFlowManagerPreview:edit custom data": { - "authId": 2087, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Edit data in category" - }, - "jpmFlowManagerPreview:delete custom data": { - "authId": 2088, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Delete data in category" - }, - "group_hierarchies:view": { - "authId": 2070, - "scopeField": null, - "revocable": false, - "desc": "View \"Admin\"" - }, - "group_hierarchies:view custom category": { - "authId": 2071, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "View categories" - }, - "group_hierarchies:create custom category": { - "authId": 2072, - "scopeField": null, - "revocable": false, - "desc": "Create categories" - }, - "group_hierarchies:edit custom category": { - "authId": 2073, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Edit categories" - }, - "group_hierarchies:delete custom category": { - "authId": 2074, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Delete categories" - }, - "group_hierarchies:view custom data": { - "authId": 2075, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "View data in category" - }, - "group_hierarchies:create custom data": { - "authId": 2076, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Create data in category" - }, - "group_hierarchies:edit custom data": { - "authId": 2077, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Edit data in category" - }, - "group_hierarchies:delete custom data": { - "authId": 2078, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Delete data in category" - }, - "jpmFlowManager:view": { - "authId": 2050, - "scopeField": null, - "revocable": false, - "desc": "View \"Flow\"" - }, - "jpmFlowManager:view custom category": { - "authId": 2051, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "View categories" - }, - "jpmFlowManager:create custom category": { - "authId": 2052, - "scopeField": null, - "revocable": false, - "desc": "Create categories" - }, - "jpmFlowManager:edit custom category": { - "authId": 2053, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Edit categories" - }, - "jpmFlowManager:delete custom category": { - "authId": 2054, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Delete categories" - }, - "jpmFlowManager:view custom data": { - "authId": 2055, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "View data in category" - }, - "jpmFlowManager:create custom data": { - "authId": 2056, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Create data in category" - }, - "jpmFlowManager:edit custom data": { - "authId": 2057, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Edit data in category" - }, - "jpmFlowManager:delete custom data": { - "authId": 2058, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Delete data in category" - } -} \ No newline at end of file + "$meta": { + "capturedFrom": "eqrm.church.tools", + "ctVersion": "3.134.0", + "capturedAt": "2026-07-08", + "rightCount": 187, + "source": "POST /index.php?q=churchauth/ajax func=getMasterData", + "regenerate": "npm run regenerate:permission-catalog (see src/permissions/README.md)" + }, + "churchcore:administer settings": { + "authId": 1, + "scopeField": null, + "revocable": false, + "desc": "Edit system settings" + }, + "churchcore:edit public profiles": { + "authId": 10, + "scopeField": null, + "revocable": false, + "desc": "Manage public profiles" + }, + "churchcore:view website": { + "authId": 11, + "scopeField": null, + "revocable": false, + "desc": "View overview" + }, + "churchcore:edit website staff": { + "authId": 12, + "scopeField": null, + "revocable": false, + "desc": "Manage users" + }, + "churchcore:edit website releases": { + "authId": 13, + "scopeField": null, + "revocable": false, + "desc": "Manage permissions" + }, + "churchcore:administer persons": { + "authId": 2, + "scopeField": null, + "revocable": false, + "desc": "Manage permissions" + }, + "churchcore:view logfile": { + "authId": 3, + "scopeField": null, + "revocable": false, + "desc": "View log" + }, + "churchcore:edit languages": { + "authId": 9, + "scopeField": "cc_language", + "revocable": false, + "desc": "Edit translations" + }, + "churchcore:edit translations masterdata": { + "authId": 5, + "scopeField": null, + "revocable": false, + "desc": "Manage languages" + }, + "churchcore:simulate persons": { + "authId": 7, + "scopeField": null, + "revocable": false, + "desc": "Simulate persons, change password and block access" + }, + "churchcore:invite persons": { + "authId": 6, + "scopeField": null, + "revocable": false, + "desc": "Invite person to ChurchTools" + }, + "churchcore:administer church html templates": { + "authId": 16, + "scopeField": null, + "revocable": false, + "desc": "Manage church HTML templates" + }, + "churchcore:use church html templates": { + "authId": 17, + "scopeField": "cc_html_template", + "revocable": false, + "desc": "Use church HTML templates" + }, + "churchcore:view links": { + "authId": 19, + "scopeField": "cc_further_link", + "revocable": false, + "desc": "View the entries in the links-widget" + }, + "churchcore:login to external system": { + "authId": 18, + "scopeField": "oauth_client", + "revocable": false, + "desc": "Log in to external system with ChurchTools" + }, + "churchcore:administer custom modules": { + "authId": 15, + "scopeField": null, + "revocable": false, + "desc": "Manage extensions" + }, + "churchcheckin:view": { + "authId": 601, + "scopeField": null, + "revocable": false, + "desc": "View \"Check-in\"" + }, + "churchcheckin:create person": { + "authId": 602, + "scopeField": null, + "revocable": false, + "desc": "Create people" + }, + "churchcheckin:edit masterdata": { + "authId": 699, + "scopeField": null, + "revocable": false, + "desc": "Manage printer" + }, + "churchdb:view": { + "authId": 101, + "scopeField": null, + "revocable": false, + "desc": "View \"People\"" + }, + "churchdb:view station": { + "authId": 124, + "scopeField": "cdb_station", + "revocable": false, + "desc": "View campus" + }, + "churchdb:view alldata": { + "authId": 102, + "scopeField": "cdb_bereich", + "revocable": false, + "desc": "View people of a department" + }, + "churchdb:security level person": { + "authId": 125, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "View person data - up to the selected security level" + }, + "churchdb:security level view own data": { + "authId": 131, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "View your own personal data - up to the selected security level" + }, + "churchdb:security level edit own data": { + "authId": 132, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "Edit own personal data - up to the selected security level" + }, + "churchdb:create person": { + "authId": 119, + "scopeField": null, + "revocable": false, + "desc": "Create people" + }, + "churchdb:write access": { + "authId": 111, + "scopeField": null, + "revocable": false, + "desc": "Edit people" + }, + "churchdb:delete persons": { + "authId": 129, + "scopeField": null, + "revocable": false, + "desc": "Delete persons" + }, + "churchdb:push/pull archive": { + "authId": 118, + "scopeField": null, + "revocable": false, + "desc": "Archive people" + }, + "churchdb:view archive": { + "authId": 116, + "scopeField": null, + "revocable": false, + "desc": "View archive of people" + }, + "churchdb:view statistics": { + "authId": 106, + "scopeField": null, + "revocable": false, + "desc": "View person statistics" + }, + "churchdb:view person tags": { + "authId": 107, + "scopeField": null, + "revocable": false, + "desc": "View tags of people " + }, + "churchdb:view comments": { + "authId": 113, + "scopeField": "cdb_comment_viewer", + "revocable": false, + "desc": "View comments of people" + }, + "churchdb:export data": { + "authId": 112, + "scopeField": null, + "revocable": false, + "desc": "Export data of people" + }, + "churchdb:edit bulkletter": { + "authId": 128, + "scopeField": null, + "revocable": false, + "desc": "Create bulk letters" + }, + "churchdb:create print labels": { + "authId": 130, + "scopeField": null, + "revocable": false, + "desc": "Create labels" + }, + "churchdb:send sms": { + "authId": 117, + "scopeField": null, + "revocable": false, + "desc": "Use SMS interface" + }, + "churchdb:view person history": { + "authId": 108, + "scopeField": null, + "revocable": false, + "desc": "View person history" + }, + "churchdb:edit relations": { + "authId": 109, + "scopeField": null, + "revocable": false, + "desc": "Edit relations" + }, + "churchdb:view memberliste": { + "authId": 122, + "scopeField": null, + "revocable": false, + "desc": "View member list" + }, + "churchdb:complex filter": { + "authId": 120, + "scopeField": null, + "revocable": false, + "desc": "Use \"additional filters\"" + }, + "churchdb:administer global filters": { + "authId": 127, + "scopeField": null, + "revocable": false, + "desc": "Administer global filters" + }, + "churchdb:edit masterdata": { + "authId": 199, + "scopeField": null, + "revocable": false, + "desc": "Edit master data" + }, + "churchdb:+see group": { + "authId": 10102, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "View group infos" + }, + "churchdb:+edit group infos": { + "authId": 10122, + "scopeField": null, + "revocable": false, + "desc": "Edit group info" + }, + "churchdb:+edit group basic settings": { + "authId": 10116, + "scopeField": null, + "revocable": false, + "desc": "Edit basic group settings (group status, visibility, registration)" + }, + "churchdb:+admin group member fields": { + "authId": 10117, + "scopeField": null, + "revocable": false, + "desc": "Manage group member fields" + }, + "churchdb:+edit group hierarchy": { + "authId": 10114, + "scopeField": null, + "revocable": false, + "desc": "Edit group hierarchy" + }, + "churchdb:+create group": { + "authId": 10115, + "scopeField": "cdb_gruppentyp", + "revocable": false, + "desc": "Create and copy subgroups" + }, + "churchdb:+see hidden group": { + "authId": 10119, + "scopeField": null, + "revocable": false, + "desc": "View group when visibility is set to \"Hidden\"" + }, + "churchdb:+see group tags": { + "authId": 10105, + "scopeField": null, + "revocable": false, + "desc": "View tags of group" + }, + "churchdb:+see persons": { + "authId": 10101, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "View group members" + }, + "churchdb:+edit person fields of group members": { + "authId": 10106, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "Edit person info fields of group members" + }, + "churchdb:+see group member fields": { + "authId": 10126, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "View group member fields" + }, + "churchdb:+edit group member fields": { + "authId": 10133, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "Edit group member fields" + }, + "churchdb:+see own group member fields": { + "authId": 10132, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "View your own group member fields" + }, + "churchdb:+edit own group member fields": { + "authId": 10125, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "Edit your own group member fields" + }, + "churchdb:+edit basic group memberships": { + "authId": 10108, + "scopeField": null, + "revocable": false, + "desc": "Edit group memberships (role, status, notes, etc.)" + }, + "churchdb:+add person": { + "authId": 10107, + "scopeField": null, + "revocable": false, + "desc": "Add group members" + }, + "churchdb:+remove from group": { + "authId": 10118, + "scopeField": null, + "revocable": false, + "desc": "Remove group members" + }, + "churchdb:+exit group": { + "authId": 10136, + "scopeField": null, + "revocable": false, + "desc": "Exit the group" + }, + "churchdb:+invite person": { + "authId": 10103, + "scopeField": null, + "revocable": false, + "desc": "Invite group members to ChurchTools" + }, + "churchdb:+export group members": { + "authId": 10112, + "scopeField": null, + "revocable": false, + "desc": "Allowed to export group members" + }, + "churchdb:+mail group members": { + "authId": 10113, + "scopeField": null, + "revocable": false, + "desc": "Contact group members" + }, + "churchdb:+see tags": { + "authId": 10104, + "scopeField": null, + "revocable": false, + "desc": "View tags of group members" + }, + "churchdb:+admin routines": { + "authId": 10135, + "scopeField": null, + "revocable": false, + "desc": "Manage routines" + }, + "churchdb:+admin followup": { + "authId": 10123, + "scopeField": null, + "revocable": false, + "desc": "Plan follow-ups" + }, + "churchdb:+do followup": { + "authId": 10110, + "scopeField": null, + "revocable": false, + "desc": "Carry out follow-ups" + }, + "churchdb:+admin meetings": { + "authId": 10124, + "scopeField": null, + "revocable": false, + "desc": "Edit settings for group meetings" + }, + "churchdb:+do group meeting": { + "authId": 10111, + "scopeField": null, + "revocable": false, + "desc": "View and execute group meetings" + }, + "churchdb:+create notes": { + "authId": 10134, + "scopeField": null, + "revocable": false, + "desc": "Create notes" + }, + "churchdb:+view history": { + "authId": 10128, + "scopeField": null, + "revocable": false, + "desc": "View history of group members" + }, + "churchdb:+admin group chat": { + "authId": 10121, + "scopeField": null, + "revocable": false, + "desc": "Manage group chat" + }, + "churchdb:+get emails": { + "authId": 10109, + "scopeField": null, + "revocable": false, + "desc": "Receive e-mail for changes" + }, + "churchdb:+admin posts": { + "authId": 10129, + "scopeField": null, + "revocable": false, + "desc": "Manage posts" + }, + "churchdb:+create post group intern": { + "authId": 10130, + "scopeField": null, + "revocable": true, + "desc": "Create posts: For group members" + }, + "churchdb:+create post group visible": { + "authId": 10131, + "scopeField": null, + "revocable": true, + "desc": "Create posts: With visibility of the group" + }, + "churchgroup:view": { + "authId": 1101, + "scopeField": null, + "revocable": false, + "desc": "View \"Groups\"" + }, + "churchgroup:view group": { + "authId": 1104, + "scopeField": "cdb_gruppe", + "revocable": false, + "desc": "View group incl. its group members" + }, + "churchgroup:edit group": { + "authId": 1105, + "scopeField": "cdb_gruppe", + "revocable": false, + "desc": "Edit group" + }, + "churchgroup:delete group": { + "authId": 1106, + "scopeField": "cdb_gruppe", + "revocable": false, + "desc": "Delete group" + }, + "churchgroup:create groups of grouptype": { + "authId": 1107, + "scopeField": "cdb_gruppentyp", + "revocable": false, + "desc": "Create groups of grouptype" + }, + "churchgroup:view groups of grouptype": { + "authId": 1108, + "scopeField": "cdb_gruppentyp", + "revocable": false, + "desc": "View groups of grouptype - except hidden ones" + }, + "churchgroup:edit groups of grouptype": { + "authId": 1109, + "scopeField": "cdb_gruppentyp", + "revocable": false, + "desc": "Edit groups of grouptype - except hidden ones" + }, + "churchgroup:delete groups of grouptype": { + "authId": 1110, + "scopeField": "cdb_gruppentyp", + "revocable": false, + "desc": "Delete groups of grouptype - except hidden ones" + }, + "churchgroup:edit group memberships of grouptype": { + "authId": 1111, + "scopeField": "cdb_gruppentyp", + "revocable": false, + "desc": "Edit group memberships of groups of a group type" + }, + "churchgroup:edit group memberships of group": { + "authId": 1112, + "scopeField": "cdb_gruppe", + "revocable": false, + "desc": "Edit group memberships of a group" + }, + "churchgroup:administer groups": { + "authId": 1113, + "scopeField": null, + "revocable": false, + "desc": "Administer groups - i.e. create, edit, delete, and more" + }, + "churchgroup:security level group": { + "authId": 1114, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "View group infos - up to the selected security level" + }, + "churchgroup:view group history": { + "authId": 1102, + "scopeField": null, + "revocable": false, + "desc": "View history of group members" + }, + "churchgroup:view group tags": { + "authId": 1103, + "scopeField": null, + "revocable": false, + "desc": "View group tags" + }, + "churchgroup:administer global views": { + "authId": 1115, + "scopeField": null, + "revocable": false, + "desc": "Manage global saved views" + }, + "churchgroup:edit masterdata": { + "authId": 1116, + "scopeField": null, + "revocable": false, + "desc": "Manage module settings" + }, + "churchcal:view": { + "authId": 401, + "scopeField": null, + "revocable": false, + "desc": "View \"Calendar\"" + }, + "churchcal:view category": { + "authId": 403, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "View single calendar " + }, + "churchcal:edit category": { + "authId": 404, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "Create / edit events in single calendars" + }, + "churchcal:edit calendar entry template": { + "authId": 410, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "Create and edit appointment templates in individual calendars." + }, + "churchcal:assistance mode": { + "authId": 409, + "scopeField": null, + "revocable": false, + "desc": "Create bookings for others" + }, + "churchcal:create personal category": { + "authId": 407, + "scopeField": null, + "revocable": false, + "desc": "Create personal calendar" + }, + "churchcal:admin personal category": { + "authId": 406, + "scopeField": null, + "revocable": false, + "desc": "Administer personal calendar" + }, + "churchcal:create group category": { + "authId": 408, + "scopeField": null, + "revocable": false, + "desc": "Create group calendar" + }, + "churchcal:admin group category": { + "authId": 405, + "scopeField": null, + "revocable": false, + "desc": "Administer group calendar" + }, + "churchcal:admin church category": { + "authId": 402, + "scopeField": null, + "revocable": false, + "desc": "administer church calendar" + }, + "churchcal:admin appointment tags": { + "authId": 411, + "scopeField": null, + "revocable": false, + "desc": "Administer appointment tags" + }, + "churchresource:view": { + "authId": 201, + "scopeField": null, + "revocable": false, + "desc": "View \"Resources\"" + }, + "churchresource:view resource": { + "authId": 205, + "scopeField": "cr_resource", + "revocable": false, + "desc": "View Resource" + }, + "churchresource:create bookings": { + "authId": 206, + "scopeField": "cr_resource", + "revocable": false, + "desc": "Create own booking requests" + }, + "churchresource:create virtual bookings": { + "authId": 204, + "scopeField": null, + "revocable": false, + "desc": "Create requests for resources with multiple bookings" + }, + "churchresource:administer bookings": { + "authId": 202, + "scopeField": "cr_resource", + "revocable": false, + "desc": "Manage bookings" + }, + "churchresource:assistance mode": { + "authId": 203, + "scopeField": null, + "revocable": false, + "desc": "Create bookings for others" + }, + "churchresource:edit masterdata": { + "authId": 299, + "scopeField": null, + "revocable": false, + "desc": "Edit master data" + }, + "churchservice:view": { + "authId": 301, + "scopeField": null, + "revocable": false, + "desc": "View \"Events\"" + }, + "churchservice:view servicegroup": { + "authId": 304, + "scopeField": "cs_servicegroup", + "revocable": false, + "desc": "See roles of individual role categories" + }, + "churchservice:edit servicegroup": { + "authId": 305, + "scopeField": "cs_servicegroup", + "revocable": false, + "desc": "Edit roles of individual role categories" + }, + "churchservice:view history": { + "authId": 302, + "scopeField": null, + "revocable": false, + "desc": "View request history" + }, + "churchservice:view events": { + "authId": 306, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "View events from individual calendars" + }, + "churchservice:edit events": { + "authId": 303, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "Create, edit, delete events" + }, + "churchservice:edit template": { + "authId": 309, + "scopeField": null, + "revocable": false, + "desc": "Edit event templates" + }, + "churchservice:manage absent": { + "authId": 307, + "scopeField": null, + "revocable": false, + "desc": "View and manage absences for all people" + }, + "churchservice:view fact": { + "authId": 321, + "scopeField": "cs_fact", + "revocable": false, + "desc": "View facts" + }, + "churchservice:edit fact": { + "authId": 308, + "scopeField": "cs_fact", + "revocable": false, + "desc": "Edit facts" + }, + "churchservice:export facts": { + "authId": 322, + "scopeField": null, + "revocable": false, + "desc": "Export facts" + }, + "churchservice:view agenda": { + "authId": 331, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "View orders for single calendar" + }, + "churchservice:edit agenda": { + "authId": 332, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "Edit orders for single calendar" + }, + "churchservice:edit agenda templates": { + "authId": 333, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "Edit order templates for single calendar" + }, + "churchservice:view songcategory": { + "authId": 313, + "scopeField": "cs_songcategory", + "revocable": false, + "desc": "View single song category" + }, + "churchservice:edit songcategory": { + "authId": 312, + "scopeField": "cs_songcategory", + "revocable": false, + "desc": "Edit songs and upload files" + }, + "churchservice:view song statistics": { + "authId": 314, + "scopeField": null, + "revocable": false, + "desc": "view song statistics" + }, + "churchservice:use ccli": { + "authId": 341, + "scopeField": null, + "revocable": false, + "desc": "Is allowed to use CCLI in \"Events\"" + }, + "churchservice:edit masterdata": { + "authId": 399, + "scopeField": null, + "revocable": false, + "desc": "Edit master data" + }, + "churchservice:+view service": { + "authId": 10301, + "scopeField": null, + "revocable": false, + "desc": "View role assignments" + }, + "churchservice:+entry free": { + "authId": 10302, + "scopeField": null, + "revocable": false, + "desc": "Register for free roles" + }, + "churchservice:+entry all": { + "authId": 10303, + "scopeField": null, + "revocable": false, + "desc": "Planner, that means also edit role assignments" + }, + "churchservice:+edit service": { + "authId": 10304, + "scopeField": null, + "revocable": false, + "desc": "Add and remove roles to/from event" + }, + "churchservice:+admin service": { + "authId": 10305, + "scopeField": null, + "revocable": false, + "desc": "Edit role master data" + }, + "churchservice:+see workload": { + "authId": 10306, + "scopeField": null, + "revocable": false, + "desc": "View utilization" + }, + "churchservice:+see history": { + "authId": 10307, + "scopeField": null, + "revocable": false, + "desc": "View history of role assignments" + }, + "churchservice:+see reason": { + "authId": 10308, + "scopeField": null, + "revocable": false, + "desc": "View decline and commitment comments" + }, + "churchservice:+attach file": { + "authId": 10309, + "scopeField": null, + "revocable": false, + "desc": "Upload files to events" + }, + "churchservice:+edit info": { + "authId": 10310, + "scopeField": null, + "revocable": false, + "desc": "Edit event info" + }, + "churchservice:+edit event": { + "authId": 10311, + "scopeField": null, + "revocable": false, + "desc": "Edit event" + }, + "churchservice:+edit agenda": { + "authId": 10313, + "scopeField": null, + "revocable": false, + "desc": "Edit order" + }, + "churchservice:+view absence": { + "authId": 10321, + "scopeField": null, + "revocable": false, + "desc": "View absence" + }, + "churchservice:+start event chat": { + "authId": 10322, + "scopeField": null, + "revocable": false, + "desc": "Start Event Chat" + }, + "churchwiki:view": { + "authId": 501, + "scopeField": null, + "revocable": false, + "desc": "View \"Wiki\"" + }, + "churchwiki:view category": { + "authId": 502, + "scopeField": "cc_wikicategory", + "revocable": false, + "desc": "View single wiki category" + }, + "churchwiki:edit category": { + "authId": 503, + "scopeField": "cc_wikicategory", + "revocable": false, + "desc": "Edit single wiki category" + }, + "churchwiki:edit masterdata": { + "authId": 599, + "scopeField": null, + "revocable": false, + "desc": "Edit master data" + }, + "churchreport:view": { + "authId": 701, + "scopeField": null, + "revocable": false, + "desc": "View \"Report\"" + }, + "churchreport:view query": { + "authId": 702, + "scopeField": "crp_query", + "revocable": false, + "desc": "Call query" + }, + "churchreport:edit masterdata": { + "authId": 799, + "scopeField": null, + "revocable": false, + "desc": "Edit master data" + }, + "finance:view": { + "authId": 901, + "scopeField": null, + "revocable": false, + "desc": "View Finances " + }, + "finance:view accounting period": { + "authId": 902, + "scopeField": "cf_accounting_periods", + "revocable": false, + "desc": "View accounting years and clients" + }, + "finance:edit accounting period": { + "authId": 903, + "scopeField": "cf_accounting_periods", + "revocable": false, + "desc": "Creating and Editing transactions for the Accounting Year and Client" + }, + "finance:edit masterdata": { + "authId": 999, + "scopeField": null, + "revocable": false, + "desc": "Edit master data" + }, + "finance:+see cost center budgets": { + "authId": 10904, + "scopeField": null, + "revocable": false, + "desc": "View budgets in associated cost centers" + }, + "finance:+see cost centers": { + "authId": 10901, + "scopeField": null, + "revocable": false, + "desc": "View transactions and budgets in associated cost centers" + }, + "finance:+see donators in cost centers": { + "authId": 10903, + "scopeField": null, + "revocable": false, + "desc": "View donors in associated cost centers" + }, + "finance:+allow posting": { + "authId": 10902, + "scopeField": null, + "revocable": false, + "desc": "Create transactions for linked cost centers" + }, + "churchsync:view": { + "authId": 1001, + "scopeField": null, + "revocable": false, + "desc": "Sync view and administer" + }, + "jpmFlowManagerPreview:view": { + "authId": 2080, + "scopeField": null, + "revocable": false, + "desc": "View \"Flow 2.0 (Beta)\"" + }, + "jpmFlowManagerPreview:view custom category": { + "authId": 2081, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "View categories" + }, + "jpmFlowManagerPreview:create custom category": { + "authId": 2082, + "scopeField": null, + "revocable": false, + "desc": "Create categories" + }, + "jpmFlowManagerPreview:edit custom category": { + "authId": 2083, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Edit categories" + }, + "jpmFlowManagerPreview:delete custom category": { + "authId": 2084, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Delete categories" + }, + "jpmFlowManagerPreview:view custom data": { + "authId": 2085, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "View data in category" + }, + "jpmFlowManagerPreview:create custom data": { + "authId": 2086, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Create data in category" + }, + "jpmFlowManagerPreview:edit custom data": { + "authId": 2087, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Edit data in category" + }, + "jpmFlowManagerPreview:delete custom data": { + "authId": 2088, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Delete data in category" + }, + "group_hierarchies:view": { + "authId": 2070, + "scopeField": null, + "revocable": false, + "desc": "View \"Admin\"" + }, + "group_hierarchies:view custom category": { + "authId": 2071, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "View categories" + }, + "group_hierarchies:create custom category": { + "authId": 2072, + "scopeField": null, + "revocable": false, + "desc": "Create categories" + }, + "group_hierarchies:edit custom category": { + "authId": 2073, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Edit categories" + }, + "group_hierarchies:delete custom category": { + "authId": 2074, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Delete categories" + }, + "group_hierarchies:view custom data": { + "authId": 2075, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "View data in category" + }, + "group_hierarchies:create custom data": { + "authId": 2076, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Create data in category" + }, + "group_hierarchies:edit custom data": { + "authId": 2077, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Edit data in category" + }, + "group_hierarchies:delete custom data": { + "authId": 2078, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Delete data in category" + }, + "jpmFlowManager:view": { + "authId": 2050, + "scopeField": null, + "revocable": false, + "desc": "View \"Flow\"" + }, + "jpmFlowManager:view custom category": { + "authId": 2051, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "View categories" + }, + "jpmFlowManager:create custom category": { + "authId": 2052, + "scopeField": null, + "revocable": false, + "desc": "Create categories" + }, + "jpmFlowManager:edit custom category": { + "authId": 2053, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Edit categories" + }, + "jpmFlowManager:delete custom category": { + "authId": 2054, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Delete categories" + }, + "jpmFlowManager:view custom data": { + "authId": 2055, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "View data in category" + }, + "jpmFlowManager:create custom data": { + "authId": 2056, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Create data in category" + }, + "jpmFlowManager:edit custom data": { + "authId": 2057, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Edit data in category" + }, + "jpmFlowManager:delete custom data": { + "authId": 2058, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Delete data in category" + } +} diff --git a/src/permissions/catalog.ts b/src/permissions/catalog.ts index 91ef92b..6bd6e19 100644 --- a/src/permissions/catalog.ts +++ b/src/permissions/catalog.ts @@ -9,7 +9,12 @@ */ import catalogData from "./catalog.json" with { type: "json" }; -export interface CatalogEntry { authId: number; scopeField: string | null; revocable: boolean; desc: string } +export interface CatalogEntry { + authId: number; + scopeField: string | null; + revocable: boolean; + desc: string; +} /** * Provenance for the catalog (#25). Recorded as a reserved top-level `$meta` key in catalog.json so @@ -30,34 +35,100 @@ export interface CatalogMeta { [k: string]: unknown; } -// Split the reserved `$meta` provenance key from the rights. Done once at module load so every -// consumer of CATALOG sees rights only, and metadata is available without a second parse. -const { $meta, ...rights } = catalogData as unknown as { $meta?: CatalogMeta } & Record; +/** Split the reserved `$meta` provenance key from the rights, so no consumer sees it as a right. */ +function splitCatalog(data: unknown): { rights: Record; meta: CatalogMeta | null } { + const { $meta, ...rights } = data as { $meta?: CatalogMeta } & Record; + return { rights, meta: $meta ?? null }; +} + +const bundled = splitCatalog(catalogData); /** - * The permission catalog: name → authId bridge, inlined at build time (see the module header). - * It is a constant, not something "loaded" — callers that need a snapshot already spread it, so it - * is exported directly rather than behind a `loadCatalog()` wrapper. Excludes the `$meta` key. + * The permission catalog: name → authId bridge. Bundled at build time (see the module header), and + * REPLACEABLE at runtime by a per-instance capture (#105 — see {@link useCatalog}). + * + * `let`, not `const`, because ESM exports are live bindings: every module that did + * `import { CATALOG }` sees the swap without any of them having to learn that a catalog can be + * loaded. That keeps the "one static catalog" reading of this module intact everywhere it is used, + * while the command layer gets to point it at a fresher one. */ -export const CATALOG = rights as Record; +export let CATALOG: Record = bundled.rights; /** The catalog's recorded provenance (#25), or `null` on a legacy catalog with no `$meta` key. */ -export const CATALOG_META: CatalogMeta | null = $meta ?? null; +export let CATALOG_META: CatalogMeta | null = bundled.meta; + +/** + * True once a capture taken from the TARGET instance itself has been loaded (#105). Such a catalog is + * authoritative for that host, so the version-skew warning — which exists to flag "this snapshot came + * from a different ChurchTools than you are planning against" — has nothing left to say and stays + * quiet. False while the bundled snapshot is active. + */ +export let CATALOG_IS_PER_INSTANCE = false; /** * Every authId the catalog knows a name for. `ct plan` uses this to detect a live grant carrying an * authId the catalog cannot name (a stale/foreign right) — such a grant is warned about and left * untouched, never revoked, because we cannot even describe what we would be deleting (#25). */ -export const KNOWN_AUTH_IDS: ReadonlySet = new Set( - Object.values(CATALOG).map((e) => e.authId), +export let KNOWN_AUTH_IDS: ReadonlySet = new Set(Object.values(CATALOG).map((e) => e.authId)); + +/** + * authId → the scope dimension (`scopeField`) that right is scoped by, or `null` for an unscoped + * right. The inverse of the name-keyed catalog. Used wherever a LIVE grant — which carries an authId + * and no name — must be classified by dimension: `preserveUnknown` (#102) and the declarability + * verdict in `ct coverage` (#103). An authId absent from the map is a right the catalog cannot name. + */ +export let SCOPE_FIELD_BY_AUTH_ID: ReadonlyMap = new Map( + Object.values(CATALOG).map((e) => [e.authId, e.scopeField] as const), ); +/** + * Every distinct scope dimension the catalog knows, for validating an author-supplied dimension list + * (`preserveUnknown: ["cc_html_template"]`, #102). A typo there would otherwise preserve nothing and + * look identical to "there was nothing to preserve". + */ +export let KNOWN_SCOPE_FIELDS: ReadonlySet = new Set( + Object.values(CATALOG) + .map((e) => e.scopeField) + .filter((f): f is string => f != null), +); + +/** + * Point every catalog consumer at a different capture (#105). Used by the per-instance catalog + * (`.ct/permission-catalog..json`) so a consumer repo can act on the staleness warning without + * waiting for a release of this package — the bundled catalog is a snapshot of ONE instance's + * ChurchTools version, and the warning that told people to regenerate it named a script that only + * exists in this repo. + * + * Every derived index is rebuilt here, so the whole module stays internally consistent — there is no + * path that leaves `KNOWN_AUTH_IDS` describing the old catalog. + */ +export function useCatalog(data: unknown, opts: { perInstance?: boolean } = {}): void { + const next = splitCatalog(data); + CATALOG_IS_PER_INSTANCE = opts.perInstance ?? false; + CATALOG = next.rights; + CATALOG_META = next.meta; + KNOWN_AUTH_IDS = new Set(Object.values(CATALOG).map((e) => e.authId)); + SCOPE_FIELD_BY_AUTH_ID = new Map(Object.values(CATALOG).map((e) => [e.authId, e.scopeField] as const)); + KNOWN_SCOPE_FIELDS = new Set( + Object.values(CATALOG) + .map((e) => e.scopeField) + .filter((f): f is string => f != null), + ); +} + +/** Restore the catalog bundled with this release. Exists so tests can undo {@link useCatalog}. */ +export function useBundledCatalog(): void { + useCatalog(catalogData); +} + export function resolveAuthId(name: string): CatalogEntry { const entry = CATALOG[name]; if (!entry) { const [mod] = name.split(":"); - const near = Object.keys(CATALOG).filter((k) => k.startsWith(`${mod}:`)).slice(0, 6); + const near = Object.keys(CATALOG) + .filter((k) => k.startsWith(`${mod}:`)) + .slice(0, 6); const hint = near.length ? ` Did you mean one of: ${near.join(", ")}?` : ""; throw new Error(`Unknown permission "${name}".${hint}`); } diff --git a/src/permissions/fetch.ts b/src/permissions/fetch.ts new file mode 100644 index 0000000..27d4658 --- /dev/null +++ b/src/permissions/fetch.ts @@ -0,0 +1,43 @@ +/** + * The one read every permission path shares: `GET /permissions/` (and its + * `/` form). + * + * These reads were written as a plain `client.get`, on the belief that a permission domain returns a + * single instance-wide blob rather than a paged list. That belief is not checked anywhere, and #100 + * is the whole catalogue of what happens when it is wrong: a paged endpoint read with a plain `get` + * returns CT's default first page as a valid-looking array, with `meta` — the only evidence rows are + * missing — discarded by `request()`'s `data` unwrap. On a host with a few hundred authored grants + * that would make `ct plan` see a truncated actual set (re-PUTting grants that already exist), + * `ct coverage` under-report, and `ct adopt grants --all-declarable` skip most role instances as + * "no authored grants" — all of it silent, all of it looking exactly like a correct run. + * + * So the assumption is now enforced instead of assumed: read the envelope, and if `meta.pagination` + * says more rows exist than arrived, page the endpoint properly rather than returning a first page. + * When the endpoint is un-paged (the expected case today) this costs one request, exactly as before. + */ +import { hasMorePages, type CtClient } from "../api/ctClient.js"; +import type { RawPermission } from "./grants.js"; + +/** + * A client able to perform the guarded read. `getRaw`/`getAll` are optional so the callers' existing + * `Pick` contract still holds for the narrow test doubles that supply only `get` — + * a real {@link CtClient} always carries all three and always takes the guarded path. + */ +export type PermissionReader = Pick & Partial>; + +/** Read one permission domain's rows, following pagination if the endpoint turns out to paginate. */ +export async function fetchPermissionRows(client: PermissionReader, path: string): Promise { + if (!client.getRaw || !client.getAll) { + // No envelope access (test double): fall back to the plain read rather than silently returning + // nothing. Production always has both. + const rows = await client.get(path); + return Array.isArray(rows) ? rows : []; + } + const { data, meta } = await client.getRaw(path); + const rows = Array.isArray(data) ? data : []; + if (!hasMorePages(meta, rows.length)) { + return rows; + } + const all = await client.getAll(path); + return all.data; +} diff --git a/src/permissions/grants.ts b/src/permissions/grants.ts index 23833e0..005ecec 100644 --- a/src/permissions/grants.ts +++ b/src/permissions/grants.ts @@ -49,8 +49,12 @@ export interface GrantTuple { pending?: boolean; } export interface RawPermission { - authId: number; dataId: number | null; type: "grant" | "revoke"; domainId: number; - isInherited?: boolean; meta?: { modifiedPid?: number }; + authId: number; + dataId: number | null; + type: "grant" | "revoke"; + domainId: number; + isInherited?: boolean; + meta?: { modifiedPid?: number }; } /** @@ -59,8 +63,15 @@ export interface RawPermission { * and guarantees it can never collide with an actual row (actuals never carry a scopeKey), so it * always lands in `toPut`. */ -export function tupleKey(t: { authId: number; dataId: number[]; type: string; scopeKey?: string; pending?: boolean }): string { - const scope = t.pending && t.scopeKey != null ? `pending:${t.scopeKey}` : [...t.dataId].sort((a, b) => a - b).join(","); +export function tupleKey(t: { + authId: number; + dataId: number[]; + type: string; + scopeKey?: string; + pending?: boolean; +}): string { + const scope = + t.pending && t.scopeKey != null ? `pending:${t.scopeKey}` : [...t.dataId].sort((a, b) => a - b).join(","); return `${t.type}:${t.authId}:${scope}`; } @@ -68,7 +79,7 @@ export function normalizeActual(rows: RawPermission[]): GrantTuple[] { const out: GrantTuple[] = []; for (const r of rows) { if (r.meta?.modifiedPid === -1) continue; // system baseline — invisible to reconciliation - if (r.isInherited) continue; // inherited — not directly owned here + if (r.isInherited) continue; // inherited — not directly owned here // dataId is [] or a single element (CT reads scoped grants back one row per dataId), so there is // nothing to sort here — and tupleKey sorts defensively anyway when it builds the identity key. out.push({ authId: r.authId, dataId: r.dataId == null ? [] : [r.dataId], type: r.type }); @@ -76,9 +87,31 @@ export function normalizeActual(rows: RawPermission[]): GrantTuple[] { return out; } -export interface GrantDiff { toPut: GrantTuple[]; toDelete: GrantTuple[]; preserved: GrantTuple[] } +export interface GrantDiff { + toPut: GrantTuple[]; + toDelete: GrantTuple[]; + preserved: GrantTuple[]; + /** + * Live grants that WOULD have been revoked but were kept, because the declaration opted into + * `preserveUnknown` for their scope dimension (#102). Separate from {@link preserved} (pre-existing + * deny rows, which reconciliation never owned in the first place): these are grants this + * declaration COULD have owned and deliberately does not. Rendered explicitly — "I forgot one" and + * "I deliberately left the module grants alone" must never look alike in a plan. + */ + preservedUnknown: GrantTuple[]; +} + +/** + * Decide whether a live grant absent from the declaration should be KEPT rather than revoked (#102). + * Built from a declaration's `preserveUnknown`; `undefined` means the strict default (revoke). + */ +export type PreservePredicate = (t: GrantTuple) => boolean; -export function diffGrants(desired: GrantTuple[], actual: GrantTuple[]): GrantDiff { +export function diffGrants( + desired: GrantTuple[], + actual: GrantTuple[], + preserveUnknown?: PreservePredicate, +): GrantDiff { // Reconciliation owns only user-authored GRANT rows. `desiredTuples` only ever emits // `type: "grant"`, so an explicit deny row (`type: "revoke"`) has no desired counterpart and // would land in `toDelete` — silently removing an admin's deny. Treat non-grant rows as @@ -88,6 +121,12 @@ export function diffGrants(desired: GrantTuple[], actual: GrantTuple[]): GrantDi const desiredKeys = new Map(desired.map((t) => [tupleKey(t), t])); const actualKeys = new Map(managedActual.map((t) => [tupleKey(t), t])); const toPut = [...desiredKeys].filter(([k]) => !actualKeys.has(k)).map(([, t]) => t); - const toDelete = [...actualKeys].filter(([k]) => !desiredKeys.has(k)).map(([, t]) => t); - return { toPut, toDelete, preserved }; + const undeclared = [...actualKeys].filter(([k]) => !desiredKeys.has(k)).map(([, t]) => t); + // Opt-in partial ownership (#102). Without it, ONE live grant on a dimension this tool has no + // business managing (a wiki category, an HTML template) makes the whole role instance undeclarable, + // because a partial declaration turns a clean no-op into a destructive plan. The split happens here + // rather than upstream so `toDelete` keeps its exact meaning — "apply will revoke this". + const toDelete = preserveUnknown ? undeclared.filter((t) => !preserveUnknown(t)) : undeclared; + const preservedUnknown = preserveUnknown ? undeclared.filter((t) => preserveUnknown(t)) : []; + return { toPut, toDelete, preserved, preservedUnknown }; } diff --git a/src/permissions/plan.ts b/src/permissions/plan.ts index c49c015..d7b60f8 100644 --- a/src/permissions/plan.ts +++ b/src/permissions/plan.ts @@ -4,15 +4,29 @@ * (the managed-guard — unmanaged domainIds are never surfaced or touched), * and diff. Mirrors `src/engine/build.ts`'s fetch-error handling. */ -import type { CtClient } from "../api/ctClient.js"; import { CtApiError } from "../api/ctClient.js"; +import { fetchPermissionRows, type PermissionReader } from "./fetch.js"; import type { State } from "../state/state.js"; import type { DesiredResource } from "../engine/types.js"; -import { resolveAuthId, CATALOG_META, KNOWN_AUTH_IDS } from "./catalog.js"; +import { + resolveAuthId, + CATALOG_META, + CATALOG_IS_PER_INSTANCE, + KNOWN_AUTH_IDS, + SCOPE_FIELD_BY_AUTH_ID, +} from "./catalog.js"; import { compareVersions } from "../api/version.js"; import { resolveScope, resolveScopeRefs, type ScopeRefMap } from "./scope.js"; -import { normalizeActual, diffGrants, type GrantTuple, type GrantDiff, type DomainType, type RawPermission } from "./grants.js"; -import type { DesiredPermission } from "./types.js"; +import { + normalizeActual, + diffGrants, + type GrantTuple, + type GrantDiff, + type DomainType, + type PreservePredicate, + type RawPermission, +} from "./grants.js"; +import type { DesiredPermission, PreserveUnknown } from "./types.js"; import { Resolver } from "../resolve/resolver.js"; import { isPendingRef, refKey, refLabel, type Ref } from "../resolve/refs.js"; @@ -26,7 +40,13 @@ import { isPendingRef, refKey, refLabel, type Ref } from "../resolve/refs.js"; * (#20/#46) and the scope pending path (#29). A pending domain has no live grants yet, so its diff * is `desired → toPut` against an empty actual set. */ -export interface PermissionPlanItem { key: string; domainType: DomainType; domainId: number | null; pendingDomain?: Ref; diff: GrantDiff } +export interface PermissionPlanItem { + key: string; + domainType: DomainType; + domainId: number | null; + pendingDomain?: Ref; + diff: GrantDiff; +} /** * Fan out each grant to (authId, dataId) tuples. ChurchTools reads a scoped grant back as @@ -55,7 +75,9 @@ export function desiredTuples( return [{ authId: entry.authId, dataId: [], type: "grant" as const }]; } if (entry.scopeField == null) { - throw new Error(`${p.domainType} "${p.key}": "${name}" is not a scoped right (no scopeField) — remove "scope" or use a scoped right.`); + throw new Error( + `${p.domainType} "${p.key}": "${name}" is not a scoped right (no scopeField) — remove "scope" or use a scoped right.`, + ); } // Retain the symbolic scopeKey (and its managed resource type, #98) on every scoped tuple so its // dataId is re-resolved against post-execute state at apply time. `id === null` means the target is @@ -70,7 +92,14 @@ export function desiredTuples( }); return scoped.map(({ key, id, numeric, type }) => id === null - ? { authId: entry.authId, dataId: [], type: "grant" as const, scopeKey: key, scopeType: type, pending: true } + ? { + authId: entry.authId, + dataId: [], + type: "grant" as const, + scopeKey: key, + scopeType: type, + pending: true, + } : numeric ? { authId: entry.authId, dataId: [id], type: "grant" as const } : { authId: entry.authId, dataId: [id], type: "grant" as const, scopeKey: key, scopeType: type }, @@ -78,6 +107,29 @@ export function desiredTuples( }); } +/** + * Turn a declaration's `preserveUnknown` (#102) into the predicate `diffGrants` applies to every live + * grant the declaration does not mention. `undefined` ⇒ no predicate ⇒ the strict default (revoke). + * + * The dimension of a live grant comes from its authId via the catalog. A grant whose authId the + * catalog cannot name never reaches here — `buildPermissionPlan` already excludes those from the diff + * entirely (they are warned about and left untouched, never revoked). + */ +export function preservePredicateFor( + preserveUnknown: PreserveUnknown | undefined, +): PreservePredicate | undefined { + if (preserveUnknown === undefined || preserveUnknown === false) return undefined; + if (preserveUnknown === true) return () => true; + const dimensions = new Set(preserveUnknown); + return (t) => { + const scopeField = SCOPE_FIELD_BY_AUTH_ID.get(t.authId); + // An UNSCOPED right (scopeField null) is never covered by a dimension list: the author named + // dimensions to leave alone, and "no dimension" is not one of them. Preserving it would silently + // widen the escape hatch past what was asked for. + return scopeField != null && dimensions.has(scopeField); + }; +} + /** * A permission whose domainId has been resolved. Either a concrete numeric domain, or — when the * domain is a group type created in this same run (#69) — a `pendingDomain` Ref with `domainId: null`, @@ -102,7 +154,8 @@ type ResolvedPermission = * would otherwise each diff against the other's grants and churn forever. Mirrors config/context.ts. */ async function resolveDomainIds( - permissions: DesiredPermission[], resolver: Resolver, + permissions: DesiredPermission[], + resolver: Resolver, ): Promise { const resolved: ResolvedPermission[] = []; for (const p of permissions) { @@ -113,7 +166,14 @@ async function resolveDomainIds( const site = `${p.domainType} "${p.key}".domainId`; const res = await resolver.resolve(p.domainId, site); if (isPendingRef(res)) { - resolved.push({ key: p.key, domainType: p.domainType, grants: p.grants, domainId: null, pendingDomain: res.__pendingRef }); + resolved.push({ + key: p.key, + domainType: p.domainType, + grants: p.grants, + preserveUnknown: p.preserveUnknown, + domainId: null, + pendingDomain: res.__pendingRef, + }); continue; } resolved.push({ ...p, domainId: res }); @@ -137,20 +197,40 @@ async function resolveDomainIds( } export async function buildPermissionPlan( - client: Pick, state: State, permissions: DesiredPermission[], desired: DesiredResource[] = [], - resolver?: Resolver, instanceVersion?: string, + client: PermissionReader, + state: State, + permissions: DesiredPermission[], + desired: DesiredResource[] = [], + resolver?: Resolver, + instanceVersion?: string, ): Promise<{ items: PermissionPlanItem[]; fetchErrors: string[]; warnings: string[] }> { const items: PermissionPlanItem[] = []; const fetchErrors: string[] = []; const warnings: string[] = []; - // Catalog staleness (#25): the catalog is a snapshot captured against one CT version. If the live - // instance reports a different version, right names/authIds/scopeFields may have drifted — warn - // (never fail) so the diff is trusted-but-verified and the fix (regenerate) is one command away. - if (permissions.length > 0 && instanceVersion && CATALOG_META && compareVersions(instanceVersion, CATALOG_META.ctVersion) !== 0) { + // Catalog staleness (#25/#105): the catalog is a snapshot captured against one CT version. If the + // live instance reports a different version, right names/authIds/scopeFields may have drifted — + // warn (never fail) so the diff is trusted-but-verified. + // + // The remediation now names a command a consumer repo can actually run (#105) — the old text + // pointed at a script that only exists in this repo, so the warning was unactionable where printed. + // + // A per-instance capture is authoritative for its host AT CAPTURE TIME, not forever: the instance + // gets upgraded while the committed file does not. So the version is compared either way; only the + // wording differs, because "re-capture" and "capture one" are different asks. + if ( + permissions.length > 0 && + instanceVersion && + CATALOG_META && + compareVersions(instanceVersion, CATALOG_META.ctVersion) !== 0 + ) { warnings.push( - `Permission catalog was captured from ChurchTools ${CATALOG_META.ctVersion} but this instance ` + - `runs ${instanceVersion}. Right names/authIds may be stale — regenerate it with ` + - `\`npm run regenerate:permission-catalog\` (see docs/handbuch/permissions.md).`, + CATALOG_IS_PER_INSTANCE + ? `Permission catalog for this host was captured against ChurchTools ${CATALOG_META.ctVersion} ` + + `but the instance now runs ${instanceVersion}. Right names/authIds may have drifted since — ` + + `re-capture with \`ct permissions catalog --refresh\` (see docs/handbuch/permissions.md).` + : `Permission catalog was captured from ChurchTools ${CATALOG_META.ctVersion} but this instance ` + + `runs ${instanceVersion}. Right names/authIds may be stale — capture one for this instance ` + + `with \`ct permissions catalog --refresh\` (see docs/handbuch/permissions.md).`, ); } // Resolve logical domainIds (#20) up front. Shares the command layer's resolver so master-data @@ -168,7 +248,7 @@ export async function buildPermissionPlan( const byType = new Map(); for (const dt of new Set(resolved.filter((p) => p.pendingDomain === undefined).map((p) => p.domainType))) { try { - byType.set(dt, await client.get(`/permissions/${dt}`)); + byType.set(dt, await fetchPermissionRows(client, `/permissions/${dt}`)); } catch (err) { const message = err instanceof CtApiError ? `${err.status}` : (err as Error).message; fetchErrors.push(`permissions ${dt}: ${message}`); @@ -189,7 +269,10 @@ export async function buildPermissionPlan( pendingDomain: p.pendingDomain, // domainId is irrelevant to desiredTuples (it only reads key/domainType/grants); pass the // pending Ref through so the shape stays a valid DesiredPermission. - diff: diffGrants(desiredTuples({ ...p, domainId: p.pendingDomain }, state, declaredGroupKeys, scopeRefs), []), + diff: diffGrants( + desiredTuples({ ...p, domainId: p.pendingDomain }, state, declaredGroupKeys, scopeRefs), + [], + ), }); continue; } @@ -220,11 +303,20 @@ export async function buildPermissionPlan( for (const authId of [...unknownAuthIds].sort((a, b) => a - b)) { warnings.push( `${p.domainType} #${p.domainId} ("${p.key}"): a live grant carries authId ${authId}, which is ` + - `not in the permission catalog — left untouched (never revoked). Regenerate the catalog ` + - `(\`npm run regenerate:permission-catalog\`) if this right should be manageable.`, + `not in the permission catalog — left untouched (never revoked). Capture this instance's own ` + + `catalog (\`ct permissions catalog --refresh\`) if this right should be manageable.`, ); } - items.push({ key: p.key, domainType: p.domainType, domainId: p.domainId, diff: diffGrants(desiredTuples(p, state, declaredGroupKeys, scopeRefs), knownActual) }); + items.push({ + key: p.key, + domainType: p.domainType, + domainId: p.domainId, + diff: diffGrants( + desiredTuples(p, state, declaredGroupKeys, scopeRefs), + knownActual, + preservePredicateFor(p.preserveUnknown), + ), + }); } return { items, fetchErrors, warnings }; } diff --git a/src/permissions/render.ts b/src/permissions/render.ts index d69146d..2a2298c 100644 --- a/src/permissions/render.ts +++ b/src/permissions/render.ts @@ -13,9 +13,7 @@ import { refLabel } from "../resolve/refs.js"; * resource pending-ref rendering (src/engine/render.ts). Its real id is filled in at apply time. */ function fmtDomain(item: PermissionPlanItem): string { - return item.pendingDomain - ? `<${refLabel(item.pendingDomain)} (created this apply)>` - : `#${item.domainId}`; + return item.pendingDomain ? `<${refLabel(item.pendingDomain)} (created this apply)>` : `#${item.domainId}`; } function fmtTuple(t: GrantTuple): string { @@ -29,27 +27,34 @@ function fmtTuple(t: GrantTuple): string { } export function renderPermissionPlan(items: PermissionPlanItem[]): string { - const changed = items.filter((i) => i.diff.toPut.length > 0 || i.diff.toDelete.length > 0); + // A `preserveUnknown` item can have an EMPTY diff and still deserve a line (#102): the whole point + // of the opt-in is that "I deliberately left the module grants alone" is visible, and it would not + // be if a role whose only interesting property is 41 preserved grants rendered as nothing at all. + const changed = items.filter( + (i) => i.diff.toPut.length > 0 || i.diff.toDelete.length > 0 || i.diff.preservedUnknown.length > 0, + ); if (changed.length === 0) { const preserved = items.reduce((n, i) => n + i.diff.preserved.length, 0); - const suffix = preserved > 0 - ? ` (${preserved} pre-existing deny row(s) left untouched.)` - : ""; + const suffix = preserved > 0 ? ` (${preserved} pre-existing deny row(s) left untouched.)` : ""; return pc.green(`No permission changes. Desired grants match ChurchTools.${suffix}`); } const lines: string[] = []; let totalGrant = 0; let totalRevoke = 0; + let totalPreserved = 0; for (const item of changed) { const grantCount = item.diff.toPut.length; const revokeCount = item.diff.toDelete.length; + const preservedCount = item.diff.preservedUnknown.length; totalGrant += grantCount; totalRevoke += revokeCount; + totalPreserved += preservedCount; + const preservedNote = preservedCount > 0 ? `, ${pc.dim(`~${preservedCount} preserved`)}` : ""; lines.push( - ` ${item.domainType} ${fmtDomain(item)} (${item.key}): ${pc.green(`+${grantCount} grant(s)`)}, ${pc.red(`-${revokeCount} remove(s)`)}`, + ` ${item.domainType} ${fmtDomain(item)} (${item.key}): ${pc.green(`+${grantCount} grant(s)`)}, ${pc.red(`-${revokeCount} remove(s)`)}${preservedNote}`, ); for (const t of item.diff.toPut) { lines.push(` ${pc.green("+")} ${fmtTuple(t)}`); @@ -57,12 +62,20 @@ export function renderPermissionPlan(items: PermissionPlanItem[]): string { for (const t of item.diff.toDelete) { lines.push(` ${pc.red("-")} ${fmtTuple(t)}`); } + for (const t of item.diff.preservedUnknown) { + lines.push(` ${pc.dim(`~ ${fmtTuple(t)} (preserved, not managed — preserveUnknown)`)}`); + } for (const t of item.diff.preserved) { lines.push(` ${pc.dim(`~ ${fmtTuple(t)} (pre-existing deny — left untouched)`)}`); } } lines.push(""); - lines.push(pc.bold(`Permission plan: ${totalGrant} to grant, ${totalRevoke} to remove.`)); + // Preserved grants are counted separately and never folded into the change totals: they are + // explicitly what apply will NOT do. + const preservedSummary = totalPreserved > 0 ? `, ${totalPreserved} preserved (not managed)` : ""; + lines.push( + pc.bold(`Permission plan: ${totalGrant} to grant, ${totalRevoke} to remove${preservedSummary}.`), + ); return lines.join("\n"); } diff --git a/src/permissions/scope.ts b/src/permissions/scope.ts index 18073d1..bb80078 100644 --- a/src/permissions/scope.ts +++ b/src/permissions/scope.ts @@ -2,7 +2,16 @@ import type { State } from "../state/state.js"; import type { GrantTuple } from "./grants.js"; import type { DesiredPermission, ScopeEntry } from "./types.js"; import type { Resolver } from "../resolve/resolver.js"; -import { isPendingRef, isRef, ref, refKey, refLabel, type Ref, type RefKind, type SimpleRef } from "../resolve/refs.js"; +import { + isPendingRef, + isRef, + ref, + refKey, + refLabel, + type Ref, + type RefKind, + type SimpleRef, +} from "../resolve/refs.js"; import { resolveAuthId } from "./catalog.js"; /** @@ -14,7 +23,12 @@ import { resolveAuthId } from "./catalog.js"; * unchanged when it has no `scopeKey`). `type` is the MANAGED RESOURCE TYPE behind `key` (#98): * "group" for the historical group dimension, "campus"/"group-type" for a typed logical scope ref. */ -export interface ScopeResolution { key: string; id: number | null; numeric?: boolean; type?: string } +export interface ScopeResolution { + key: string; + id: number | null; + numeric?: boolean; + type?: string; +} /** * ChurchTools' "every value of this dimension" dataId. CT both accepts it on write and reads it back @@ -117,7 +131,11 @@ export function normalizeScopeEntry(entry: unknown, where: string): string | num * state-backed identity to re-resolve at apply time. A ref that resolved through a live master-data * catalog carries neither: its id is already final and host-correct. */ -export interface ScopeRefResolution { id: number | null; managedKey?: string; managedType?: string } +export interface ScopeRefResolution { + id: number | null; + managedKey?: string; + managedType?: string; +} /** Typed scope refs resolved for this host, keyed by {@link refKey}. */ export type ScopeRefMap = ReadonlyMap; diff --git a/src/permissions/types.ts b/src/permissions/types.ts index f2a1115..59b7515 100644 --- a/src/permissions/types.ts +++ b/src/permissions/types.ts @@ -14,10 +14,7 @@ import type { Ref } from "../resolve/refs.js"; * (see `normalizeScopeEntry` in src/permissions/scope.ts), so `ref.campus("koblenz")` is identical. */ export type ScopeSugar = - | { group: string } - | { campus: string } - | { groupType: string } - | { department: string }; + { group: string } | { campus: string } | { groupType: string } | { department: string }; /** * One entry of a scoped grant's `scope`: @@ -33,6 +30,22 @@ export type ScopeEntry = string | number | Ref | ScopeSugar; export type Grant = string | { right: string; scope: ScopeEntry[] }; +/** + * Opt-in partial grant ownership (#102). A declaration normally OWNS its domain outright: every live + * grant absent from `grants` is revoked. That is the right default and stays the default — but it also + * means one unmanageable grant makes a whole role instance undeclarable, and on a real instance the + * blocker is usually module data (calendar categories, HTML templates, wiki categories) sitting next + * to perfectly expressible structural grants. + * + * - `true` — keep every live grant this declaration does not mention. + * - `string[]` — keep them only on these scope dimensions (`scopeField`s). The narrow form, and the + * one worth reaching for: an unexpected new grant on a dimension you DO manage still + * shows up as drift instead of being swallowed. + * + * Preserved grants are never invisible — the plan renders each one (see `renderPermissionPlan`). + */ +export type PreserveUnknown = boolean | string[]; + export interface DesiredPermission { key: string; domainType: DomainType; @@ -43,4 +56,6 @@ export interface DesiredPermission { */ domainId: number | Ref; grants: Grant[]; + /** Opt-in escape from whole-domain ownership (#102). Omitted ⇒ strict: undeclared grant → revoke. */ + preserveUnknown?: PreserveUnknown; } diff --git a/src/resolve/refs.ts b/src/resolve/refs.ts index 43e716e..df6392a 100644 --- a/src/resolve/refs.ts +++ b/src/resolve/refs.ts @@ -107,17 +107,37 @@ export const ref = { * So a department can be REFERENCED by name on any host, but never declared, adopted or created — * an unresolvable name is a hard error, not a create. */ - department: (key: string): SimpleRef => ({ __ctRef: true, kind: "department", key: requireKey("department", key) }), - groupType: (key: string): SimpleRef => ({ __ctRef: true, kind: "group-type", key: requireKey("group-type", key) }), - status: (key: string): SimpleRef => ({ __ctRef: true, kind: "group-status", key: requireKey("group-status", key) }), + department: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "department", + key: requireKey("department", key), + }), + groupType: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "group-type", + key: requireKey("group-type", key), + }), + status: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "group-status", + key: requireKey("group-status", key), + }), /** * A PERSON status (`/statuses` — "0 - First", "3 - Group Active", …), the domain of a `status` * permission declaration. Unrelated to {@link ref.status} (GROUP status, `groupStatusId`), which * has no catalog at all (#67) — person statuses do, so this one resolves by name like any other * master-data ref. */ - personStatus: (key: string): SimpleRef => ({ __ctRef: true, kind: "person-status", key: requireKey("person-status", key) }), - roleDef: (key: string): SimpleRef => ({ __ctRef: true, kind: "role-def", key: requireKey("role-def", key) }), + personStatus: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "person-status", + key: requireKey("person-status", key), + }), + roleDef: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "role-def", + key: requireKey("role-def", key), + }), group: (key: string): SimpleRef => ({ __ctRef: true, kind: "group", key: requireKey("group", key) }), /** * A `group_role` permission domain, by its (group, role) pair (#25). The resolver maps it to the @@ -189,9 +209,7 @@ export function pendingRef(r: Ref): PendingRef { export function isPendingRef(value: unknown): value is PendingRef { return ( - typeof value === "object" && - value !== null && - isRef((value as { __pendingRef?: unknown }).__pendingRef) + typeof value === "object" && value !== null && isRef((value as { __pendingRef?: unknown }).__pendingRef) ); } diff --git a/src/resolve/resolver.ts b/src/resolve/resolver.ts index 2aea3b4..5ed8ca1 100644 --- a/src/resolve/resolver.ts +++ b/src/resolve/resolver.ts @@ -418,7 +418,8 @@ export function reresolvePendingValue(value: unknown, state: State): unknown { if (Array.isArray(value)) return value.map((v) => reresolvePendingValue(v, state)); if (value !== null && typeof value === "object") { const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) out[k] = reresolvePendingValue(v, state); + for (const [k, v] of Object.entries(value as Record)) + out[k] = reresolvePendingValue(v, state); return out; } return value; diff --git a/src/resolve/reverse.ts b/src/resolve/reverse.ts index 6ea3893..c3c645e 100644 --- a/src/resolve/reverse.ts +++ b/src/resolve/reverse.ts @@ -56,22 +56,27 @@ export interface RoleCatalogEntry { } export class ReverseResolver { - private readonly client: Pick; + private readonly client: Pick; /** id → logical key, per catalog path, fetched at most once. A failed fetch caches an empty map. */ private readonly catalogs = new Map>>(); /** groupTypeRoleId → {groupTypeId, name} from `/group/roles`, fetched at most once (#76). */ private roleCatalog?: Promise>; - constructor(client: Pick) { + constructor(client: Pick) { this.client = client; } private index(path: string): Promise> { let p = this.catalogs.get(path); if (!p) { + // `getAll`, never a plain `get` (#101): these catalogs are LIST endpoints, so a plain GET + // returns only CT's default first page (10 rows). That silently truncated every reverse map — + // on eqrm prod `/group/roles` has 46 rows, so 36 roles had no id→key entry and every ruleset + // `role.id` pointing at one was left as a host-specific number with a vague warning. The + // forward Resolver was fixed for exactly this; the reverse side had the same bug. p = this.client - .get(path) - .then((rows) => { + .getAll(path) + .then(({ data: rows }) => { const map = new Map(); if (Array.isArray(rows)) { for (const row of rows) { @@ -116,13 +121,17 @@ export class ReverseResolver { async roleGroupTypeCatalog(): Promise> { if (!this.roleCatalog) { this.roleCatalog = this.client - .get("/group/roles") - .then((rows) => { + .getAll("/group/roles") + .then(({ data: rows }) => { const map = new Map(); if (Array.isArray(rows)) { for (const row of rows) { const groupTypeId = Number(row?.groupTypeId); - if (typeof row?.id === "number" && typeof row.name === "string" && Number.isFinite(groupTypeId)) { + if ( + typeof row?.id === "number" && + typeof row.name === "string" && + Number.isFinite(groupTypeId) + ) { map.set(row.id, { groupTypeId, name: row.name }); } } diff --git a/src/ui/prompt.ts b/src/ui/prompt.ts index 9225258..6930ea6 100644 --- a/src/ui/prompt.ts +++ b/src/ui/prompt.ts @@ -74,8 +74,6 @@ export async function confirmEnv( return false; } const ask = opts.ask ?? realAsk; - const answer = await ask( - `Protected environment "${envName}". Type the environment name to confirm: `, - ); + const answer = await ask(`Protected environment "${envName}". Type the environment name to confirm: `); return answer.trim() === envName; } diff --git a/tests/adopt-grants-bulk.test.ts b/tests/adopt-grants-bulk.test.ts new file mode 100644 index 0000000..1a9ae2c --- /dev/null +++ b/tests/adopt-grants-bulk.test.ts @@ -0,0 +1,201 @@ +/** + * Bulk `ct adopt grants` (#104). + * + * Adopting the declarable estate of a real instance used to mean one invocation and one manual paste + * per role instance, each needing its `key` renamed and its emitted numeric `id:` swapped for the + * portable `group` + `role` pair — the two edits a human forgets on the 30th paste. These tests pin + * the portable emission, the derived key, and the refusal to emit a revoking block in bulk. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { RawPermission } from "../src/permissions/grants.js"; +import type { State } from "../src/state/state.js"; + +/** `churchcore:administer settings` (unscoped) and `churchgroup:view group` (`cdb_gruppe`). */ +const UNSCOPED = 1; +const VIEW_GROUP = 1104; +/** `churchcore:use church html templates` — `cc_html_template`, module data with no resource. */ +const HTML_TEMPLATE = 17; + +const perm = (authId: number, domainId: number, dataId: number | null = null): RawPermission => ({ + authId, + dataId, + type: "grant", + domainId, + meta: { modifiedPid: 5 }, +}); + +/** + * Two groups. `kids` (#10, managed as "kids") has a declarable Leiter role and a Mitglied role blocked + * by a module dimension; `ops` (#11, unmanaged) has a declarable Leiter role. + */ +const groupRows = [ + { + id: 10, + name: "Kids", + information: { groupTypeId: 2 }, + roles: [ + { id: 100, groupTypeRoleId: 16 }, + { id: 101, groupTypeRoleId: 17 }, + ], + }, + { id: 11, name: "Ops", information: { groupTypeId: 2 }, roles: [{ id: 200, groupTypeRoleId: 16 }] }, +]; +const roleDefRows = [ + { id: 16, name: "Leiter", groupTypeId: 2 }, + { id: 17, name: "Mitglied", groupTypeId: 2 }, +]; +const permissions: RawPermission[] = [ + perm(UNSCOPED, 100), + perm(VIEW_GROUP, 100, 10), + perm(UNSCOPED, 101), + perm(HTML_TEMPLATE, 101, 3), // blocks role instance 101 + perm(UNSCOPED, 200), +]; + +const getMock = vi.fn(async (path: string): Promise => { + if (path === "/permissions/group_role") return permissions; + const m = /^\/permissions\/group_role\/(\d+)$/.exec(path); + if (m) return permissions.filter((p) => p.domainId === Number(m[1])); + throw new Error(`unmocked GET ${path}`); +}); +const getAllMock = vi.fn(async (path: string): Promise<{ data: unknown[] }> => { + if (path.startsWith("/groups")) return { data: groupRows }; + if (path === "/group/roles") return { data: roleDefRows }; + throw new Error(`unmocked getAll ${path}`); +}); + +vi.mock("../src/api/session.js", () => ({ + authedSession: vi.fn(async () => ({ client: { get: getMock, getAll: getAllMock }, me: { id: 1 } })), +})); + +const { adoptCommand } = await import("../src/commands/adopt.js"); + +const HOST = "https://mychurch.church.tools"; +const originalHost = process.env.CT_HOST; +let workDir: string; +let statePath: string; + +const state: State = { + version: 1, + host: HOST, + resources: { + kids: { type: "group", id: 10, key: "kids", fields: { name: "Kids" }, adoptedAt: "t", updatedAt: "t" }, + }, +}; + +async function run(args: string[]): Promise<{ stdout: string; stderr: string }> { + const outs: string[] = []; + const errs: string[] = []; + const o = vi.spyOn(process.stdout, "write").mockImplementation((s) => { + outs.push(String(s)); + return true; + }); + const e = vi.spyOn(process.stderr, "write").mockImplementation((s) => { + errs.push(String(s)); + return true; + }); + try { + await adoptCommand().parseAsync([...args, "--state", statePath], { from: "user" }); + } finally { + o.mockRestore(); + e.mockRestore(); + } + return { stdout: outs.join(""), stderr: errs.join("") }; +} + +beforeEach(() => { + getMock.mockClear(); + getAllMock.mockClear(); + process.env.CT_HOST = HOST; + workDir = mkdtempSync(join(tmpdir(), "ct-adopt-grants-bulk-")); + statePath = join(workDir, "ct-state.json"); + writeFileSync(statePath, JSON.stringify(state, null, 2), "utf8"); +}); + +afterEach(() => { + if (originalHost === undefined) delete process.env.CT_HOST; + else process.env.CT_HOST = originalHost; + rmSync(workDir, { recursive: true, force: true }); +}); + +describe("ct adopt grants --group (#104)", () => { + it("emits the PORTABLE group + role form and a derived key when the group is managed", async () => { + const { stdout } = await run(["grants", "--group", "kids"]); + expect(stdout).toContain('key: "kids_leiter"'); + expect(stdout).toContain('group: "kids"'); + expect(stdout).toContain('role: "Leiter"'); + // The host-specific pairing id is exactly what the portable form replaces. + expect(stdout).not.toContain("id: 100"); + }); + + it("skips the role instance blocked by a module dimension, and says which and why", async () => { + const { stdout, stderr } = await run(["grants", "--group", "kids"]); + expect(stdout).not.toContain("kids_mitglied"); + expect(stderr).toContain("Kids / Mitglied (domainId 101)"); + expect(stderr).toContain("cc_html_template"); + expect(stderr).toContain("preserveUnknown"); + }); + + it("falls back to the numeric id form when the group is NOT managed", async () => { + const { stdout } = await run(["grants", "--group", "11"]); + expect(stdout).toContain("id: 200"); + expect(stdout).toContain("host-specific — adopt the group"); + expect(stdout).not.toContain('group: "'); + }); + + it("resolves --group by live name as well as by managed key and numeric id", async () => { + const { stdout } = await run(["grants", "--group", "Kids"]); + expect(stdout).toContain('group: "kids"'); + }); + + it("summarises what it emitted and what it did not — never a silent cap", async () => { + const { stderr } = await run(["grants", "--group", "kids"]); + expect(stderr).toMatch(/1 block\(s\) emitted .* 1 skipped \(not declarable\)/); + }); +}); + +describe("ct adopt grants --all-declarable (#104)", () => { + it("emits every declarable role instance on the host in one invocation", async () => { + const { stdout } = await run(["grants", "--all-declarable"]); + expect(stdout).toContain('key: "kids_leiter"'); + expect(stdout).toContain("id: 200"); // the unmanaged group's declarable role, numeric form + expect(stdout).not.toContain("kids_mitglied"); + }); + + it("--write appends the blocks to a file instead of printing them", async () => { + const target = join(workDir, "grants.ts"); + writeFileSync(target, "// existing\n", "utf8"); + const { stdout } = await run(["grants", "--all-declarable", "--write", target]); + const written = readFileSync(target, "utf8"); + expect(written).toContain("// existing"); + expect(written).toContain('key: "kids_leiter"'); + expect(stdout).not.toContain("ct.groupRole"); // went to the file, not stdout + }); +}); + +describe("ct adopt grants — argument guards (#104)", () => { + it("rejects mixing a positional domain with a bulk selector", async () => { + await expect(run(["grants", "group_role", "100", "--group", "kids"])).rejects.toThrow(/not both/); + }); + + it("rejects both bulk selectors at once", async () => { + await expect(run(["grants", "--group", "kids", "--all-declarable"])).rejects.toThrow( + /only one of: --group, --all-declarable/, + ); + }); + + it("still supports the original single-domain form", async () => { + const { stdout } = await run(["grants", "group_role", "100"]); + expect(getMock).toHaveBeenCalledWith("/permissions/group_role/100"); + // The single form keeps its pre-#104 shape: numeric id, rename-to-taste key. + expect(stdout).toContain("id: 100"); + expect(stdout).toContain("rename to taste"); + }); + + it("explains what to pass when given nothing at all", async () => { + await expect(run(["grants"])).rejects.toThrow(/Specify /); + }); +}); diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts index c46eaba..3f06300 100644 --- a/tests/adopt-group-command.test.ts +++ b/tests/adopt-group-command.test.ts @@ -381,36 +381,69 @@ describe("ct adopt group --with-dynamic --portable-rulesets (#76 Stage 3)", () = expect(and[3]!.oneof![1]).toEqual([1, 2]); }); - it("warns once naming the file for the unmanaged id it left numeric (escape hatch)", async () => { + it("names every dimension it left numeric, with the reason (#101)", async () => { const errs: string[] = []; const spy = vi.spyOn(process.stderr, "write").mockImplementation((s) => { errs.push(String(s)); return true; }); try { - await adoptWithManagedGroup(["--portable-rulesets"]); + await adoptWithManagedGroup([]); } finally { spy.mockRestore(); } const warned = errs.join(""); - expect(warned).toMatch(/left 1 unmanaged id\(s\) numeric in portable_dynamic\.json/); - expect(warned).toContain("escape hatch"); + // The whole point of #101: not "left N ids numeric", but WHICH dimension, WHICH ids, and WHY. + expect(warned).toContain("rulesets/portable_dynamic.json keeps"); + expect(warned).toContain("NOT portable to another host"); + // Detail is id-free: formatPortablizeWarnings prints the ids once, ahead of it, so a detail + // naming one id would be stamped across every id merged into the line. + expect(warned).toMatch(/ctgroup\.id: 999 left numeric — not under management/); + expect(warned).toMatch(/ctgroup\.groupStatusId: 1, 2 left numeric — group statuses have no REST catalog/); }); - it("is OFF by default: a plain --with-dynamic capture keeps raw numeric ids (no markers)", async () => { - await adoptWithManagedGroup([]); // no --portable-rulesets + it("is ON by default since #101: a plain --with-dynamic capture emits ref markers", async () => { + await adoptWithManagedGroup([]); // no flag at all + const written = JSON.parse(await readFile(join(workDir, "rulesets", "portable_dynamic.json"), "utf8")); + const and = (written.query as { and: Array> }).and; + expect(and[0]!.oneof![1]).toEqual([{ __ctRef: true, kind: "group", key: "area_a" }, 999]); + }); + + it("--no-portable-rulesets opts out, keeping raw numeric ids — and says the capture is host-specific", async () => { + const errs: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((s) => { + errs.push(String(s)); + return true; + }); + try { + await adoptWithManagedGroup(["--no-portable-rulesets"]); + } finally { + spy.mockRestore(); + } const written = JSON.parse(await readFile(join(workDir, "rulesets", "portable_dynamic.json"), "utf8")); const and = (written.query as { and: Array> }).and; expect(and[0]!.oneof![1]).toEqual([10, 999]); // raw ids, no rewrite expect(JSON.stringify(written)).not.toContain("__ctRef"); + expect(errs.join("")).toContain("captured verbatim (--no-portable-rulesets)"); + }); + + it("--strict-rulesets refuses to write a ruleset that still carries a host-specific id", async () => { + await run(["group", "10", "--state", statePath]); + await expect( + run(["group", "33", "--with-dynamic", "--strict-rulesets", "--state", statePath]), + ).rejects.toThrow(/--strict-rulesets/); + // Nothing was written — the refusal must not leave a half-portable file behind. + await expect(readFile(join(workDir, "rulesets", "portable_dynamic.json"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); }); it("--dry-run --portable-rulesets writes no file", async () => { await run(["group", "10", "--state", statePath]); await run(["group", "33", "--with-dynamic", "--portable-rulesets", "--dry-run", "--state", statePath]); - await expect( - readFile(join(workDir, "rulesets", "portable_dynamic.json"), "utf8"), - ).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(join(workDir, "rulesets", "portable_dynamic.json"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); }); }); diff --git a/tests/adopt.test.ts b/tests/adopt.test.ts index 4c47173..0236d5b 100644 --- a/tests/adopt.test.ts +++ b/tests/adopt.test.ts @@ -4,10 +4,19 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; const fakeCampus: Record = { id: 0, name: "Mainz", shorty: "MZ" }; -const getMock = vi.fn(async (): Promise> => fakeCampus); +const getMock = vi.fn(async (...args: [string?]): Promise> => { + void args; + return fakeCampus; +}); +// The ReverseResolver reads its master-data catalogs through `getAll` (#101): a plain `get` returns +// only CT's default first page, which silently truncated every id→key map on a real instance. +const getAllMock = vi.fn(async (path: string): Promise<{ data: unknown[] }> => { + const single = await getMock(path); + return { data: Array.isArray(single) ? single : [single] }; +}); vi.mock("../src/api/session.js", () => ({ - authedSession: vi.fn(async () => ({ client: { get: getMock }, me: { id: 1 } })), + authedSession: vi.fn(async () => ({ client: { get: getMock, getAll: getAllMock }, me: { id: 1 } })), })); const { adoptCommand } = await import("../src/commands/adopt.js"); diff --git a/tests/apply-env-command.test.ts b/tests/apply-env-command.test.ts index 117aa0f..45e8e02 100644 --- a/tests/apply-env-command.test.ts +++ b/tests/apply-env-command.test.ts @@ -14,7 +14,13 @@ import type { Plan } from "../src/engine/types.js"; const plan: Plan = { items: [ - { type: "campus", key: "mainz", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "Mainz" }] }, + { + type: "campus", + key: "mainz", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "Mainz" }], + }, ], }; diff --git a/tests/apply-refresh.test.ts b/tests/apply-refresh.test.ts index 6f60d31..fb953b6 100644 --- a/tests/apply-refresh.test.ts +++ b/tests/apply-refresh.test.ts @@ -80,8 +80,16 @@ describe("runPostApplyHooks (dynamic refresh)", () => { const refreshCalls = calls.filter((c) => c.path.startsWith("/dynamicgroups/")); expect(refreshCalls).toHaveLength(2); - expect(refreshCalls).toContainEqual({ method: "POST", path: "/dynamicgroups/42/refresh", body: undefined }); - expect(refreshCalls).toContainEqual({ method: "POST", path: "/dynamicgroups/43/refresh", body: undefined }); + expect(refreshCalls).toContainEqual({ + method: "POST", + path: "/dynamicgroups/42/refresh", + body: undefined, + }); + expect(refreshCalls).toContainEqual({ + method: "POST", + path: "/dynamicgroups/43/refresh", + body: undefined, + }); }); it("never calls the all-groups /dynamicgroups/refresh endpoint", async () => { @@ -129,7 +137,13 @@ describe("runPostApplyHooks (dynamic refresh)", () => { key: "dyn_a", id: 42, action: "update", - changes: [{ field: "dynamic", from: { status: "active", ruleset: {} }, to: { status: "none", ruleset: {} } }], + changes: [ + { + field: "dynamic", + from: { status: "active", ruleset: {} }, + to: { status: "none", ruleset: {} }, + }, + ], }, { type: "group", @@ -179,8 +193,16 @@ describe("runPostApplyHooks (dynamic refresh)", () => { await expect(runPostApplyHooks(plan, state, client)).resolves.toBeUndefined(); const refreshCalls = calls.filter((c) => c.path.startsWith("/dynamicgroups/")); expect(refreshCalls).toHaveLength(2); - expect(refreshCalls).toContainEqual({ method: "POST", path: "/dynamicgroups/42/refresh", body: undefined }); - expect(refreshCalls).toContainEqual({ method: "POST", path: "/dynamicgroups/43/refresh", body: undefined }); + expect(refreshCalls).toContainEqual({ + method: "POST", + path: "/dynamicgroups/42/refresh", + body: undefined, + }); + expect(refreshCalls).toContainEqual({ + method: "POST", + path: "/dynamicgroups/43/refresh", + body: undefined, + }); }); it("skips a changed-dynamic item whose id is not yet resolvable in state (explicit undefined check, not truthiness)", async () => { @@ -204,7 +226,9 @@ describe("runPostApplyHooks (dynamic refresh)", () => { }, ], }; - const { client, calls } = recorder({ "POST /dynamicgroups/0/refresh": [{ created: 0, updated: 0, deleted: 0 }] }); + const { client, calls } = recorder({ + "POST /dynamicgroups/0/refresh": [{ created: 0, updated: 0, deleted: 0 }], + }); await runPostApplyHooks(plan, state, client); expect(calls).toEqual([{ method: "POST", path: "/dynamicgroups/0/refresh", body: undefined }]); }); diff --git a/tests/blueprint.test.ts b/tests/blueprint.test.ts index 2130a0e..f2690de 100644 --- a/tests/blueprint.test.ts +++ b/tests/blueprint.test.ts @@ -23,7 +23,7 @@ describe("campus blueprint", () => { const order = orderKeys(resources); const pos = (k: string) => order.indexOf(k); for (const c of ["mainz", "berlin"]) { - expect(pos(c)).toBeLessThan(pos(`${c}_lead`)); // campus (tier 0) before its groups (tier 1) + expect(pos(c)).toBeLessThan(pos(`${c}_lead`)); // campus (tier 0) before its groups (tier 1) expect(pos(`${c}_lead`)).toBeLessThan(pos(`${c}_team`)); // parent before child (intra-tier dependency) } }); diff --git a/tests/config.test.ts b/tests/config.test.ts index a211eed..c61c284 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -12,7 +12,10 @@ describe("normalizeHost", () => { describe("resolveConfig", () => { it("prefers CT_HOST env over the stored host", async () => { - const config = await resolveConfig({ CT_HOST: "https://env.church.tools" }, async () => "https://stored.church.tools"); + const config = await resolveConfig( + { CT_HOST: "https://env.church.tools" }, + async () => "https://stored.church.tools", + ); expect(config.host).toBe("https://env.church.tools"); }); diff --git a/tests/coverage.test.ts b/tests/coverage.test.ts new file mode 100644 index 0000000..a706c94 --- /dev/null +++ b/tests/coverage.test.ts @@ -0,0 +1,190 @@ +/** + * `ct coverage` (#103). The verdict half is what matters: whether a live (group, role) instance's + * grants could be declared as config today, and — when they cannot — which scope dimension blocks it. + * Everything here is offline: the report builder takes already-fetched rows. + */ +import { describe, it, expect } from "vitest"; +import { + buildCoverageReport, + declarability, + decodeGroupsWithRoles, + renderCoverage, + type GroupRow, +} from "../src/coverage/report.js"; +import type { RawPermission } from "../src/permissions/grants.js"; +import type { State } from "../src/state/state.js"; + +/** `churchgroup:view group` — `cdb_gruppe`, a dimension with a logical reference form (#98). */ +const VIEW_GROUP = 1104; +/** `churchcore:use church html templates` — `cc_html_template`, module data with no resource behind it. */ +const HTML_TEMPLATE = 17; +/** `churchcore:administer settings` — unscoped. */ +const UNSCOPED = 1; + +const row = (o: Partial & { authId: number; domainId: number }): RawPermission => ({ + dataId: null, + type: "grant", + meta: { modifiedPid: 7 }, + ...o, +}); + +describe("declarability (#103)", () => { + it("calls an unscoped + group-scoped role instance declarable", () => { + const v = declarability([ + row({ authId: UNSCOPED, domainId: 1 }), + row({ authId: VIEW_GROUP, dataId: 42, domainId: 1 }), + ]); + expect(v).toMatchObject({ declarable: true, grantCount: 2, blockedBy: [] }); + }); + + it("names the blocking dimension rather than just saying no", () => { + const v = declarability([ + row({ authId: VIEW_GROUP, dataId: 42, domainId: 1 }), + row({ authId: HTML_TEMPLATE, dataId: 3, domainId: 1 }), + ]); + expect(v.declarable).toBe(false); + expect(v.blockedBy).toEqual(["cc_html_template"]); + // The count is of ALL authored grants, so "blocked by 1 of 2" is visible, not just "blocked". + expect(v.grantCount).toBe(2); + }); + + it("excludes inherited rows and the system baseline from the authored count", () => { + // Forgetting this filter is the exact mistake that inflated a hand-rolled audit from 590 to 714 + // grants and made several role instances look unmanageable that are not. + const v = declarability([ + row({ authId: VIEW_GROUP, dataId: 42, domainId: 1 }), + row({ authId: HTML_TEMPLATE, dataId: 3, domainId: 1, isInherited: true }), + row({ authId: HTML_TEMPLATE, dataId: 4, domainId: 1, meta: { modifiedPid: -1 } }), + ]); + expect(v.grantCount).toBe(1); + expect(v.declarable).toBe(true); // the only blocker was an inherited row ct never authors + }); + + it("treats the -1 ALL sentinel as declarable on any dimension", () => { + const v = declarability([row({ authId: HTML_TEMPLATE, dataId: -1, domainId: 1 })]); + expect(v.declarable).toBe(true); + }); + + it("blocks on a right the catalog cannot even name", () => { + const v = declarability([row({ authId: 999_999, dataId: 1, domainId: 1 })]); + expect(v.declarable).toBe(false); + expect(v.unknownAuthIds).toEqual([999_999]); + }); +}); + +describe("buildCoverageReport (#103)", () => { + const state: State = { + version: 1, + host: "h", + resources: { + struktur_a: { type: "group", id: 1, key: "struktur_a", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, + }; + const groups: GroupRow[] = [ + { + id: 1, + name: "Struktur A", + groupTypeId: 9, + roles: [ + { domainId: 100, groupId: 1, groupName: "Struktur A", groupTypeId: 9, roleName: "Leiter" }, + { domainId: 101, groupId: 1, groupName: "Struktur A", groupTypeId: 9, roleName: "Mitglied" }, + ], + }, + { + id: 2, + name: "Local Lead B", + groupTypeId: 12, + roles: [{ domainId: 200, groupId: 2, groupName: "Local Lead B", groupTypeId: 12, roleName: "Leiter" }], + }, + { id: 3, name: "Plain C", groupTypeId: 12, roles: [] }, + ]; + const report = buildCoverageReport({ + host: "h", + state, + groups, + groupTypeNames: new Map([ + [9, "Struktur"], + [12, "Local Lead"], + ]), + dynamicGroupIds: new Set([1, 3]), + groupRolePermissions: [ + row({ authId: VIEW_GROUP, dataId: 42, domainId: 100 }), + row({ authId: HTML_TEMPLATE, dataId: 3, domainId: 101 }), + row({ authId: UNSCOPED, domainId: 200 }), + ], + }); + + it("reports declarability PER (group, role), not per group", () => { + // Struktur A has one declarable role and one blocked one — reporting at group granularity would + // hide exactly that, which is why the unit is the role instance. + const strukturA = report.roleInstances.filter((r) => r.groupId === 1); + expect(strukturA.map((r) => [r.roleName, r.verdict.declarable])).toEqual([ + ["Leiter", true], + ["Mitglied", false], + ]); + expect(report.grants).toMatchObject({ declarable: 2, blocked: 1, roleInstances: 3, authored: 3 }); + }); + + it("collects every blocking dimension instance-wide", () => { + expect(report.grants.blockingDimensions).toEqual(["cc_html_template"]); + }); + + it("counts managed / dynamic / unmanaged-with-grants per group type", () => { + const byName = new Map(report.byType.map((t) => [t.name, t])); + expect(byName.get("Struktur")).toMatchObject({ + total: 1, + managed: 1, + dynamic: 1, + unmanagedWithGrants: 0, + }); + // Group 2 is unmanaged AND carries grants — the "you are missing this" number. Group 3 is + // unmanaged too but has no grants, so it must not inflate it. + expect(byName.get("Local Lead")).toMatchObject({ + total: 2, + managed: 0, + dynamic: 1, + unmanagedWithGrants: 1, + }); + }); + + it("skips role instances with no authored grants entirely", () => { + expect(report.roleInstances.some((r) => r.groupId === 3)).toBe(false); + }); + + it("marks which role instances sit on a managed group", () => { + expect(report.roleInstances.find((r) => r.domainId === 100)?.managedGroupKey).toBe("struktur_a"); + expect(report.roleInstances.find((r) => r.domainId === 200)?.managedGroupKey).toBeNull(); + }); + + it("renders the totals, the per-type table and the blocking dimensions", () => { + const text = renderCoverage(report); + expect(text).toContain("3 groups · 1 managed · 3 authored grants over 3 role instances"); + expect(text).toContain("Struktur"); + expect(text).toContain("2 role instance(s) declarable · 1 blocked"); + expect(text).toContain("blocked by: cc_html_template"); + }); +}); + +describe("decodeGroupsWithRoles (#103)", () => { + it("reads groupTypeId from `information` and role names inline", () => { + const [g] = decodeGroupsWithRoles( + [{ id: 5, name: "Kids", information: { groupTypeId: 2 }, roles: [{ id: 77, name: "Leiter" }] }], + new Map(), + ); + expect(g).toMatchObject({ id: 5, groupTypeId: 2 }); + expect(g?.roles[0]).toMatchObject({ domainId: 77, roleName: "Leiter", groupId: 5 }); + }); + + it("falls back to the /group/roles catalog when the role row carries no inline name", () => { + const [g] = decodeGroupsWithRoles( + [{ id: 5, name: "Kids", groupTypeId: 2, roles: [{ id: 77, groupTypeRoleId: 16 }] }], + new Map([[16, "Organisator"]]), + ); + expect(g?.roles[0]?.roleName).toBe("Organisator"); + }); + + it("still counts a role whose name cannot be recovered — it has grants either way", () => { + const [g] = decodeGroupsWithRoles([{ id: 5, name: "Kids", roles: [{ id: 77 }] }], new Map()); + expect(g?.roles[0]).toMatchObject({ domainId: 77, roleName: "role #77" }); + }); +}); diff --git a/tests/ctClient.test.ts b/tests/ctClient.test.ts index a985fb6..09be860 100644 --- a/tests/ctClient.test.ts +++ b/tests/ctClient.test.ts @@ -18,7 +18,10 @@ describe("CtClient", () => { const fetchMock = vi .fn() .mockResolvedValueOnce( - jsonResponse({ data: { id: 7, firstName: "Ada" } }, { setCookie: "ChurchTools_ct_mychurch=abc; Path=/" }), + jsonResponse( + { data: { id: 7, firstName: "Ada" } }, + { setCookie: "ChurchTools_ct_mychurch=abc; Path=/" }, + ), ) .mockResolvedValueOnce(jsonResponse({ data: "csrf-123" })); vi.stubGlobal("fetch", fetchMock); @@ -55,7 +58,10 @@ describe("CtClient", () => { expect(headers.get("Cookie")).toContain("s=1"); }); - async function authedClient(): Promise<{ client: CtClient; fetchMock: ReturnType> }> { + async function authedClient(): Promise<{ + client: CtClient; + fetchMock: ReturnType>; + }> { const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ data: { id: 1 } }, { setCookie: "s=1; Path=/" })) diff --git a/tests/destroy.test.ts b/tests/destroy.test.ts index 6483ecb..6cb65b7 100644 --- a/tests/destroy.test.ts +++ b/tests/destroy.test.ts @@ -6,7 +6,14 @@ import { CtApiError, type CtClient } from "../src/api/ctClient.js"; function stateWith(...entries: Array<{ key: string; type: string; id: number }>): State { const state = emptyState("h"); for (const e of entries) { - state.resources[e.key] = { type: e.type, id: e.id, key: e.key, fields: {}, adoptedAt: "t", updatedAt: "t" }; + state.resources[e.key] = { + type: e.type, + id: e.id, + key: e.key, + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; } return state; } @@ -40,10 +47,7 @@ describe("orderDestroy", () => { }); it("orders a child before its parent within the group tier (live hierarchy edges)", () => { - const state = stateWith( - { key: "area", type: "group", id: 1 }, - { key: "kids", type: "group", id: 2 }, - ); + const state = stateWith({ key: "area", type: "group", id: 1 }, { key: "kids", type: "group", id: 2 }); // kids → parent area. State carries no edges, so the command supplies them from /groups/hierarchies. const edges = new Map([["kids", ["area"]]]); // Input order deliberately parent-first: a tier-only sort would delete area before kids. @@ -74,10 +78,16 @@ describe("runDeleteLoop", () => { }); const save = vi.fn(async () => {}); - await runDeleteLoop({ client: asClient(request), state, statePath: "s.json", ordered: ["gone", "other"], save }); + await runDeleteLoop({ + client: asClient(request), + state, + statePath: "s.json", + ordered: ["gone", "other"], + save, + }); - expect(state.resources.gone).toBeUndefined(); // already-deleted target removed from state - expect(state.resources.other).toBeUndefined(); // loop continued and deleted the rest + expect(state.resources.gone).toBeUndefined(); // already-deleted target removed from state + expect(state.resources.other).toBeUndefined(); // loop continued and deleted the rest expect(request).toHaveBeenCalledTimes(2); expect(save).toHaveBeenCalledTimes(2); }); @@ -91,11 +101,17 @@ describe("runDeleteLoop", () => { const save = vi.fn(async () => {}); const prevExit = process.exitCode; - await runDeleteLoop({ client: asClient(request), state, statePath: "s.json", ordered: ["boom", "later"], save }); + await runDeleteLoop({ + client: asClient(request), + state, + statePath: "s.json", + ordered: ["boom", "later"], + save, + }); - expect(state.resources.boom).toBeDefined(); // not removed — the DELETE failed - expect(state.resources.later).toBeDefined(); // never reached - expect(request).toHaveBeenCalledTimes(1); // stopped at the first target + expect(state.resources.boom).toBeDefined(); // not removed — the DELETE failed + expect(state.resources.later).toBeDefined(); // never reached + expect(request).toHaveBeenCalledTimes(1); // stopped at the first target expect(save).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); process.exitCode = prevExit; @@ -104,7 +120,8 @@ describe("runDeleteLoop", () => { it("renders a CtApiError's HTTP status + body in the stop message, via the shared formatter (#71)", async () => { const state = stateWith({ key: "boom", type: "group", id: 1 }); const request = vi.fn(async (_m: string, path: string) => { - if (path === "/groups/1") throw new CtApiError("DELETE /groups/1 failed", 403, { message: "no permission" }); + if (path === "/groups/1") + throw new CtApiError("DELETE /groups/1 failed", 403, { message: "no permission" }); return {}; }); const save = vi.fn(async () => {}); diff --git a/tests/dynamic.integration.test.ts b/tests/dynamic.integration.test.ts index 3b44993..c445abf 100644 --- a/tests/dynamic.integration.test.ts +++ b/tests/dynamic.integration.test.ts @@ -111,7 +111,10 @@ describe.runIf(live && liveWrite)("dynamic ruleset round-trip pin (#36, live wri const changedFields = [...allFields].filter((f) => !isDeepStrictEqual(wantNorm[f], gotNorm[f])); if (changedFields.length > 0) { const detail = changedFields - .map((f) => ` ${f}:\n authored: ${JSON.stringify(wantNorm[f])}\n returned: ${JSON.stringify(gotNorm[f])}`) + .map( + (f) => + ` ${f}:\n authored: ${JSON.stringify(wantNorm[f])}\n returned: ${JSON.stringify(gotNorm[f])}`, + ) .join("\n"); throw new Error( `CT rewrote ${changedFields.length} RuleSet field(s) on PUT — extend normalizeRuleset ` + @@ -127,13 +130,15 @@ describe.runIf(live && liveWrite)("dynamic ruleset round-trip pin (#36, live wri // "manual" (e.g. the committed fixture at tests/fixtures/dynamic/status.get.json is "active"). // This test only ever writes the ruleset (never a status PUT), so reading the live status here // isolates exactly the #36 property under test — ruleset field rewriting — from status drift. - const liveStatus = ( - await client.get<{ dynamicGroupStatus?: string }>(`/dynamicgroups/${GID}/status`) - )?.dynamicGroupStatus ?? "none"; + const liveStatus = + (await client.get<{ dynamicGroupStatus?: string }>(`/dynamicgroups/${GID}/status`)) + ?.dynamicGroupStatus ?? "none"; const state: State = { version: 1, host: expectedHost, - resources: { pin36: { type: "group", id: GID, key: "pin36", fields: {}, adoptedAt: "t", updatedAt: "t" } }, + resources: { + pin36: { type: "group", id: GID, key: "pin36", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, }; const actual = new Map>([["pin36", {}]]); const desired: DesiredResource[] = [ @@ -150,7 +155,10 @@ describe.runIf(live && liveWrite)("dynamic ruleset round-trip pin (#36, live wri const dynamicChange = diffFields(folded.desired[0]!.fields, actual.get("pin36")!).find( (c) => c.field === "dynamic", ); - expect(dynamicChange, `plan is not a no-op after the PUT: ${JSON.stringify(dynamicChange)}`).toBeUndefined(); + expect( + dynamicChange, + `plan is not a no-op after the PUT: ${JSON.stringify(dynamicChange)}`, + ).toBeUndefined(); } finally { // Restore the prior ruleset so the dev instance isn't left mutated by this test. await client.request("PUT", `/dynamicgroups/${GID}/ruleset`, putRulesetBody(normalizeRuleset(before))); diff --git a/tests/dynamic.test.ts b/tests/dynamic.test.ts index 3a0acc0..514ebe0 100644 --- a/tests/dynamic.test.ts +++ b/tests/dynamic.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; -import { normalizeRuleset, stripCosmeticLabels, coerceScalars, normalizeDynamic, putRulesetBody } from "../src/engine/dynamic.js"; +import { + normalizeRuleset, + stripCosmeticLabels, + coerceScalars, + normalizeDynamic, + putRulesetBody, +} from "../src/engine/dynamic.js"; describe("stripCosmeticLabels", () => { it("unwraps a dterm with a string label to its expr, recursively", () => { @@ -8,41 +14,49 @@ describe("stripCosmeticLabels", () => { expect(stripCosmeticLabels(input)).toEqual({ and: [{ "==": [{ var: "ctgroup.campusId" }, 1] }] }); }); it("unwraps a dterm with an object label (title/stereotype/i18n key)", () => { - const input = { dterm: [{ title: "group.x.title", stereotype: ["groupmembership"] }, { isnull: [{ var: "person.dateOfDeath" }] }] }; + const input = { + dterm: [ + { title: "group.x.title", stereotype: ["groupmembership"] }, + { isnull: [{ var: "person.dateOfDeath" }] }, + ], + }; expect(stripCosmeticLabels(input)).toEqual({ isnull: [{ var: "person.dateOfDeath" }] }); }); }); describe("coerceScalars", () => { it("coerces numeric strings to numbers so int/string drift is not spurious", () => { - expect(coerceScalars({ "==": [{ var: "ctgroup.campusId" }, "1"] })) - .toEqual({ "==": [{ var: "ctgroup.campusId" }, 1] }); + expect(coerceScalars({ "==": [{ var: "ctgroup.campusId" }, "1"] })).toEqual({ + "==": [{ var: "ctgroup.campusId" }, 1], + }); }); it("coerces numeric strings inside oneof arrays but leaves non-numeric strings", () => { - expect(coerceScalars({ oneof: [{ var: "ctgroup.id" }, ["112", "8"]] })) - .toEqual({ oneof: [{ var: "ctgroup.id" }, [112, 8]] }); - expect(coerceScalars({ "==": [{ var: "groupmember.groupMemberStatus" }, "active"] })) - .toEqual({ "==": [{ var: "groupmember.groupMemberStatus" }, "active"] }); + expect(coerceScalars({ oneof: [{ var: "ctgroup.id" }, ["112", "8"]] })).toEqual({ + oneof: [{ var: "ctgroup.id" }, [112, 8]], + }); + expect(coerceScalars({ "==": [{ var: "groupmember.groupMemberStatus" }, "active"] })).toEqual({ + "==": [{ var: "groupmember.groupMemberStatus" }, "active"], + }); }); it("leaves leading-zero and >2^53 numeric strings as strings (no corruption, no precision loss)", () => { // A leading-zero zip code is a semantic string; parseInt would drop the zero and break the compare. - expect(coerceScalars({ "==": [{ var: "person.zip" }, "01067"] })) - .toEqual({ "==": [{ var: "person.zip" }, "01067"] }); + expect(coerceScalars({ "==": [{ var: "person.zip" }, "01067"] })).toEqual({ + "==": [{ var: "person.zip" }, "01067"], + }); // A digit string beyond MAX_SAFE_INTEGER can't be represented exactly — must stay a string. const big = "90071992547409910"; // > 2^53 - expect(coerceScalars({ "==": [{ var: "x.id" }, big] })) - .toEqual({ "==": [{ var: "x.id" }, big] }); + expect(coerceScalars({ "==": [{ var: "x.id" }, big] })).toEqual({ "==": [{ var: "x.id" }, big] }); // Canonical ints still coerce, so a 5 vs "5" int/string pair keeps diffing equal. - expect(coerceScalars({ "==": [{ var: "x.n" }, "5"] })) - .toEqual({ "==": [{ var: "x.n" }, 5] }); - expect(coerceScalars({ "==": [{ var: "x.n" }, "0"] })) - .toEqual({ "==": [{ var: "x.n" }, 0] }); + expect(coerceScalars({ "==": [{ var: "x.n" }, "5"] })).toEqual({ "==": [{ var: "x.n" }, 5] }); + expect(coerceScalars({ "==": [{ var: "x.n" }, "0"] })).toEqual({ "==": [{ var: "x.n" }, 0] }); }); }); describe("normalizeRuleset", () => { it("drops read-only timestamps and the PUT envelope, and is idempotent", () => { - const withEnvelope = { dynamicGroupRuleSet: { description: "x", dynamicGroupUpdateStarted: "t", process: {}, query: {} } }; + const withEnvelope = { + dynamicGroupRuleSet: { description: "x", dynamicGroupUpdateStarted: "t", process: {}, query: {} }, + }; const once = normalizeRuleset(withEnvelope); expect(once).not.toHaveProperty("dynamicGroupUpdateStarted"); expect(once).not.toHaveProperty("dynamicGroupRuleSet"); @@ -55,16 +69,25 @@ describe("normalizeRuleset", () => { }); it("coerces filter operands but leaves numeric-looking RuleSet-level fields as strings", () => { - const r = { description: "2024", shorty: "007", personIdFieldName: "person.id", - query: { "==": [{ var: "ctgroup.id" }, "112"] }, process: {} }; + const r = { + description: "2024", + shorty: "007", + personIdFieldName: "person.id", + query: { "==": [{ var: "ctgroup.id" }, "112"] }, + process: {}, + }; const out = normalizeRuleset(r); expect(out.description).toBe("2024"); // stays a string, not 2024 - expect(out.shorty).toBe("007"); // stays a string, not 7 + expect(out.shorty).toBe("007"); // stays a string, not 7 expect(out.query).toEqual({ "==": [{ var: "ctgroup.id" }, 112] }); // filter operand coerced }); it("round-trips a leading-zero query leaf byte-identical (no retype on write-back)", () => { - const authored = { description: "Zip filter", query: { "==": [{ var: "person.zip" }, "01067"] }, process: {} }; + const authored = { + description: "Zip filter", + query: { "==": [{ var: "person.zip" }, "01067"] }, + process: {}, + }; const once = normalizeRuleset(authored); // The zip leaf survives normalization untouched — apply PUTs `to.ruleset`, so any retype here // would be written back to CT and silently break the JSONLogic string comparison. @@ -76,8 +99,8 @@ describe("normalizeRuleset", () => { for (const name of ["ruleset-683", "ruleset-2022", "ruleset-1092"]) { const raw = JSON.parse(readFileSync(`tests/fixtures/dynamic/${name}.get.json`, "utf8")); // array shape const once = normalizeRuleset(raw); - expect(normalizeRuleset(once)).toEqual(once); // idempotent - expect(JSON.stringify(once)).not.toContain("dterm"); // cosmetic labels stripped + expect(normalizeRuleset(once)).toEqual(once); // idempotent + expect(JSON.stringify(once)).not.toContain("dterm"); // cosmetic labels stripped } }); }); diff --git a/tests/envs.test.ts b/tests/envs.test.ts index 4a49c60..1562313 100644 --- a/tests/envs.test.ts +++ b/tests/envs.test.ts @@ -2,12 +2,7 @@ import { describe, it, expect, afterEach } from "vitest"; import { writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { - DEFAULT_ENVS_PATH, - resolveEnvsPath, - defaultEnvStatePath, - loadEnvProfile, -} from "../src/env/envs.js"; +import { DEFAULT_ENVS_PATH, resolveEnvsPath, defaultEnvStatePath, loadEnvProfile } from "../src/env/envs.js"; const envsPath = join(tmpdir(), `ct-cli-envs-${process.pid}.json`); @@ -67,16 +62,12 @@ describe("loadEnvProfile", () => { }); it("throws a friendly error when the envs file is missing", async () => { - await expect(loadEnvProfile("dev", envsPath)).rejects.toThrow( - /Environment profile file not found/, - ); + await expect(loadEnvProfile("dev", envsPath)).rejects.toThrow(/Environment profile file not found/); }); it("names the known environments when the requested one is absent", async () => { await writeEnvs({ environments: { dev: { host: "https://d.church.tools" } } }); - await expect(loadEnvProfile("prod", envsPath)).rejects.toThrow( - /Unknown environment "prod".*dev/s, - ); + await expect(loadEnvProfile("prod", envsPath)).rejects.toThrow(/Unknown environment "prod".*dev/s); }); it("rejects a profile missing a host", async () => { diff --git a/tests/execute.test.ts b/tests/execute.test.ts index dd8d9a3..8c5099e 100644 --- a/tests/execute.test.ts +++ b/tests/execute.test.ts @@ -81,7 +81,13 @@ describe("executePlan", () => { }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed).toBeUndefined(); // POST body = declared fields ∪ deterministic create-defaults (declared values win). expect(calls[0]).toEqual({ @@ -287,12 +293,23 @@ describe("executePlan", () => { }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.updated).toEqual(["team"]); // CT reads campus at information.campusId but accepts a top-level campusId on PATCH — mirroring // how groupTypeId/groupStatusId are written. PATCH carries only the changed field. expect(calls[0]).toEqual({ method: "PATCH", path: "/groups/9", body: { campusId: 4 } }); - expect(state.resources.team!.fields).toEqual({ name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: 4 }); + expect(state.resources.team!.fields).toEqual({ + name: "Team", + groupTypeId: 2, + groupStatusId: 1, + campusId: 4, + }); }); it("does NOT revert a field that drifted in CT when a sibling field is updated (#27)", async () => { @@ -399,7 +416,13 @@ describe("executePlan", () => { }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed).toBeUndefined(); expect(result.created).toEqual(["parent", "child"]); // The edge PUT resolves the parent's freshly-assigned id (1) against the child's (2). @@ -443,9 +466,7 @@ describe("executePlan", () => { // A note-less no-op whose config now sets preventDestroy — the flag alone is never a diffed field, // so this is the only chance to persist it to state. const plan: Plan = { - items: [ - { type: "campus", key: "mainz", id: 0, action: "no-op", changes: [], preventDestroy: true }, - ], + items: [{ type: "campus", key: "mainz", id: 0, action: "no-op", changes: [], preventDestroy: true }], }; await executePlan(plan, { client, state, statePath: "s.json", save, now: fixedNow }); expect(state.resources.mainz!.preventDestroy).toBe(true); @@ -516,7 +537,9 @@ describe("executePlan", () => { const state = emptyState("h"); const client = { request: async (): Promise => { - throw new CtApiError("POST /group/grouptypes failed", 403, { message: "no permission to create group types" }); + throw new CtApiError("POST /group/grouptypes failed", 403, { + message: "no permission to create group types", + }); }, }; const plan: Plan = { @@ -561,7 +584,13 @@ describe("executePlan", () => { }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed).toBeUndefined(); expect(calls[0]).toEqual({ method: "POST", @@ -649,7 +678,13 @@ describe("executePlan", () => { }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed?.key).toBe("kids_2026_b"); // The shared formatter's output is preserved verbatim (HTTP status + body)... expect(result.failed?.message).toContain("HTTP 400"); @@ -671,10 +706,22 @@ describe("executePlan", () => { }; const plan: Plan = { items: [ - { type: "group", key: "kids_2026_b", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "K" }] }, + { + type: "group", + key: "kids_2026_b", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "K" }], + }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed?.message).toContain("allowDuplicateName: true"); }); @@ -700,7 +747,13 @@ describe("executePlan", () => { }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed?.message).not.toContain("Guidance:"); }); @@ -713,10 +766,22 @@ describe("executePlan", () => { }; const plan: Plan = { items: [ - { type: "group", key: "kids", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "" }] }, + { + type: "group", + key: "kids", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "" }], + }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed?.message).not.toContain("Guidance:"); }); }); diff --git a/tests/fixtures/dynamic/README.md b/tests/fixtures/dynamic/README.md index 70c1603..d4d689e 100644 --- a/tests/fixtures/dynamic/README.md +++ b/tests/fixtures/dynamic/README.md @@ -38,7 +38,7 @@ against this production login.** write-back re-sends exactly what CT returned, so it is drift-free by construction. 2. **`CT_LIVE=1 CT_LIVE_WRITE=1 CT_LIVE_WRITE_HOST= - CT_DYNAMIC_FIXTURE_GID=`** — the **#36 pin**: +CT_DYNAMIC_FIXTURE_GID=`** — the **#36 pin**: PUTs a ruleset this test authors itself (custom `description`, `shorty`, `importance`, …, never copied from a GET) to the designated group, GETs it back, and asserts normalized deep-equality field by field. It also builds diff --git a/tests/fixtures/dynamic/portablize-sintegrationmeeting.json b/tests/fixtures/dynamic/portablize-sintegrationmeeting.json index cc454b9..da36e36 100644 --- a/tests/fixtures/dynamic/portablize-sintegrationmeeting.json +++ b/tests/fixtures/dynamic/portablize-sintegrationmeeting.json @@ -6,9 +6,7 @@ "query": { "method": "ChurchQuery", "params": { - "groupBy": [ - "person.id" - ], + "groupBy": ["person.id"], "filter": { "and": [ { @@ -32,9 +30,7 @@ "dterm": [ { "title": "nur LEITER", - "stereotype": [ - "groupmembership" - ] + "stereotype": ["groupmembership"] }, { "and": [ @@ -45,11 +41,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1, - 2, - 4 - ] + [1, 2, 4] ] }, { @@ -57,15 +49,7 @@ { "var": "ctgroup.id" }, - [ - "148", - "1228", - "32", - "1237", - "1243", - "27", - "119" - ] + ["148", "1228", "32", "1237", "1243", "27", "119"] ] } ] @@ -77,14 +61,7 @@ { "var": "role.id" }, - [ - "16", - "84", - "85", - "17", - "90", - "91" - ] + ["16", "84", "85", "17", "90", "91"] ] } ] @@ -97,9 +74,7 @@ "dterm": [ { "title": "ALLE", - "stereotype": [ - "groupmembership" - ] + "stereotype": ["groupmembership"] }, { "and": [ @@ -110,11 +85,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1, - 2, - 4 - ] + [1, 2, 4] ] }, { @@ -122,9 +93,7 @@ { "var": "ctgroup.id" }, - [ - "1974" - ] + ["1974"] ] } ] @@ -136,11 +105,7 @@ { "var": "role.id" }, - [ - "15", - "16", - "17" - ] + ["15", "16", "17"] ] } ] @@ -154,11 +119,7 @@ ] }, "primaryEntityAlias": "person", - "responseFields": [ - "person.id", - "person.firstName", - "person.lastName" - ] + "responseFields": ["person.id", "person.firstName", "person.lastName"] } }, "process": { @@ -178,4 +139,4 @@ }, "groupAndQueryResult": {} } -} \ No newline at end of file +} diff --git a/tests/fixtures/dynamic/portablize-skidscheckinopsmz.json b/tests/fixtures/dynamic/portablize-skidscheckinopsmz.json index 2aed610..f5bb6d4 100644 --- a/tests/fixtures/dynamic/portablize-skidscheckinopsmz.json +++ b/tests/fixtures/dynamic/portablize-skidscheckinopsmz.json @@ -6,9 +6,7 @@ "query": { "method": "ChurchQuery", "params": { - "groupBy": [ - "person.id" - ], + "groupBy": ["person.id"], "filter": { "and": [ { @@ -28,9 +26,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -42,9 +38,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1 - ] + [1] ] }, { @@ -52,10 +46,7 @@ { "var": "ctgroup.id" }, - [ - "112", - "8" - ] + ["112", "8"] ] } ] @@ -67,12 +58,7 @@ { "var": "role.id" }, - [ - "84", - "85", - "17", - "16" - ] + ["84", "85", "17", "16"] ] } ] @@ -84,9 +70,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -98,9 +82,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1 - ] + [1] ] }, { @@ -108,9 +90,7 @@ { "var": "ctgroup.id" }, - [ - "1246" - ] + ["1246"] ] } ] @@ -122,10 +102,7 @@ { "var": "role.id" }, - [ - "16", - "17" - ] + ["16", "17"] ] } ] @@ -139,11 +116,7 @@ ] }, "primaryEntityAlias": "person", - "responseFields": [ - "person.id", - "person.firstName", - "person.lastName" - ] + "responseFields": ["person.id", "person.firstName", "person.lastName"] } }, "process": { @@ -164,4 +137,4 @@ }, "groupAndQueryResult": {} } -} \ No newline at end of file +} diff --git a/tests/fixtures/dynamic/ruleset-1092.get.json b/tests/fixtures/dynamic/ruleset-1092.get.json index 0b02638..82ab450 100644 --- a/tests/fixtures/dynamic/ruleset-1092.get.json +++ b/tests/fixtures/dynamic/ruleset-1092.get.json @@ -7,9 +7,7 @@ "query": { "method": "ChurchQuery", "params": { - "groupBy": [ - "person.id" - ], + "groupBy": ["person.id"], "filter": { "and": [ { @@ -29,9 +27,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -43,9 +39,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1 - ] + [1] ] }, { @@ -53,10 +47,7 @@ { "var": "ctgroup.id" }, - [ - "112", - "8" - ] + ["112", "8"] ] } ] @@ -68,12 +59,7 @@ { "var": "role.id" }, - [ - "84", - "85", - "17", - "16" - ] + ["84", "85", "17", "16"] ] } ] @@ -85,9 +71,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -99,9 +83,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1 - ] + [1] ] }, { @@ -109,9 +91,7 @@ { "var": "ctgroup.id" }, - [ - "1246" - ] + ["1246"] ] } ] @@ -123,10 +103,7 @@ { "var": "role.id" }, - [ - "16", - "17" - ] + ["16", "17"] ] } ] @@ -140,11 +117,7 @@ ] }, "primaryEntityAlias": "person", - "responseFields": [ - "person.id", - "person.firstName", - "person.lastName" - ] + "responseFields": ["person.id", "person.firstName", "person.lastName"] } }, "process": { diff --git a/tests/fixtures/dynamic/ruleset-2022.get.json b/tests/fixtures/dynamic/ruleset-2022.get.json index e79e157..e64fb14 100644 --- a/tests/fixtures/dynamic/ruleset-2022.get.json +++ b/tests/fixtures/dynamic/ruleset-2022.get.json @@ -7,9 +7,7 @@ "query": { "method": "ChurchQuery", "params": { - "groupBy": [ - "person.id" - ], + "groupBy": ["person.id"], "filter": { "and": [ { @@ -42,9 +40,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -56,9 +52,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1 - ] + [1] ] }, { @@ -87,9 +81,7 @@ { "var": "ctgroup.id" }, - [ - "1869" - ] + ["1869"] ] } ] @@ -115,12 +107,7 @@ { "var": "role.id" }, - [ - "8", - "9", - "10", - "92" - ] + ["8", "9", "10", "92"] ] }, { @@ -140,11 +127,7 @@ ] }, "primaryEntityAlias": "person", - "responseFields": [ - "person.id", - "person.firstName", - "person.lastName" - ] + "responseFields": ["person.id", "person.firstName", "person.lastName"] } }, "process": { diff --git a/tests/fixtures/dynamic/ruleset-683.get.json b/tests/fixtures/dynamic/ruleset-683.get.json index 1414982..62902a0 100644 --- a/tests/fixtures/dynamic/ruleset-683.get.json +++ b/tests/fixtures/dynamic/ruleset-683.get.json @@ -7,9 +7,7 @@ "query": { "method": "ChurchQuery", "params": { - "groupBy": [ - "person.id" - ], + "groupBy": ["person.id"], "filter": { "and": [ { @@ -20,9 +18,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -34,9 +30,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1 - ] + [1] ] }, { @@ -44,13 +38,7 @@ { "var": "ctgroup.groupTypeId" }, - [ - 2, - 12, - 16, - 19, - 1 - ] + [2, 12, 16, 19, 1] ] }, { @@ -58,17 +46,7 @@ { "var": "ctgroup.campusId" }, - [ - 0, - 9, - 17, - 18, - 23, - 26, - 29, - 32, - 33 - ] + [0, 9, 17, 18, 23, 26, 29, 32, 33] ] } ] @@ -115,9 +93,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -127,9 +103,7 @@ { "var": "ctgroup.id" }, - [ - "1993" - ] + ["1993"] ] }, { @@ -170,11 +144,7 @@ ] }, "primaryEntityAlias": "person", - "responseFields": [ - "person.id", - "person.firstName", - "person.lastName" - ] + "responseFields": ["person.id", "person.firstName", "person.lastName"] } }, "process": { diff --git a/tests/fixtures/dynamic/ruleset.get.json b/tests/fixtures/dynamic/ruleset.get.json index 1414982..62902a0 100644 --- a/tests/fixtures/dynamic/ruleset.get.json +++ b/tests/fixtures/dynamic/ruleset.get.json @@ -7,9 +7,7 @@ "query": { "method": "ChurchQuery", "params": { - "groupBy": [ - "person.id" - ], + "groupBy": ["person.id"], "filter": { "and": [ { @@ -20,9 +18,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -34,9 +30,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1 - ] + [1] ] }, { @@ -44,13 +38,7 @@ { "var": "ctgroup.groupTypeId" }, - [ - 2, - 12, - 16, - 19, - 1 - ] + [2, 12, 16, 19, 1] ] }, { @@ -58,17 +46,7 @@ { "var": "ctgroup.campusId" }, - [ - 0, - 9, - 17, - 18, - 23, - 26, - 29, - 32, - 33 - ] + [0, 9, 17, 18, 23, 26, 29, 32, 33] ] } ] @@ -115,9 +93,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -127,9 +103,7 @@ { "var": "ctgroup.id" }, - [ - "1993" - ] + ["1993"] ] }, { @@ -170,11 +144,7 @@ ] }, "primaryEntityAlias": "person", - "responseFields": [ - "person.id", - "person.firstName", - "person.lastName" - ] + "responseFields": ["person.id", "person.firstName", "person.lastName"] } }, "process": { diff --git a/tests/get-command.test.ts b/tests/get-command.test.ts index d2f517b..ca0148f 100644 --- a/tests/get-command.test.ts +++ b/tests/get-command.test.ts @@ -2,9 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const getAllMock = vi.fn(); const getMock = vi.fn(); +const getRawMock = vi.fn(); vi.mock("../src/api/session.js", () => ({ - authedSession: vi.fn(async () => ({ client: { get: getMock, getAll: getAllMock }, me: { id: 1 } })), + authedSession: vi.fn(async () => ({ + client: { get: getMock, getAll: getAllMock, getRaw: getRawMock }, + me: { id: 1 }, + })), })); const { getCommand } = await import("../src/commands/get.js"); @@ -18,6 +22,7 @@ describe("ct get (#50)", () => { beforeEach(() => { getAllMock.mockReset(); getMock.mockReset(); + getRawMock.mockReset(); }); it("auto-paginates a list resource and prints every item, not just the first page", async () => { @@ -82,7 +87,12 @@ describe("ct get (#50)", () => { getAllMock.mockResolvedValue({ data: [ { id: 5, name: "first_contact", securityLevel: 1, fieldCategory: { internCode: "f_person" } }, - { id: 9, name: "bezeichnung", securityLevel: 2, fieldCategory: { internCode: "f_group", table: "cdb_gruppe" } }, + { + id: 9, + name: "bezeichnung", + securityLevel: 2, + fieldCategory: { internCode: "f_group", table: "cdb_gruppe" }, + }, ], meta: { pagination: { total: 2, current: 1, lastPage: 1, limit: 100 } }, }); @@ -97,7 +107,7 @@ describe("ct get (#50)", () => { }); it("propagates a raw call's CtApiError (status + body) instead of swallowing it", async () => { - getMock.mockRejectedValue( + getRawMock.mockRejectedValue( new CtApiError("GET /groups?limit=500 failed", 400, { errors: ["limit exceeds max of 100"] }), ); @@ -107,3 +117,121 @@ describe("ct get (#50)", () => { }); }); }); + +/** + * `ct get raw` used to issue exactly one request and print whatever came back — which for any CT list + * endpoint is its default first page. `ct get raw "/groups"` therefore reported 10 rows on an instance + * with 645, in a valid-looking JSON array, while `ct get groups` on the same path reported all 645. + */ +describe("ct get raw pagination (#100)", () => { + beforeEach(() => { + getAllMock.mockReset(); + getMock.mockReset(); + getRawMock.mockReset(); + }); + + it("follows pagination instead of returning CT's default first page", async () => { + getRawMock.mockResolvedValue({ + data: Array.from({ length: 10 }, (_, i) => ({ id: i })), + meta: { pagination: { total: 645, current: 1, lastPage: 65, limit: 10 } }, + }); + getAllMock.mockResolvedValue({ + data: Array.from({ length: 645 }, (_, i) => ({ id: i })), + meta: { pagination: { total: 645, current: 65, lastPage: 65, limit: 10 } }, + }); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await runGet(["raw", "/groups"]); + + expect(getAllMock).toHaveBeenCalledWith("/groups"); + const printed = JSON.parse(writeSpy.mock.calls[0]?.[0] as string) as unknown[]; + expect(printed).toHaveLength(645); + writeSpy.mockRestore(); + }); + + it("prints a single-object response as-is and never appends paging params to it", async () => { + getRawMock.mockResolvedValue({ data: { id: 42, name: "Kids" } }); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await runGet(["raw", "/groups/42"]); + + expect(getRawMock).toHaveBeenCalledWith("/groups/42"); + expect(getAllMock).not.toHaveBeenCalled(); + expect(JSON.parse(writeSpy.mock.calls[0]?.[0] as string)).toEqual({ id: 42, name: "Kids" }); + writeSpy.mockRestore(); + }); + + it("makes exactly one request for an unpaginated list", async () => { + getRawMock.mockResolvedValue({ + data: [{ id: 1 }, { id: 2 }], + meta: { pagination: { total: 2, current: 1, lastPage: 1 } }, + }); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await runGet(["raw", "/departments"]); + + expect(getAllMock).not.toHaveBeenCalled(); + expect(JSON.parse(writeSpy.mock.calls[0]?.[0] as string)).toHaveLength(2); + writeSpy.mockRestore(); + }); + + it("--no-paginate keeps the single-request probe but WARNS that rows were dropped", async () => { + getRawMock.mockResolvedValue({ + data: Array.from({ length: 10 }, (_, i) => ({ id: i })), + meta: { pagination: { total: 645, current: 1, lastPage: 65 } }, + }); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + await runGet(["raw", "/groups", "--no-paginate"]); + + expect(getAllMock).not.toHaveBeenCalled(); + expect(JSON.parse(writeSpy.mock.calls[0]?.[0] as string)).toHaveLength(10); + expect(errSpy.mock.calls.map(String).join("")).toContain("INCOMPLETE: returned 10 of 645 row(s)"); + writeSpy.mockRestore(); + errSpy.mockRestore(); + }); + + it("--page probes exactly that page", async () => { + getRawMock.mockResolvedValue({ + data: [{ id: 300 }], + meta: { pagination: { total: 645, current: 3, lastPage: 65 } }, + }); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + await runGet(["raw", "/groups", "--page", "3"]); + + expect(getRawMock).toHaveBeenCalledWith("/groups?page=3&limit=100"); + expect(getAllMock).not.toHaveBeenCalled(); + writeSpy.mockRestore(); + errSpy.mockRestore(); + }); + + it("honours a caller's own page=/limit= verbatim rather than appending a conflicting pair", async () => { + getRawMock.mockResolvedValue({ + data: [{ id: 1 }], + meta: { pagination: { total: 1, current: 1, lastPage: 1 } }, + }); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await runGet(["raw", "/groups?limit=100&page=2"]); + + expect(getRawMock).toHaveBeenCalledWith("/groups?limit=100&page=2"); + expect(getAllMock).not.toHaveBeenCalled(); + writeSpy.mockRestore(); + }); + + it("rejects a nonsense --page instead of silently probing page 1", async () => { + await expect(runGet(["raw", "/groups", "--page", "0"])).rejects.toThrow(/Invalid --page/); + }); + + // Both spellings are explicit, so neither may be silently dropped. Appending anyway produced + // `?limit=50&page=2&limit=100` — a duplicated param whose winner is the server's parsing rule. + it("rejects --page combined with page/limit already in the path", async () => { + await expect(runGet(["raw", "/groups?limit=50", "--page", "2"])).rejects.toThrow( + /--page 2 conflicts with the page\/limit already in the path/, + ); + expect(getRawMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/integration.test.ts b/tests/integration.test.ts index d316ee7..4c16bda 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -7,9 +7,7 @@ import type { DesiredResource } from "../src/engine/types.js"; /** A tiny in-memory ChurchTools: a campuses store supporting GET/POST/PUT; hierarchy is empty. */ function fakeCt() { - const campuses = new Map>([ - [0, { id: 0, name: "Mainz", shorty: "MZ" }], - ]); + const campuses = new Map>([[0, { id: 0, name: "Mainz", shorty: "MZ" }]]); let nextId = 1; return { get: async (path: string): Promise => { diff --git a/tests/permission-adopt.test.ts b/tests/permission-adopt.test.ts index 507fec6..d404458 100644 --- a/tests/permission-adopt.test.ts +++ b/tests/permission-adopt.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; import { emitAdoptedGrants } from "../src/permissions/adopt.js"; -import { diffGrants, normalizeActual, type DomainType, type RawPermission } from "../src/permissions/grants.js"; +import { + diffGrants, + normalizeActual, + type DomainType, + type RawPermission, +} from "../src/permissions/grants.js"; import { desiredTuples } from "../src/permissions/plan.js"; import { resolveScopeRefs } from "../src/permissions/scope.js"; import { Resolver } from "../src/resolve/resolver.js"; @@ -62,7 +67,12 @@ describe("emitAdoptedGrants", () => { 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() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 42, + rows, + state: emptyState(), + }); expect(block).toContain("ct.groupTypeRole({"); expect(block).toContain("id: 42,"); @@ -72,7 +82,9 @@ describe("emitAdoptedGrants", () => { }); it("group_role emits ct.groupRole", () => { - const rows: RawPermission[] = [{ authId: 1, dataId: null, type: "grant", domainId: 7, meta: { modifiedPid: 5 } }]; + 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({"); }); @@ -81,7 +93,12 @@ describe("emitAdoptedGrants", () => { 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() }); + 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"); @@ -89,7 +106,14 @@ describe("emitAdoptedGrants", () => { 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" }; + 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 } }, @@ -102,12 +126,17 @@ describe("emitAdoptedGrants", () => { 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() }); + 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:"); + expect(block).toContain('// { right: "churchgroup:view group", scope:'); }); it("excludes baseline + inherited rows and notes preserved revoke/deny rows", () => { @@ -117,7 +146,12 @@ describe("emitAdoptedGrants", () => { { 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() }); + 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 @@ -129,7 +163,12 @@ describe("emitAdoptedGrants", () => { 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() }); + 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 @@ -149,7 +188,12 @@ describe("emitAdoptedGrants", () => { 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() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 42, + rows, + state: emptyState(), + }); expect(block).not.toContain("NOTE:"); expect(parseEmittedGrants(block)).toEqual(["churchdb:+edit group infos"]); @@ -168,7 +212,12 @@ describe("emitAdoptedGrants", () => { 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() }); + 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([]); @@ -178,7 +227,12 @@ describe("emitAdoptedGrants", () => { 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() }); + 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([]); @@ -214,7 +268,12 @@ describe("emitAdoptedGrants", () => { { authId: 113, dataId: 1, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, { authId: 113, dataId: 2, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, ]; - const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 9, rows, state: emptyState() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 9, + rows, + state: emptyState(), + }); expect(block).toContain('{ right: "churchdb:view comments", scope: [1, 2] }'); expect(block).not.toContain("ct adopt group"); @@ -233,7 +292,12 @@ describe("emitAdoptedGrants", () => { { authId: 131, dataId: 5, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, { authId: 132, dataId: 1, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, ]; - const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 9, rows, state: emptyState() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 9, + rows, + state: emptyState(), + }); const grants = parseEmittedGrants(block); expect(block).not.toContain("WARNING"); @@ -246,7 +310,12 @@ describe("emitAdoptedGrants", () => { // Full round-trip: pasting this block into config and diffing against the SAME live rows must // be a no-op — no toPut, and critically no toDelete (a partial block must never revoke a live // grant it could not express). - const desired = grants.flatMap((g) => desiredTuples({ key: "adopted", domainType: "group_type_role", domainId: 9, grants: [g] }, emptyState())); + const desired = grants.flatMap((g) => + desiredTuples( + { key: "adopted", domainType: "group_type_role", domainId: 9, grants: [g] }, + emptyState(), + ), + ); const actual = normalizeActual(rows); const diff = diffGrants(desired, actual); expect(diff.toPut).toEqual([]); @@ -265,14 +334,14 @@ describe("emitAdoptedGrants", () => { const rows: RawPermission[] = [ // writable, user-authored grants (unscoped + group-scoped) — emitted active { authId: 1113, dataId: null, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchgroup:administer groups - { authId: 1104, dataId: 99, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchgroup:view group scoped to managed "kids" + { authId: 1104, dataId: 99, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchgroup:view group scoped to managed "kids" // admin-authored MEMBER rights (authId >= 10000) — NOW managed: unscoped + security-level scopes { authId: 10107, dataId: null, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+add person (unscoped) - { authId: 10101, dataId: 2, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+see persons scope [2] - { authId: 10133, dataId: 1, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+edit group member fields scope [1] + { authId: 10101, dataId: 2, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+see persons scope [2] + { authId: 10133, dataId: 1, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+edit group member fields scope [1] // EXCLUDED (authId >= 10000 too): system baseline + a truly-inherited row → never revoked { authId: 10122, dataId: null, type: "grant", domainId: 9, meta: { modifiedPid: -1 } }, // system baseline - { authId: 10111, dataId: null, type: "grant", domainId: 9, isInherited: true }, // inherited + { authId: 10111, dataId: null, type: "grant", domainId: 9, isInherited: true }, // inherited ]; const state = stateWithKids(); const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 9, rows, state }); @@ -283,7 +352,9 @@ describe("emitAdoptedGrants", () => { // Paste-and-plan: diff the emitted declaration against the FULL live row set. normalizeActual // drops the system-baseline + inherited rows, so they never appear as revokes. - const desired = grants.flatMap((g) => desiredTuples({ key: "adopted", domainType: "group_type_role", domainId: 9, grants: [g] }, state)); + const desired = grants.flatMap((g) => + desiredTuples({ key: "adopted", domainType: "group_type_role", domainId: 9, grants: [g] }, state), + ); const actual = normalizeActual(rows); const diff = diffGrants(desired, actual); expect(diff.toPut).toEqual([]); @@ -296,7 +367,12 @@ describe("emitAdoptedGrants", () => { 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() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 42, + rows, + state: emptyState(), + }); expect(block).toContain("ct adopt group 777"); }); @@ -304,7 +380,14 @@ describe("emitAdoptedGrants", () => { // churchdb:view station (authId 124) scopes by cdb_station. Campus ids are host-specific, so an // adopted numeric literal is a misgrant when the block is replayed on the other instance. const state = stateWithKids(); - state.resources.koblenz = { type: "campus", id: 23, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }; + state.resources.koblenz = { + type: "campus", + id: 23, + key: "koblenz", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; const rows: RawPermission[] = [ { authId: 124, dataId: 23, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, ]; @@ -331,7 +414,14 @@ describe("emitAdoptedGrants", () => { it("mixes managed (logical) and unmanaged (numeric) campus scopes in one grant", () => { const state = emptyState(); - state.resources.koblenz = { type: "campus", id: 23, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }; + state.resources.koblenz = { + type: "campus", + id: 23, + key: "koblenz", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; const rows: RawPermission[] = [ { authId: 124, dataId: 23, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, { authId: 124, dataId: 99, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, @@ -379,7 +469,14 @@ describe("emitAdoptedGrants", () => { // A managed campus, so the fixture also covers the #98 typed-ref emission form (`{ campus: … }`) // — without it the round-trip property was only ever exercised on group/numeric scopes, and the // one shape that needs a pre-resolved ScopeRefMap went unchecked. - state.resources.koblenz = { type: "campus", id: 23, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }; + state.resources.koblenz = { + type: "campus", + id: 23, + key: "koblenz", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; const rows: RawPermission[] = [ { authId: 124, dataId: 23, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // cdb_station, managed → { campus: … } { authId: 124, dataId: 777, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // cdb_station, unmanaged → numeric diff --git a/tests/permission-apply.test.ts b/tests/permission-apply.test.ts index c6b6b5c..063dac1 100644 --- a/tests/permission-apply.test.ts +++ b/tests/permission-apply.test.ts @@ -5,18 +5,39 @@ import { CtApiError } from "../src/api/ctClient.js"; describe("applyPermissionPlan", () => { it("PUTs each grant and DELETEs each removed tuple with the array dataId body", async () => { const request = vi.fn(async () => ({})); - const res = await applyPermissionPlan([{ - key: "t", domainType: "group_type_role", domainId: 8, - diff: { - toPut: [{ authId: 1104, dataId: [42], type: "grant" }, { authId: 1101, dataId: [], type: "grant" }], - toDelete: [{ authId: 2000, dataId: [], type: "grant" }], - preserved: [], - }, - }], { request } as never); + const res = await applyPermissionPlan( + [ + { + key: "t", + domainType: "group_type_role", + domainId: 8, + diff: { + toPut: [ + { authId: 1104, dataId: [42], type: "grant" }, + { authId: 1101, dataId: [], type: "grant" }, + ], + toDelete: [{ authId: 2000, dataId: [], type: "grant" }], + preserved: [], + preservedUnknown: [], + }, + }, + ], + { request } as never, + ); expect(res).toEqual({ granted: 2, deleted: 1, failed: [] }); - expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { authId: 1104, dataId: [42], type: "grant" }); - expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { authId: 1101, type: "grant" }); // no dataId when unscoped - expect(request).toHaveBeenCalledWith("DELETE", "/permissions/group_type_role/8", { authId: 2000, type: "grant" }); + expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { + authId: 1104, + dataId: [42], + type: "grant", + }); + expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { + authId: 1101, + type: "grant", + }); // no dataId when unscoped + expect(request).toHaveBeenCalledWith("DELETE", "/permissions/group_type_role/8", { + authId: 2000, + type: "grant", + }); }); it("collects a failed write instead of aborting the batch, and keeps writing the rest (#35 items 3+14)", async () => { @@ -26,17 +47,25 @@ describe("applyPermissionPlan", () => { if (b.authId === 1104) throw new Error("boom"); return {}; }); - const res = await applyPermissionPlan([{ - key: "t", domainType: "group_type_role", domainId: 8, - diff: { - toPut: [ - { authId: 1104, dataId: [42], type: "grant" }, - { authId: 1101, dataId: [], type: "grant" }, - ], - toDelete: [{ authId: 2000, dataId: [], type: "grant" }], - preserved: [], - }, - }], { request } as never); + const res = await applyPermissionPlan( + [ + { + key: "t", + domainType: "group_type_role", + domainId: 8, + diff: { + toPut: [ + { authId: 1104, dataId: [42], type: "grant" }, + { authId: 1101, dataId: [], type: "grant" }, + ], + toDelete: [{ authId: 2000, dataId: [], type: "grant" }], + preserved: [], + preservedUnknown: [], + }, + }, + ], + { request } as never, + ); expect(res.granted).toBe(1); // only the 1101 PUT succeeded expect(res.deleted).toBe(1); expect(res.failed).toEqual([ @@ -50,14 +79,22 @@ describe("applyPermissionPlan", () => { const request = vi.fn(async () => { throw new CtApiError("PUT /permissions/group_type_role/8 failed", 403, { message: "no permission" }); }); - const res = await applyPermissionPlan([{ - key: "t", domainType: "group_type_role", domainId: 8, - diff: { - toPut: [{ authId: 1104, dataId: [42], type: "grant" }], - toDelete: [], - preserved: [], - }, - }], { request } as never); + const res = await applyPermissionPlan( + [ + { + key: "t", + domainType: "group_type_role", + domainId: 8, + diff: { + toPut: [{ authId: 1104, dataId: [42], type: "grant" }], + toDelete: [], + preserved: [], + preservedUnknown: [], + }, + }, + ], + { request } as never, + ); expect(res.failed).toHaveLength(1); expect(res.failed[0]?.message).toContain("HTTP 403"); expect(res.failed[0]?.message).toContain("no permission"); diff --git a/tests/permission-bootstrap.test.ts b/tests/permission-bootstrap.test.ts index 480c71a..3dd2c1a 100644 --- a/tests/permission-bootstrap.test.ts +++ b/tests/permission-bootstrap.test.ts @@ -41,7 +41,15 @@ function mockClient(newId: number) { } const createKidsPlan: Plan = { - items: [{ type: "group", key: "kids", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "Kids" }] }], + items: [ + { + type: "group", + key: "kids", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "Kids" }], + }, + ], }; describe("scope bootstrap: declare group + grant scoped to it in one config (#29)", () => { @@ -51,7 +59,9 @@ describe("scope bootstrap: declare group + grant scoped to it in one config (#29 const { items, fetchErrors } = await buildPermissionPlan(client, state, [scopedPerm], desiredKidsGroup); expect(fetchErrors).toEqual([]); // The scoped grant survives planning and lands in toPut as a pending tuple. - expect(items[0]?.diff.toPut).toEqual([{ authId: 1104, dataId: [], type: "grant", scopeKey: "kids", scopeType: "group", pending: true }]); + expect(items[0]?.diff.toPut).toEqual([ + { authId: 1104, dataId: [], type: "grant", scopeKey: "kids", scopeType: "group", pending: true }, + ]); // Read-only `ct plan` renders instead of aborting, and labels the pending scope. expect(renderPermissionPlan(items)).toContain("kids (created this apply)"); }); @@ -75,12 +85,25 @@ describe("stale id after recreate (#33 item 3)", () => { it("re-resolves the scope dataId against post-execute state — grant PUT carries the NEW id", async () => { const { client, calls } = mockClient(777); // Pre-apply state has kids under a stale id 100 (it vanished from CT and will be recreated). - const state: State = { version: 1, host: HOST, resources: { - kids: { type: "group", id: 100, key: "kids", fields: { name: "Kids" }, adoptedAt: "t", updatedAt: "t" }, - }}; + const state: State = { + version: 1, + host: HOST, + resources: { + kids: { + type: "group", + id: 100, + key: "kids", + fields: { name: "Kids" }, + adoptedAt: "t", + updatedAt: "t", + }, + }, + }; const { items } = await buildPermissionPlan(client, state, [scopedPerm], desiredKidsGroup); // At plan time the tuple resolves to the OLD id 100… - expect(items[0]?.diff.toPut).toEqual([{ authId: 1104, dataId: [100], type: "grant", scopeKey: "kids", scopeType: "group" }]); + expect(items[0]?.diff.toPut).toEqual([ + { authId: 1104, dataId: [100], type: "grant", scopeKey: "kids", scopeType: "group" }, + ]); // …then a recreate mints id 777 in state. await executePlan(createKidsPlan, { client, state, statePath: "unused", save: async () => {} }); @@ -94,6 +117,8 @@ describe("stale id after recreate (#33 item 3)", () => { describe("truly-unknown scope key still hard-fails (#29 acceptance)", () => { it("throws the clear error when a key is neither in state nor declared", () => { - expect(() => desiredTuples(scopedPerm, emptyState(HOST), new Set())).toThrow(/scope key "kids" does not resolve/i); + expect(() => desiredTuples(scopedPerm, emptyState(HOST), new Set())).toThrow( + /scope key "kids" does not resolve/i, + ); }); }); diff --git a/tests/permission-catalog-store.test.ts b/tests/permission-catalog-store.test.ts new file mode 100644 index 0000000..37fec30 --- /dev/null +++ b/tests/permission-catalog-store.test.ts @@ -0,0 +1,173 @@ +/** + * Per-instance permission catalog (#105). + * + * The bundled catalog is a snapshot of ONE ChurchTools version, and the staleness warning it produced + * told consumer repos to run a script that only exists in the ct-cli repo — unactionable where it was + * printed, and printed on every single plan, which is how a warning stops being read. + */ +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + capturePermissionCatalog, + hostCatalogPath, + loadHostCatalog, + writeHostCatalog, +} from "../src/permissions/catalog-store.js"; +import { + CATALOG, + CATALOG_IS_PER_INSTANCE, + CATALOG_META, + KNOWN_AUTH_IDS, + KNOWN_SCOPE_FIELDS, + SCOPE_FIELD_BY_AUTH_ID, + resolveAuthId, + useBundledCatalog, +} from "../src/permissions/catalog.js"; + +const HOST = "https://mychurch.church.tools"; +let workDir: string | undefined; + +afterEach(() => { + useBundledCatalog(); // the catalog is process-global — never leak a capture into another test + if (workDir) rmSync(workDir, { recursive: true, force: true }); + workDir = undefined; +}); + +function tempCatalogDir(contents?: unknown): string { + workDir = mkdtempSync(join(tmpdir(), "ct-catalog-")); + if (contents !== undefined) { + writeFileSync(hostCatalogPath(HOST, workDir), JSON.stringify(contents), "utf8"); + } + return workDir; +} + +describe("hostCatalogPath", () => { + it("keeps one file per instance so dev and prod captures coexist", () => { + expect(hostCatalogPath("https://dev.church.tools", ".ct")).toBe( + ".ct/permission-catalog.dev.church.tools.json", + ); + expect(hostCatalogPath("https://prod.church.tools/", ".ct")).toBe( + ".ct/permission-catalog.prod.church.tools.json", + ); + }); +}); + +describe("loadHostCatalog", () => { + it("returns null and leaves the bundled catalog active when no capture exists", async () => { + const dir = tempCatalogDir(); + expect(await loadHostCatalog(HOST, dir)).toBeNull(); + expect(CATALOG_IS_PER_INSTANCE).toBe(false); + expect(CATALOG["churchcore:administer settings"]).toBeDefined(); + }); + + it("replaces every derived index, not just the name map", async () => { + const dir = tempCatalogDir({ + $meta: { + capturedFrom: "mychurch.church.tools", + ctVersion: "9.9.9", + capturedAt: "2026-08-13", + rightCount: 1, + }, + "newmodule:new right": { authId: 4242, scopeField: "cdb_zone", revocable: false, desc: "New" }, + }); + + const path = await loadHostCatalog(HOST, dir); + + expect(path).toBe(hostCatalogPath(HOST, dir)); + expect(resolveAuthId("newmodule:new right").authId).toBe(4242); + // A stale index here would be worse than no swap at all: `KNOWN_AUTH_IDS` decides which live + // grants are safe to reconcile, and `SCOPE_FIELD_BY_AUTH_ID` decides what `preserveUnknown` keeps. + expect(KNOWN_AUTH_IDS.has(4242)).toBe(true); + expect(KNOWN_AUTH_IDS.has(1)).toBe(false); // the bundled rights are gone, not merged + expect(SCOPE_FIELD_BY_AUTH_ID.get(4242)).toBe("cdb_zone"); + expect(KNOWN_SCOPE_FIELDS.has("cdb_zone")).toBe(true); + expect(CATALOG_META?.ctVersion).toBe("9.9.9"); + expect(CATALOG_IS_PER_INSTANCE).toBe(true); + }); + + it("throws on a malformed capture instead of silently planning against a different catalog", async () => { + const dir = mkdtempSync(join(tmpdir(), "ct-catalog-")); + workDir = dir; + writeFileSync(hostCatalogPath(HOST, dir), "{ not json", "utf8"); + await expect(loadHostCatalog(HOST, dir)).rejects.toThrow(/not valid JSON/); + expect(CATALOG_IS_PER_INSTANCE).toBe(false); + }); + + it("rejects a top-level array", async () => { + const dir = tempCatalogDir([]); + await expect(loadHostCatalog(HOST, dir)).rejects.toThrow(/expected a JSON object/); + }); + + // "It parsed as an object" is not enough for a file that decides what a permission NAME means: an + // entry with no authId resolves truthily, matches no actual, and reaches `ct apply` as a PUT + // carrying `authId: undefined`. It has to fail here, not three layers downstream on a write. + it("rejects an entry with no numeric authId rather than PUTting `authId: undefined` later", async () => { + const dir = tempCatalogDir({ "churchdb:view": { scopeField: null, revocable: false, desc: "" } }); + await expect(loadHostCatalog(HOST, dir)).rejects.toThrow(/right "churchdb:view" has no numeric authId/); + expect(CATALOG_IS_PER_INSTANCE).toBe(false); + }); + + it("rejects an entry whose scopeField is neither a string nor null", async () => { + const dir = tempCatalogDir({ "churchdb:view": { authId: 1, scopeField: 7, revocable: false, desc: "" } }); + await expect(loadHostCatalog(HOST, dir)).rejects.toThrow(/scopeField that is neither a string nor null/); + }); +}); + +describe("capturePermissionCatalog", () => { + // Typed through the capture function's own parameter so the fake stays honest about its shape. + const client: Parameters[0] = { + host: HOST, + version: "3.135.2", + legacyPostForm: (async () => ({ + data: { + auth_table: { + churchdb: { + "view station": { + id: 124, + datenfeld: "cdb_station", + bezeichnung: "Standort sehen", + isRevocable: 1, + }, + "view alldata": { id: 102, datenfeld: "", bezeichnung: "Alle sehen", isRevocable: false }, + }, + }, + }, + })) as (typeof client)["legacyPostForm"], + }; + + it("flattens auth_table into the catalog schema and stamps its provenance", async () => { + const catalog = await capturePermissionCatalog(client); + expect(catalog["churchdb:view station"]).toEqual({ + authId: 124, + scopeField: "cdb_station", + revocable: true, + desc: "Standort sehen", + }); + // An empty `datenfeld` means UNSCOPED — not a dimension named "". + expect(catalog["churchdb:view alldata"]).toMatchObject({ scopeField: null, revocable: false }); + expect(catalog.$meta).toMatchObject({ + capturedFrom: "mychurch.church.tools", + ctVersion: "3.135.2", + rightCount: 2, + }); + }); + + it("fails loudly if the legacy endpoint's shape changed, rather than writing an empty catalog", async () => { + await expect( + capturePermissionCatalog({ + ...client, + legacyPostForm: (async () => ({ data: {} })) as (typeof client)["legacyPostForm"], + }), + ).rejects.toThrow(/no data\.auth_table/); + }); + + it("round-trips through writeHostCatalog into a loadable per-instance catalog", async () => { + const dir = tempCatalogDir(); + const path = await writeHostCatalog(HOST, await capturePermissionCatalog(client), dir); + expect(JSON.parse(readFileSync(path, "utf8"))["churchdb:view station"].authId).toBe(124); + await loadHostCatalog(HOST, dir); + expect(resolveAuthId("churchdb:view station").authId).toBe(124); + }); +}); diff --git a/tests/permission-catalog.test.ts b/tests/permission-catalog.test.ts index 15a54a4..b79e669 100644 --- a/tests/permission-catalog.test.ts +++ b/tests/permission-catalog.test.ts @@ -13,7 +13,9 @@ describe("permission catalog", () => { expect(e.scopeField).toBe("cdb_gruppe"); }); it("throws a helpful error for an unknown right", () => { - expect(() => resolveAuthId("churchgroup:no such right")).toThrow(/unknown permission "churchgroup:no such right"/i); + expect(() => resolveAuthId("churchgroup:no such right")).toThrow( + /unknown permission "churchgroup:no such right"/i, + ); }); it("exposes the whole catalog (187 rights)", () => { expect(Object.keys(CATALOG).length).toBeGreaterThanOrEqual(180); diff --git a/tests/permission-fetch.test.ts b/tests/permission-fetch.test.ts new file mode 100644 index 0000000..6ae99ee --- /dev/null +++ b/tests/permission-fetch.test.ts @@ -0,0 +1,64 @@ +/** + * The guard on `GET /permissions/` (review of #100/#103/#104/#105). + * + * Every permission read was a plain `client.get`, on the unchecked belief that a permission domain + * returns one instance-wide blob rather than a paged list. If that is ever wrong, the failure is + * completely silent: `request()` drops `meta`, so a first page of 10 rows is indistinguishable from + * a complete answer — `ct plan` sees a truncated actual set, `ct coverage` under-reports, and + * `ct adopt grants --all-declarable` files most role instances under "no authored grants". + */ +import { describe, it, expect, vi } from "vitest"; +import { fetchPermissionRows } from "../src/permissions/fetch.js"; + +const row = (id: number) => ({ id, domainType: "group_role", domainId: id, authId: 1, isInherited: false }); + +describe("fetchPermissionRows", () => { + it("costs exactly one request while the endpoint returns no pagination block", async () => { + const getRaw = vi.fn(async () => ({ data: [row(1), row(2)] })); + const getAll = vi.fn(); + const rows = await fetchPermissionRows( + { get: vi.fn(), getRaw, getAll } as never, + "/permissions/group_role", + ); + expect(rows).toHaveLength(2); + expect(getRaw).toHaveBeenCalledWith("/permissions/group_role"); + expect(getAll).not.toHaveBeenCalled(); + }); + + it("pages the endpoint properly when meta says more rows exist than arrived", async () => { + const getRaw = vi.fn(async () => ({ + data: [row(1)], + meta: { pagination: { total: 300, current: 1, lastPage: 3, limit: 100 } }, + })); + const getAll = vi.fn(async () => ({ data: [row(1), row(2), row(3)] })); + const rows = await fetchPermissionRows( + { get: vi.fn(), getRaw, getAll } as never, + "/permissions/group_role", + ); + expect(rows).toHaveLength(3); + expect(getAll).toHaveBeenCalledWith("/permissions/group_role"); + }); + + it("also catches a `total` reported without page numbers", async () => { + const getRaw = vi.fn(async () => ({ data: [row(1)], meta: { pagination: { total: 42 } } })); + const getAll = vi.fn(async () => ({ data: [row(1), row(2)] })); + const rows = await fetchPermissionRows({ get: vi.fn(), getRaw, getAll } as never, "/permissions/status"); + expect(rows).toHaveLength(2); + }); + + it("falls back to the plain read for a narrow client that has no envelope access", async () => { + const get = vi.fn(async () => [row(1)]); + const rows = await fetchPermissionRows({ get } as never, "/permissions/group_role"); + expect(rows).toEqual([row(1)]); + expect(get).toHaveBeenCalledWith("/permissions/group_role"); + }); + + it("returns [] for a non-array body rather than letting it reach the diff", async () => { + const getRaw = vi.fn(async () => ({ data: { message: "nope" } })); + const rows = await fetchPermissionRows( + { get: vi.fn(), getRaw, getAll: vi.fn() } as never, + "/permissions/group_role", + ); + expect(rows).toEqual([]); + }); +}); diff --git a/tests/permission-grants.test.ts b/tests/permission-grants.test.ts index b21774d..a6281f5 100644 --- a/tests/permission-grants.test.ts +++ b/tests/permission-grants.test.ts @@ -7,7 +7,7 @@ describe("normalizeActual", () => { { authId: 1104, dataId: 3, type: "grant" as const, domainId: 42, meta: { modifiedPid: 1 } }, { authId: 1101, dataId: null, type: "grant" as const, domainId: 42, meta: { modifiedPid: 1 } }, { authId: 9999, dataId: 1, type: "grant" as const, domainId: 42, meta: { modifiedPid: -1 } }, // system baseline → excluded - { authId: 8888, dataId: 1, type: "grant" as const, domainId: 42, isInherited: true }, // inherited → excluded + { authId: 8888, dataId: 1, type: "grant" as const, domainId: 42, isInherited: true }, // inherited → excluded ]; expect(normalizeActual(rows)).toEqual([ { authId: 1104, dataId: [3], type: "grant" }, @@ -20,11 +20,11 @@ describe("diffGrants", () => { it("adds missing, deletes extra, no-ops identical (order-independent dataId)", () => { const desired = [ { authId: 1104, dataId: [7, 3], type: "grant" as const }, // present but reordered - { authId: 1101, dataId: [], type: "grant" as const }, // new + { authId: 1101, dataId: [], type: "grant" as const }, // new ]; const actual = [ { authId: 1104, dataId: [3, 7], type: "grant" as const }, // same tuple, different order - { authId: 2000, dataId: [], type: "grant" as const }, // extra → delete + { authId: 2000, dataId: [], type: "grant" as const }, // extra → delete ]; const d = diffGrants(desired, actual); expect(d.toPut.map(tupleKey)).toEqual([tupleKey({ authId: 1101, dataId: [], type: "grant" })]); @@ -37,8 +37,8 @@ describe("diffGrants", () => { // It must NOT land in toDelete (that would silently remove an admin's explicit deny). const desired = [{ authId: 1104, dataId: [3], type: "grant" as const }]; const actual = [ - { authId: 1104, dataId: [3], type: "grant" as const }, // matched → no-op - { authId: 1105, dataId: [7], type: "revoke" as const }, // pre-existing deny → preserved, never deleted + { authId: 1104, dataId: [3], type: "grant" as const }, // matched → no-op + { authId: 1105, dataId: [7], type: "revoke" as const }, // pre-existing deny → preserved, never deleted ]; const d = diffGrants(desired, actual); expect(d.toPut).toEqual([]); diff --git a/tests/permission-pending-domain.test.ts b/tests/permission-pending-domain.test.ts index 7210498..238fc83 100644 --- a/tests/permission-pending-domain.test.ts +++ b/tests/permission-pending-domain.test.ts @@ -34,7 +34,15 @@ const strukturPerm: DesiredPermission = { grants: ["churchgroup:administer groups"], }; const createStrukturPlan: Plan = { - items: [{ type: "group-type", key: "struktur", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "Struktur" }] }], + items: [ + { + type: "group-type", + key: "struktur", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "Struktur" }], + }, + ], }; /** A mock client: POST /group/grouptypes mints STRUKTUR_TYPE_ID; GETs return whatever `perms` maps. */ @@ -52,7 +60,12 @@ function mockClient(perms: Record = {}) { describe("pending domain: declare group type + grant by reference in one config (#69/#23)", () => { it("plans from EMPTY state without aborting — a pending grant block, not the hard error", async () => { const { client, get } = mockClient(); - const { items, fetchErrors } = await buildPermissionPlan(client, emptyState(HOST), [strukturPerm], strukturType); + const { items, fetchErrors } = await buildPermissionPlan( + client, + emptyState(HOST), + [strukturPerm], + strukturType, + ); expect(fetchErrors).toEqual([]); // The domain is pending: no numeric id yet, the Ref is carried for apply-time re-resolution. @@ -87,13 +100,22 @@ describe("pending domain: declare group type + grant by reference in one config // …then permission reconciliation runs against POST-execute state and writes to the fresh domain. const res = await applyPermissionPlan(items, client, state); expect(res.granted).toBe(1); - const put = calls.find((c) => c.method === "PUT" && c.path === `/permissions/group_type_role/${STRUKTUR_TYPE_ID}`); + const put = calls.find( + (c) => c.method === "PUT" && c.path === `/permissions/group_type_role/${STRUKTUR_TYPE_ID}`, + ); expect(put?.body).toEqual({ authId: 1113, type: "grant" }); // fresh domain id in the path, not a placeholder // Second plan (type now in state, grant now live) converges to a no-op — domain is concrete. const { client: c2 } = mockClient({ "/permissions/group_type_role": [ - { domainType: "group_type_role", domainId: STRUKTUR_TYPE_ID, authId: 1113, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, + { + domainType: "group_type_role", + domainId: STRUKTUR_TYPE_ID, + authId: 1113, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, ], }); const { items: items2, fetchErrors } = await buildPermissionPlan(c2, state, [strukturPerm], strukturType); @@ -108,12 +130,30 @@ describe("pending domain: declare group type + grant by reference in one config describe("pending domain: prod-like scenario (type already in state) is unchanged (#69)", () => { it("resolves to the concrete domain id and reconciles idempotently — no pending path", async () => { - const state: State = { version: 1, host: HOST, resources: { - struktur: { type: "group-type", id: STRUKTUR_TYPE_ID, key: "struktur", fields: { name: "Struktur" }, adoptedAt: "t", updatedAt: "t" }, - }}; + const state: State = { + version: 1, + host: HOST, + resources: { + struktur: { + type: "group-type", + id: STRUKTUR_TYPE_ID, + key: "struktur", + fields: { name: "Struktur" }, + adoptedAt: "t", + updatedAt: "t", + }, + }, + }; const { client } = mockClient({ "/permissions/group_type_role": [ - { domainType: "group_type_role", domainId: STRUKTUR_TYPE_ID, authId: 1113, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, + { + domainType: "group_type_role", + domainId: STRUKTUR_TYPE_ID, + authId: 1113, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, ], }); const { items, fetchErrors } = await buildPermissionPlan(client, state, [strukturPerm], strukturType); @@ -132,9 +172,14 @@ describe("group_role symmetry: a same-run group is NOT made pending — it stays // materially harder case than group_type_role (whose domainId is the group type's OWN id, present // in post-execute state). So group_role deliberately does NOT go pending: it fails fast at // resolve time with its own actionable message, unchanged by this fix. - const declaredGroup: DesiredResource[] = [{ type: "group", key: "kids_area", fields: { name: "Kids" }, dependsOn: [] }]; + const declaredGroup: DesiredResource[] = [ + { type: "group", key: "kids_area", fields: { name: "Kids" }, dependsOn: [] }, + ]; const grPerm: DesiredPermission = { - key: "kids_lead", domainType: "group_role", domainId: ref.groupRole("kids_area", "Leiter"), grants: [], + key: "kids_lead", + domainType: "group_role", + domainId: ref.groupRole("kids_area", "Leiter"), + grants: [], }; const { client } = mockClient(); await expect(buildPermissionPlan(client, emptyState(HOST), [grPerm], declaredGroup)).rejects.toThrow( diff --git a/tests/permission-plan.test.ts b/tests/permission-plan.test.ts index d628274..884540b 100644 --- a/tests/permission-plan.test.ts +++ b/tests/permission-plan.test.ts @@ -1,21 +1,35 @@ import { describe, it, expect, vi } from "vitest"; import { desiredTuples, buildPermissionPlan } from "../src/permissions/plan.js"; -import { CATALOG_META } from "../src/permissions/catalog.js"; +import { CATALOG, CATALOG_META, useCatalog, useBundledCatalog } from "../src/permissions/catalog.js"; import { ref } from "../src/resolve/refs.js"; import type { State } from "../src/state/state.js"; -const state: State = { version: 1, host: "h", resources: { - kids_area: { type: "group", id: 42, key: "kids_area", fields: {}, adoptedAt: "t", updatedAt: "t" }, - other: { type: "group", id: 7, key: "other", fields: {}, adoptedAt: "t", updatedAt: "t" }, -}}; +/** The bundled rights, snapshotted before any test swaps the catalog (CATALOG is a live binding). */ +const bundledForTest = { ...CATALOG }; + +const state: State = { + version: 1, + host: "h", + resources: { + kids_area: { type: "group", id: 42, key: "kids_area", fields: {}, adoptedAt: "t", updatedAt: "t" }, + other: { type: "group", id: 7, key: "other", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, +}; describe("desiredTuples", () => { it("resolves names and scope to tuples", () => { const tuples = desiredTuples( - { key: "t", domainType: "group_type_role", domainId: 8, grants: [ - "churchgroup:administer groups", // authId 1113, unscoped (no scopeField) → global - { right: "churchgroup:view group", scope: ["kids_area"] }, // authId 1104, scoped, dataId [42] - ]}, state); + { + key: "t", + domainType: "group_type_role", + domainId: 8, + grants: [ + "churchgroup:administer groups", // authId 1113, unscoped (no scopeField) → global + { right: "churchgroup:view group", scope: ["kids_area"] }, // authId 1104, scoped, dataId [42] + ], + }, + state, + ); expect(tuples).toEqual([ { authId: 1113, dataId: [], type: "grant" }, { authId: 1104, dataId: [42], type: "grant", scopeKey: "kids_area", scopeType: "group" }, // scoped tuples retain their symbolic key for re-resolution @@ -25,22 +39,34 @@ describe("desiredTuples", () => { it("rejects a bare-string scoped right — it would silently grant globally", () => { // churchgroup:view group carries scopeField "cdb_gruppe" (it IS scoped). Declared as a bare // string it would emit dataId: [] — a global grant. It must be declared as { right, scope }. - expect(() => desiredTuples( - { key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:view group"] }, state), + expect(() => + desiredTuples( + { key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:view group"] }, + state, + ), ).toThrow(/is a scoped right.*must be declared as \{ right/is); }); it("accepts an admin-authored authId >= 10000 member right on group_type_role (#65)", () => { // The old authId>=10000 rejection is gone — admin-authored member rights CT lets you write are // now declarable. "churchdb:+add person" (authId 10107) is unscoped, so a bare string is valid. - expect(desiredTuples({ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchdb:+add person"] }, state)) - .toEqual([{ authId: 10107, dataId: [], type: "grant" }]); + expect( + desiredTuples( + { key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchdb:+add person"] }, + state, + ), + ).toEqual([{ authId: 10107, dataId: [], type: "grant" }]); }); it("fans out a multi-element scope into one single-dataId tuple per dataId (idempotency: ChurchTools reads scoped grants back one row per dataId)", () => { const tuples = desiredTuples( - { key: "t", domainType: "group_type_role", domainId: 8, grants: [ - { right: "churchgroup:view group", scope: ["kids_area", "other"] }, - ]}, state); + { + key: "t", + domainType: "group_type_role", + domainId: 8, + grants: [{ right: "churchgroup:view group", scope: ["kids_area", "other"] }], + }, + state, + ); // resolveScope sorts resolved dataIds ascending (7 < 42), independent of scope-key order. expect(tuples).toEqual([ { authId: 1104, dataId: [7], type: "grant", scopeKey: "other", scopeType: "group" }, @@ -51,10 +77,16 @@ describe("desiredTuples", () => { it("rejects a scoped grant on a right with no scopeField", () => { // churchcore:administer settings has scopeField: null in the catalog — not a scoped right. - expect(() => desiredTuples( - { key: "t", domainType: "group_type_role", domainId: 8, grants: [ - { right: "churchcore:administer settings", scope: ["kids_area"] }, - ]}, state), + expect(() => + desiredTuples( + { + key: "t", + domainType: "group_type_role", + domainId: 8, + grants: [{ right: "churchcore:administer settings", scope: ["kids_area"] }], + }, + state, + ), ).toThrow(/not a scoped right/); }); @@ -64,9 +96,14 @@ describe("desiredTuples", () => { // declare it. Numeric entries fan out just like logical keys, and MUST NOT retain a scopeKey — // there is no state resource to re-resolve at apply time. const tuples = desiredTuples( - { key: "t", domainType: "group_type_role", domainId: 8, grants: [ - { right: "churchdb:view comments", scope: [1, 2] }, - ]}, state); + { + key: "t", + domainType: "group_type_role", + domainId: 8, + grants: [{ right: "churchdb:view comments", scope: [1, 2] }], + }, + state, + ); expect(tuples).toEqual([ { authId: 113, dataId: [1], type: "grant" }, { authId: 113, dataId: [2], type: "grant" }, @@ -76,9 +113,14 @@ describe("desiredTuples", () => { it("mixes a numeric scope entry with a logical group key in the same declaration", () => { const tuples = desiredTuples( - { key: "t", domainType: "group_type_role", domainId: 8, grants: [ - { right: "churchgroup:view group", scope: ["kids_area", 3] }, - ]}, state); + { + key: "t", + domainType: "group_type_role", + domainId: 8, + grants: [{ right: "churchgroup:view group", scope: ["kids_area", 3] }], + }, + state, + ); expect(tuples).toEqual([ { authId: 1104, dataId: [3], type: "grant" }, { authId: 1104, dataId: [42], type: "grant", scopeKey: "kids_area", scopeType: "group" }, @@ -88,58 +130,126 @@ describe("desiredTuples", () => { describe("buildPermissionPlan", () => { it("diffs desired vs actual (bulk fetch filtered to managed domainIds)", async () => { - const client = { get: vi.fn(async () => [ - { domainType: "group_type_role", domainId: 8, authId: 1113, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, - { domainType: "group_type_role", domainId: 99, authId: 1, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, // unmanaged domainId → ignored - ]) }; - const { items, fetchErrors } = await buildPermissionPlan(client as never, state, - [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }]); + const client = { + get: vi.fn(async () => [ + { + domainType: "group_type_role", + domainId: 8, + authId: 1113, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, + { + domainType: "group_type_role", + domainId: 99, + authId: 1, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, // unmanaged domainId → ignored + ]), + }; + const { items, fetchErrors } = await buildPermissionPlan(client as never, state, [ + { key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }, + ]); expect(fetchErrors).toEqual([]); - expect(items[0]?.diff.toPut).toEqual([]); // 1113 unscoped already present - expect(items[0]?.diff.toDelete).toEqual([]); // domainId 99 is unmanaged → invisible + expect(items[0]?.diff.toPut).toEqual([]); // 1113 unscoped already present + expect(items[0]?.diff.toDelete).toEqual([]); // domainId 99 is unmanaged → invisible }); it("multi-scope grant is idempotent against ChurchTools's one-row-per-dataId read shape (no churn)", async () => { - const client = { get: vi.fn(async () => [ - { domainType: "group_type_role", domainId: 8, authId: 1104, dataId: 42, type: "grant", meta: { modifiedPid: 1 } }, - { domainType: "group_type_role", domainId: 8, authId: 1104, dataId: 7, type: "grant", meta: { modifiedPid: 1 } }, - ]) }; - const { items, fetchErrors } = await buildPermissionPlan(client as never, state, - [{ key: "t", domainType: "group_type_role", domainId: 8, grants: [ - { right: "churchgroup:view group", scope: ["kids_area", "other"] }, - ]}]); + const client = { + get: vi.fn(async () => [ + { + domainType: "group_type_role", + domainId: 8, + authId: 1104, + dataId: 42, + type: "grant", + meta: { modifiedPid: 1 }, + }, + { + domainType: "group_type_role", + domainId: 8, + authId: 1104, + dataId: 7, + type: "grant", + meta: { modifiedPid: 1 }, + }, + ]), + }; + const { items, fetchErrors } = await buildPermissionPlan(client as never, state, [ + { + key: "t", + domainType: "group_type_role", + domainId: 8, + grants: [{ right: "churchgroup:view group", scope: ["kids_area", "other"] }], + }, + ]); expect(fetchErrors).toEqual([]); expect(items[0]?.diff.toPut).toEqual([]); expect(items[0]?.diff.toDelete).toEqual([]); }); it("resolves a group_role domain by (group, role) reference and reconciles idempotently (#25)", async () => { - const client = { get: vi.fn(async (path: string) => { - if (path === "/groups/42/roles") return [{ id: 2882, name: "Leiter" }]; - if (path === "/permissions/group_role") return [ - { domainType: "group_role", domainId: 2882, authId: 1104, dataId: 42, type: "grant", meta: { modifiedPid: 1 } }, - ]; - throw new Error(`unexpected path ${path}`); - }) }; + const client = { + get: vi.fn(async (path: string) => { + if (path === "/groups/42/roles") return [{ id: 2882, name: "Leiter" }]; + if (path === "/permissions/group_role") + return [ + { + domainType: "group_role", + domainId: 2882, + authId: 1104, + dataId: 42, + type: "grant", + meta: { modifiedPid: 1 }, + }, + ]; + throw new Error(`unexpected path ${path}`); + }), + }; // Declared with ZERO numeric ids: right name + group key + role name only. const { items, warnings, fetchErrors } = await buildPermissionPlan(client as never, state, [ - { key: "kids_lead", domainType: "group_role", domainId: ref.groupRole("kids_area", "Leiter"), - grants: [{ right: "churchgroup:view group", scope: ["kids_area"] }] }, + { + key: "kids_lead", + domainType: "group_role", + domainId: ref.groupRole("kids_area", "Leiter"), + grants: [{ right: "churchgroup:view group", scope: ["kids_area"] }], + }, ]); expect(fetchErrors).toEqual([]); expect(warnings).toEqual([]); - expect(items[0]?.domainId).toBe(2882); // resolved from the (group, role) pair - expect(items[0]?.diff.toPut).toEqual([]); // adopted live row already matches → no-op + expect(items[0]?.domainId).toBe(2882); // resolved from the (group, role) pair + expect(items[0]?.diff.toPut).toEqual([]); // adopted live row already matches → no-op expect(items[0]?.diff.toDelete).toEqual([]); }); it("warns and never revokes a live grant whose authId is unknown to the catalog (#25)", async () => { - const client = { get: vi.fn(async () => [ - { domainType: "group_type_role", domainId: 8, authId: 1113, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, // known + desired - { domainType: "group_type_role", domainId: 8, authId: 987654, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, // unknown authId - ]) }; - const { items, warnings } = await buildPermissionPlan(client as never, state, - [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }]); + const client = { + get: vi.fn(async () => [ + { + domainType: "group_type_role", + domainId: 8, + authId: 1113, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, // known + desired + { + domainType: "group_type_role", + domainId: 8, + authId: 987654, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, // unknown authId + ]), + }; + const { items, warnings } = await buildPermissionPlan(client as never, state, [ + { key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }, + ]); expect(items[0]?.diff.toDelete).toEqual([]); // the unnameable grant is NOT proposed for revocation expect(items[0]?.diff.toPut).toEqual([]); expect(warnings.some((w) => w.includes("987654") && w.includes("group_type_role #8"))).toBe(true); @@ -151,17 +261,53 @@ describe("buildPermissionPlan", () => { // self-re-adding system baseline (modifiedPid === -1) and truly-inherited rows are excluded by // normalizeActual and NEVER revoked (the #65 bug was "0 to grant, 24 to remove"). The boundary is // inheritance + system-baseline, NOT the authId — so the excluded rows below carry authId >= 10000 too. - const client = { get: vi.fn(async () => [ - // declared writable grant — matches, no diff - { domainType: "group_type_role", domainId: 9, authId: 1113, dataId: null, type: "grant", meta: { modifiedPid: 5 } }, - // admin-authored member right (authId >= 10000, pid 5) that is UNDECLARED → must be revoked - { domainType: "group_type_role", domainId: 9, authId: 10107, dataId: null, type: "grant", meta: { modifiedPid: 5 } }, - // system baseline + inherited (authId >= 10000) → excluded, never revoked - { domainType: "group_type_role", domainId: 9, authId: 10122, dataId: null, type: "grant", meta: { modifiedPid: -1 } }, - { domainType: "group_type_role", domainId: 9, authId: 10111, dataId: null, type: "grant", isInherited: true }, - ]) }; - const { items, warnings, fetchErrors } = await buildPermissionPlan(client as never, state, - [{ key: "struktur", domainType: "group_type_role", domainId: 9, grants: ["churchgroup:administer groups"] }]); + const client = { + get: vi.fn(async () => [ + // declared writable grant — matches, no diff + { + domainType: "group_type_role", + domainId: 9, + authId: 1113, + dataId: null, + type: "grant", + meta: { modifiedPid: 5 }, + }, + // admin-authored member right (authId >= 10000, pid 5) that is UNDECLARED → must be revoked + { + domainType: "group_type_role", + domainId: 9, + authId: 10107, + dataId: null, + type: "grant", + meta: { modifiedPid: 5 }, + }, + // system baseline + inherited (authId >= 10000) → excluded, never revoked + { + domainType: "group_type_role", + domainId: 9, + authId: 10122, + dataId: null, + type: "grant", + meta: { modifiedPid: -1 }, + }, + { + domainType: "group_type_role", + domainId: 9, + authId: 10111, + dataId: null, + type: "grant", + isInherited: true, + }, + ]), + }; + const { items, warnings, fetchErrors } = await buildPermissionPlan(client as never, state, [ + { + key: "struktur", + domainType: "group_type_role", + domainId: 9, + grants: ["churchgroup:administer groups"], + }, + ]); expect(fetchErrors).toEqual([]); expect(items[0]?.diff.toPut).toEqual([]); // the declared writable grant already matches // the undeclared admin-authored member right IS revoked; the baseline + inherited rows are NOT @@ -173,15 +319,30 @@ describe("buildPermissionPlan", () => { it("reconciles admin-authored authId >= 10000 rights on group_role too (no authId cutoff, #65)", async () => { // No authId cutoff on either domain: on group_role the churchdb:+… rights ARE writable/declarable, // so a live admin-authored one that is undeclared must still be revoked (no blanket exclusion). - const client = { get: vi.fn(async (path: string) => { - if (path === "/groups/42/roles") return [{ id: 2882, name: "Leiter" }]; - if (path === "/permissions/group_role") return [ - { domainType: "group_role", domainId: 2882, authId: 10122, dataId: null, type: "grant", meta: { modifiedPid: 5 } }, - ]; - throw new Error(`unexpected path ${path}`); - }) }; + const client = { + get: vi.fn(async (path: string) => { + if (path === "/groups/42/roles") return [{ id: 2882, name: "Leiter" }]; + if (path === "/permissions/group_role") + return [ + { + domainType: "group_role", + domainId: 2882, + authId: 10122, + dataId: null, + type: "grant", + meta: { modifiedPid: 5 }, + }, + ]; + throw new Error(`unexpected path ${path}`); + }), + }; const { items } = await buildPermissionPlan(client as never, state, [ - { key: "kids_lead", domainType: "group_role", domainId: ref.groupRole("kids_area", "Leiter"), grants: [] }, + { + key: "kids_lead", + domainType: "group_role", + domainId: ref.groupRole("kids_area", "Leiter"), + grants: [], + }, ]); expect(items[0]?.diff.toDelete).toEqual([{ authId: 10122, dataId: [], type: "grant" }]); }); @@ -189,63 +350,176 @@ describe("buildPermissionPlan", () => { it("still revokes a REAL user-authored grant (authId < 10000) that is undeclared (#65 guard)", async () => { // Regression guard: the inherited-rights exclusion must NOT swallow ordinary undeclared grants — // those are exactly the drift a plan is meant to surface as a revoke. - const client = { get: vi.fn(async () => [ - { domainType: "group_type_role", domainId: 9, authId: 1113, dataId: null, type: "grant", meta: { modifiedPid: 5 } }, // declared - { domainType: "group_type_role", domainId: 9, authId: 1104, dataId: 42, type: "grant", meta: { modifiedPid: 5 } }, // undeclared user grant - { domainType: "group_type_role", domainId: 9, authId: 10101, dataId: null, type: "grant", isInherited: true }, // inherited → excluded - ]) }; - const { items } = await buildPermissionPlan(client as never, state, - [{ key: "struktur", domainType: "group_type_role", domainId: 9, grants: ["churchgroup:administer groups"] }]); + const client = { + get: vi.fn(async () => [ + { + domainType: "group_type_role", + domainId: 9, + authId: 1113, + dataId: null, + type: "grant", + meta: { modifiedPid: 5 }, + }, // declared + { + domainType: "group_type_role", + domainId: 9, + authId: 1104, + dataId: 42, + type: "grant", + meta: { modifiedPid: 5 }, + }, // undeclared user grant + { + domainType: "group_type_role", + domainId: 9, + authId: 10101, + dataId: null, + type: "grant", + isInherited: true, + }, // inherited → excluded + ]), + }; + const { items } = await buildPermissionPlan(client as never, state, [ + { + key: "struktur", + domainType: "group_type_role", + domainId: 9, + grants: ["churchgroup:administer groups"], + }, + ]); expect(items[0]?.diff.toDelete).toEqual([{ authId: 1104, dataId: [42], type: "grant" }]); }); it("warns when the instance CT version differs from the catalog's recorded version (#25)", async () => { const client = { get: vi.fn(async () => []) }; - const { warnings } = await buildPermissionPlan(client as never, state, + const { warnings } = await buildPermissionPlan( + client as never, + state, [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }], - [], undefined, "9.99.0"); - expect(warnings.some((w) => /catalog was captured from ChurchTools .* but this instance\s+runs 9\.99\.0/is.test(w))).toBe(true); + [], + undefined, + "9.99.0", + ); + expect( + warnings.some((w) => + /catalog was captured from ChurchTools .* but this instance\s+runs 9\.99\.0/is.test(w), + ), + ).toBe(true); }); it("does NOT warn about staleness when the instance version matches the catalog version (#25)", async () => { const client = { get: vi.fn(async () => []) }; - const { warnings } = await buildPermissionPlan(client as never, state, + const { warnings } = await buildPermissionPlan( + client as never, + state, [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }], - [], undefined, CATALOG_META!.ctVersion); + [], + undefined, + CATALOG_META!.ctVersion, + ); expect(warnings).toEqual([]); }); + + // A per-instance capture is authoritative for its host AT CAPTURE TIME — not forever. The instance + // gets upgraded while the committed file does not, which is exactly when a moved authId silently + // changes what a declared right grants, so the comparison must still run (#105 review). + it("still warns on version skew under a PER-INSTANCE catalog, naming --refresh as the remedy", async () => { + const client = { get: vi.fn(async () => []) }; + useCatalog( + { ...bundledForTest, $meta: { ...CATALOG_META, ctVersion: "3.135.0" } }, + { perInstance: true }, + ); + try { + const { warnings } = await buildPermissionPlan( + client as never, + state, + [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }], + [], + undefined, + "3.150.0", + ); + expect(warnings.some((w) => /captured against ChurchTools 3\.135\.0/.test(w))).toBe(true); + expect(warnings.some((w) => /ct permissions catalog --refresh/.test(w))).toBe(true); + } finally { + useBundledCatalog(); + } + }); + + it("stays quiet under a per-instance catalog captured against the version the host still runs", async () => { + const client = { get: vi.fn(async () => []) }; + useCatalog( + { ...bundledForTest, $meta: { ...CATALOG_META, ctVersion: "3.135.0" } }, + { perInstance: true }, + ); + try { + const { warnings } = await buildPermissionPlan( + client as never, + state, + [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }], + [], + undefined, + "3.135.0", + ); + expect(warnings).toEqual([]); + } finally { + useBundledCatalog(); + } + }); // The PERSON-status domain end to end (#90): a `personStatus` ref resolves against /statuses, the // planner bulk-fetches /permissions/status, and the live `dataId: -1` ALL sentinel round-trips to // a clean no-op — which is the whole point (an instance-wide grant that churned every plan would // rewrite everyone's rights on every apply). it("resolves a status domain by person-status name and reconciles the -1 ALL sentinel idempotently", async () => { - const client = { get: vi.fn(async (path: string) => { - if (path === "/statuses") return [{ id: 4, name: "3 - Group Active" }, { id: 6, name: "5 - Core" }]; - if (path === "/permissions/status") return [ - { domainType: "status", domainId: 6, authId: 18, dataId: -1, type: "grant", meta: { modifiedPid: 1 } }, - ]; - throw new Error(`unexpected path ${path}`); - }) }; + const client = { + get: vi.fn(async (path: string) => { + if (path === "/statuses") + return [ + { id: 4, name: "3 - Group Active" }, + { id: 6, name: "5 - Core" }, + ]; + if (path === "/permissions/status") + return [ + { + domainType: "status", + domainId: 6, + authId: 18, + dataId: -1, + type: "grant", + meta: { modifiedPid: 1 }, + }, + ]; + throw new Error(`unexpected path ${path}`); + }), + }; const { items, warnings, fetchErrors } = await buildPermissionPlan(client as never, state, [ - { key: "core_login", domainType: "status", domainId: ref.personStatus("5 - Core"), - grants: [{ right: "churchcore:login to external system", scope: [-1] }] }, + { + key: "core_login", + domainType: "status", + domainId: ref.personStatus("5 - Core"), + grants: [{ right: "churchcore:login to external system", scope: [-1] }], + }, ]); expect(fetchErrors).toEqual([]); expect(warnings).toEqual([]); - expect(items[0]?.domainId).toBe(6); // resolved from the /statuses catalog + expect(items[0]?.domainId).toBe(6); // resolved from the /statuses catalog expect(items[0]?.diff.toPut).toEqual([]); // live -1 row matches the declaration expect(items[0]?.diff.toDelete).toEqual([]); }); it("proposes the status grant on a status that does not carry it yet", async () => { - const client = { get: vi.fn(async (path: string) => { - if (path === "/statuses") return [{ id: 4, name: "3 - Group Active" }]; - if (path === "/permissions/status") return []; - throw new Error(`unexpected path ${path}`); - }) }; + const client = { + get: vi.fn(async (path: string) => { + if (path === "/statuses") return [{ id: 4, name: "3 - Group Active" }]; + if (path === "/permissions/status") return []; + throw new Error(`unexpected path ${path}`); + }), + }; const { items } = await buildPermissionPlan(client as never, state, [ - { key: "group_active_login", domainType: "status", domainId: ref.personStatus("3 - Group Active"), - grants: [{ right: "churchcore:login to external system", scope: [-1] }] }, + { + key: "group_active_login", + domainType: "status", + domainId: ref.personStatus("3 - Group Active"), + grants: [{ right: "churchcore:login to external system", scope: [-1] }], + }, ]); expect(items[0]?.domainId).toBe(4); expect(items[0]?.diff.toPut).toEqual([{ authId: 18, dataId: [-1], type: "grant" }]); diff --git a/tests/permission-preserve-unknown.test.ts b/tests/permission-preserve-unknown.test.ts new file mode 100644 index 0000000..6c7b5f2 --- /dev/null +++ b/tests/permission-preserve-unknown.test.ts @@ -0,0 +1,143 @@ +/** + * Opt-in partial grant ownership (#102). + * + * A `ct.groupRole` normally owns its whole role instance: every live grant absent from the + * declaration lands in `toDelete`. On a real instance that costs most of the estate — one + * `cc_html_template` grant makes a 41-grant role undeclarable, because a partial declaration turns a + * clean no-op into a destructive plan. `preserveUnknown` is the deliberate way out; these tests pin + * that it is opt-in, that it is loud, and that the strict default did not move. + */ +import { describe, it, expect } from "vitest"; +import { diffGrants, type GrantTuple } from "../src/permissions/grants.js"; +import { preservePredicateFor } from "../src/permissions/plan.js"; +import { renderPermissionPlan } from "../src/permissions/render.js"; +import { createContext } from "../src/config/context.js"; + +/** `churchcore:use church html templates` — scoped by `cc_html_template`, the real-world blocker. */ +const HTML_TEMPLATE = 17; +/** `churchgroup:view group` — scoped by `cdb_gruppe`, a dimension this tool DOES manage. */ +const VIEW_GROUP = 1104; +/** `churchcore:administer settings` — unscoped. */ +const UNSCOPED = 1; + +const grant = (authId: number, dataId: number[] = []): GrantTuple => ({ authId, dataId, type: "grant" }); + +describe("preserveUnknown — the diff (#102)", () => { + const desired = [grant(VIEW_GROUP, [42])]; + const actual = [grant(VIEW_GROUP, [42]), grant(HTML_TEMPLATE, [3]), grant(VIEW_GROUP, [99])]; + + it("default is unchanged: every undeclared live grant is still a revoke", () => { + const d = diffGrants(desired, actual); + expect(d.toDelete.map((t) => t.authId)).toEqual([HTML_TEMPLATE, VIEW_GROUP]); + expect(d.preservedUnknown).toEqual([]); + }); + + it("preserveUnknown: true keeps every undeclared grant instead of revoking it", () => { + const d = diffGrants(desired, actual, preservePredicateFor(true)); + expect(d.toDelete).toEqual([]); + expect(d.preservedUnknown.map((t) => t.authId)).toEqual([HTML_TEMPLATE, VIEW_GROUP]); + }); + + it("a dimension list keeps ONLY that dimension — a stray grant on a managed one is still drift", () => { + const d = diffGrants(desired, actual, preservePredicateFor(["cc_html_template"])); + // The module grant is left alone… + expect(d.preservedUnknown.map((t) => t.authId)).toEqual([HTML_TEMPLATE]); + // …while the unexpected extra group-scoped grant still shows up as something apply will revoke. + // That is the whole reason to prefer the list form over `true`. + expect(d.toDelete.map((t) => [t.authId, t.dataId])).toEqual([[VIEW_GROUP, [99]]]); + }); + + it("a dimension list never silently widens to unscoped rights", () => { + const d = diffGrants([], [grant(UNSCOPED)], preservePredicateFor(["cc_html_template"])); + expect(d.preservedUnknown).toEqual([]); + expect(d.toDelete.map((t) => t.authId)).toEqual([UNSCOPED]); + }); + + it("preserved grants are never mistaken for a pre-existing deny row", () => { + const deny: GrantTuple = { authId: VIEW_GROUP, dataId: [7], type: "revoke" }; + const d = diffGrants(desired, [...actual, deny], preservePredicateFor(true)); + expect(d.preserved).toEqual([deny]); // denies reconciliation never owned + expect(d.preservedUnknown.every((t) => t.type === "grant")).toBe(true); + }); +}); + +describe("preserveUnknown — the plan is loud about it (#102)", () => { + it("renders a role whose ONLY notable property is preserved grants, and excludes them from the totals", () => { + const rendered = renderPermissionPlan([ + { + key: "team_office_leiter", + domainType: "group_role", + domainId: 44675, + diff: { + toPut: [], + toDelete: [], + preserved: [], + preservedUnknown: [grant(HTML_TEMPLATE, [3]), grant(HTML_TEMPLATE, [4])], + }, + }, + ]); + // Invisible is exactly what this must not be: without the item line, "I forgot one" and "I + // deliberately left the module grants alone" render identically (as nothing). + expect(rendered).toContain("team_office_leiter"); + expect(rendered).toContain("preserved, not managed — preserveUnknown"); + expect(rendered).toContain("0 to grant, 0 to remove, 2 preserved (not managed)"); + }); + + it("still reports a clean no-op when nothing at all was preserved", () => { + const rendered = renderPermissionPlan([ + { + key: "k", + domainType: "group_role", + domainId: 1, + diff: { toPut: [], toDelete: [], preserved: [], preservedUnknown: [] }, + }, + ]); + expect(rendered).toContain("No permission changes"); + }); +}); + +describe("preserveUnknown — config validation (#102)", () => { + const declare = (preserveUnknown: unknown): void => { + const { ct } = createContext(); + ct.groupRole({ + key: "k", + id: 1, + grants: ["churchcore:administer settings"], + preserveUnknown: preserveUnknown as never, + }); + }; + + it("accepts true and a list of real scope dimensions", () => { + expect(() => declare(true)).not.toThrow(); + expect(() => declare(["cc_html_template", "cdb_gruppe"])).not.toThrow(); + }); + + it("rejects a dimension no right is scoped by — a typo must not read as 'nothing to preserve'", () => { + expect(() => declare(["cc_html_templates"])).toThrow(/no right in the permission catalog scopes by/); + }); + + it("rejects an empty list rather than accept a no-op escape hatch", () => { + expect(() => declare([])).toThrow(/preserves nothing/); + }); + + it("rejects a non-string entry", () => { + expect(() => declare([42])).toThrow(/must be true or an array of scope dimension names/); + }); + + it("treats false as the strict default and carries nothing through", () => { + const { ct, permissions } = createContext(); + ct.groupRole({ key: "k", id: 1, grants: [], preserveUnknown: false }); + expect(permissions[0]?.preserveUnknown).toBeUndefined(); + }); + + it("carries an accepted value onto the desired permission, deduped", () => { + const { ct, permissions } = createContext(); + ct.groupRole({ + key: "k", + id: 1, + grants: [], + preserveUnknown: ["cc_html_template", "cc_html_template"], + }); + expect(permissions[0]?.preserveUnknown).toEqual(["cc_html_template"]); + }); +}); diff --git a/tests/permission-scope-refs.test.ts b/tests/permission-scope-refs.test.ts index d69c07b..d75e642 100644 --- a/tests/permission-scope-refs.test.ts +++ b/tests/permission-scope-refs.test.ts @@ -38,7 +38,14 @@ function stateWithKoblenz(id: number): State { version: 1, host: HOST, resources: { - koblenz: { type: "campus", id, key: "koblenz", fields: { name: "Koblenz" }, adoptedAt: "t", updatedAt: "t" }, + koblenz: { + type: "campus", + id, + key: "koblenz", + fields: { name: "Koblenz" }, + adoptedAt: "t", + updatedAt: "t", + }, }, }; } @@ -55,11 +62,21 @@ function mockClient(campuses: { id: number; name: string }[] = [], newId = 555) return { client: { get, request } as unknown as CtClient, calls, get }; } -async function tuplesFor(perm: DesiredPermission, state: State, desired: DesiredResource[] = [], client?: CtClient) { +async function tuplesFor( + perm: DesiredPermission, + state: State, + desired: DesiredResource[] = [], + client?: CtClient, +) { const c = client ?? mockClient().client; const resolver = new Resolver({ client: c, state, desired }); const refs = await resolveScopeRefs([perm], resolver, state); - return desiredTuples(perm, state, new Set(desired.filter((r) => r.type === "group").map((r) => r.key)), refs); + return desiredTuples( + perm, + state, + new Set(desired.filter((r) => r.type === "group").map((r) => r.key)), + refs, + ); } describe("campus-scoped grants are portable across hosts (#98)", () => { @@ -68,14 +85,27 @@ describe("campus-scoped grants are portable across hosts (#98)", () => { const dev = await tuplesFor(campusScoped, stateWithKoblenz(6)); const prod = await tuplesFor(campusScoped, stateWithKoblenz(23)); - expect(dev).toEqual([{ authId: 124, dataId: [6], type: "grant", scopeKey: "koblenz", scopeType: "campus" }]); - expect(prod).toEqual([{ authId: 124, dataId: [23], type: "grant", scopeKey: "koblenz", scopeType: "campus" }]); + expect(dev).toEqual([ + { authId: 124, dataId: [6], type: "grant", scopeKey: "koblenz", scopeType: "campus" }, + ]); + expect(prod).toEqual([ + { authId: 124, dataId: [23], type: "grant", scopeKey: "koblenz", scopeType: "campus" }, + ]); }); it("plans to a clean no-op against each host's live rows", async () => { for (const id of [6, 23]) { const state = stateWithKoblenz(id); - const live = [{ domainType: "group_role", domainId: 900, authId: 124, dataId: id, type: "grant", meta: { modifiedPid: 5 } }]; + const live = [ + { + domainType: "group_role", + domainId: 900, + authId: 124, + dataId: id, + type: "grant", + meta: { modifiedPid: 5 }, + }, + ]; const client = { get: vi.fn(async (path: string) => (path === "/campuses" ? [] : live)) }; const { items, fetchErrors } = await buildPermissionPlan(client as never, state, [campusScoped]); expect(fetchErrors).toEqual([]); @@ -93,7 +123,9 @@ describe("campus-scoped grants are portable across hosts (#98)", () => { }); it("a campus created in the same run is pending at plan time and gets its real id at apply time", async () => { - const desired: DesiredResource[] = [{ type: "campus", key: "koblenz", fields: { name: "Koblenz", shorty: "KO" }, dependsOn: [] }]; + const desired: DesiredResource[] = [ + { type: "campus", key: "koblenz", fields: { name: "Koblenz", shorty: "KO" }, dependsOn: [] }, + ]; const { client, calls } = mockClient([], 555); const state = emptyState(HOST); const { items } = await buildPermissionPlan(client, state, [campusScoped], desired); @@ -102,7 +134,15 @@ describe("campus-scoped grants are portable across hosts (#98)", () => { ]); const createCampus: Plan = { - items: [{ type: "campus", key: "koblenz", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "Koblenz" }] }], + items: [ + { + type: "campus", + key: "koblenz", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "Koblenz" }], + }, + ], }; await executePlan(createCampus, { client, state, statePath: "unused", save: async () => {} }); expect(state.resources.koblenz?.id).toBe(555); @@ -119,7 +159,9 @@ describe("scope-dimension validation (#98)", () => { it("rejects a ref whose dimension does not match the right's scopeField, naming both", async () => { const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, // churchdb:view station scopes by cdb_station (campus) — a group-type ref there is a config bug. grants: [{ right: VIEW_STATION, scope: [ref.groupType("struktur")] }], }; @@ -130,7 +172,9 @@ describe("scope-dimension validation (#98)", () => { it("rejects a ref on a dimension with no logical form at all, naming the ref and the dimension", async () => { const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, // churchdb:security level person scopes by cc_securitylevel — security levels are not resources. grants: [{ right: "churchdb:security level person", scope: [ref.campus("koblenz")] }], }; @@ -141,7 +185,9 @@ describe("scope-dimension validation (#98)", () => { it("rejects a logical ref on a dimension that has no logical form, pointing at the numeric hatch", async () => { const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, // ccm_data_category: its values are not resources at all — numbers only, by nature. grants: [{ right: "jpmFlowManagerPreview:view custom category", scope: [ref.campus("x")] }], }; @@ -152,7 +198,9 @@ describe("scope-dimension validation (#98)", () => { it("rejects a logical ref on an unscoped right", async () => { const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, grants: [{ right: "churchcore:administer settings", scope: [ref.campus("koblenz")] }], }; await expect(resolveScopeRefs([perm], resolverFor(emptyState(HOST)), emptyState(HOST))).rejects.toThrow( @@ -164,16 +212,22 @@ describe("scope-dimension validation (#98)", () => { // Before #98 this was the trap: "koblenz" was looked up among managed GROUPS regardless of the // right's dimension, so it either errored confusingly or matched an unrelated same-keyed group. const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, grants: [{ right: VIEW_STATION, scope: ["koblenz"] }], }; const state = stateWithKoblenz(6); - await expect(tuplesFor(perm, state)).rejects.toThrow(/bare string.*scopes by "cdb_station".*\{ campus: "koblenz" \}/s); + await expect(tuplesFor(perm, state)).rejects.toThrow( + /bare string.*scopes by "cdb_station".*\{ campus: "koblenz" \}/s, + ); }); it("a ref that cannot resolve is a hard error, never a dropped or guessed dataId", async () => { const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, grants: [{ right: VIEW_STATION, scope: [ref.campus("nowhere")] }], }; await expect(tuplesFor(perm, emptyState(HOST))).rejects.toThrow(/Cannot resolve campus:nowhere/); @@ -181,18 +235,26 @@ describe("scope-dimension validation (#98)", () => { it("still accepts a numeric dataId on any dimension (the #49 escape hatch is untouched)", async () => { const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, grants: [{ right: "churchdb:view alldata", scope: [7] }], }; expect(await tuplesFor(perm, emptyState(HOST))).toEqual([{ authId: 102, dataId: [7], type: "grant" }]); }); it("resolves a group-type scope ref against the managed group type", async () => { - const state: State = { version: 1, host: HOST, resources: { - struktur: { type: "group-type", id: 12, key: "struktur", fields: {}, adoptedAt: "t", updatedAt: "t" }, - }}; + const state: State = { + version: 1, + host: HOST, + resources: { + struktur: { type: "group-type", id: 12, key: "struktur", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, + }; const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, grants: [{ right: "churchgroup:view groups of grouptype", scope: [ref.groupType("struktur")] }], }; expect(await tuplesFor(perm, state)).toEqual([ @@ -204,11 +266,18 @@ describe("scope-dimension validation (#98)", () => { describe("department scopes are a READ-ONLY ref catalog (cdb_bereich, #98)", () => { // `/departments` is GET-only — live-probed 2026-08-13 against the instance's own OpenAPI spec // (eqrm prod, CT 3.135.2). So a department resolves by NAME on every host but is never declarable. - const departments = [{ id: 7, name: "Equippers Koblenz" }, { id: 1, name: "Equippers Rhein-Main" }]; - const client = { get: vi.fn(async (path: string) => (path === "/departments" ? departments : [])) } as unknown as CtClient; + const departments = [ + { id: 7, name: "Equippers Koblenz" }, + { id: 1, name: "Equippers Rhein-Main" }, + ]; + const client = { + get: vi.fn(async (path: string) => (path === "/departments" ? departments : [])), + } as unknown as CtClient; const perm = (key: string): DesiredPermission => ({ - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, grants: [{ right: "churchdb:view alldata", scope: [{ department: key }] }], }); @@ -227,9 +296,20 @@ describe("department scopes are a READ-ONLY ref catalog (cdb_bereich, #98)", () }); it("is never treated as a managed resource, even if a same-keyed resource is in state", async () => { - const state: State = { version: 1, host: HOST, resources: { - equippers_koblenz: { type: "group", id: 999, key: "equippers_koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }, - }}; + const state: State = { + version: 1, + host: HOST, + resources: { + equippers_koblenz: { + type: "group", + id: 999, + key: "equippers_koblenz", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }, + }, + }; // The group must not shadow the department catalog — that would be the misgrant #98 is about. expect(await tuplesFor(perm("equippers_koblenz"), state, [], client)).toEqual([ { authId: 102, dataId: [7], type: "grant" }, @@ -238,7 +318,7 @@ describe("department scopes are a READ-ONLY ref catalog (cdb_bereich, #98)", () }); describe("scope object sugar", () => { - it("compiles { campus: \"koblenz\" } to the same Ref as ref.campus(...)", () => { + it('compiles { campus: "koblenz" } to the same Ref as ref.campus(...)', () => { const { ct, permissions } = createContext(); ct.groupRole({ key: "p", id: 900, grants: [{ right: VIEW_STATION, scope: [{ campus: "koblenz" }] }] }); expect(permissions[0]?.grants).toEqual([{ right: VIEW_STATION, scope: [ref.campus("koblenz")] }]); @@ -247,10 +327,18 @@ describe("scope object sugar", () => { it("rejects a scope object naming zero or several dimensions", () => { const { ct } = createContext(); expect(() => - ct.groupRole({ key: "a", id: 1, grants: [{ right: VIEW_STATION, scope: [{ campus: "a", groupType: "b" } as never] }] }), + ct.groupRole({ + key: "a", + id: 1, + grants: [{ right: VIEW_STATION, scope: [{ campus: "a", groupType: "b" } as never] }], + }), ).toThrow(/exactly one dimension/); expect(() => - ct.groupRole({ key: "b", id: 2, grants: [{ right: VIEW_STATION, scope: [{ bereich: "a" } as never] }] }), + ct.groupRole({ + key: "b", + id: 2, + grants: [{ right: VIEW_STATION, scope: [{ bereich: "a" } as never] }], + }), ).toThrow(/unknown scope dimension "bereich"/); }); @@ -264,18 +352,40 @@ describe("scope object sugar", () => { describe("re-resolution is type-aware", () => { it("re-resolves a campus scope against the campus in state, not a same-keyed group", () => { - const state: State = { version: 1, host: HOST, resources: { - koblenz: { type: "campus", id: 23, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }, - }}; - const t = { authId: 124, dataId: [], type: "grant" as const, scopeKey: "koblenz", scopeType: "campus", pending: true }; + const state: State = { + version: 1, + host: HOST, + resources: { + koblenz: { type: "campus", id: 23, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, + }; + const t = { + authId: 124, + dataId: [], + type: "grant" as const, + scopeKey: "koblenz", + scopeType: "campus", + pending: true, + }; expect(reresolveTuple(t, state)).toEqual({ ...t, dataId: [23], pending: false }); }); it("refuses to write a grant whose scope key now names a different resource type", () => { - const state: State = { version: 1, host: HOST, resources: { - koblenz: { type: "group", id: 5, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }, - }}; - const t = { authId: 124, dataId: [], type: "grant" as const, scopeKey: "koblenz", scopeType: "campus", pending: true }; + const state: State = { + version: 1, + host: HOST, + resources: { + koblenz: { type: "group", id: 5, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, + }; + const t = { + authId: 124, + dataId: [], + type: "grant" as const, + scopeKey: "koblenz", + scopeType: "campus", + pending: true, + }; expect(() => reresolveTuple(t, state)).toThrow(/did not resolve to a managed campus/); }); }); diff --git a/tests/permission-scope.test.ts b/tests/permission-scope.test.ts index 65b0044..283943c 100644 --- a/tests/permission-scope.test.ts +++ b/tests/permission-scope.test.ts @@ -2,10 +2,14 @@ import { describe, it, expect } from "vitest"; import { resolveScope } from "../src/permissions/scope.js"; import type { State } from "../src/state/state.js"; -const state: State = { version: 1, host: "h", resources: { - kids_area: { type: "group", id: 42, key: "kids_area", fields: {}, adoptedAt: "t", updatedAt: "t" }, - other: { type: "group", id: 7, key: "other", fields: {}, adoptedAt: "t", updatedAt: "t" }, -}}; +const state: State = { + version: 1, + host: "h", + resources: { + kids_area: { type: "group", id: 42, key: "kids_area", fields: {}, adoptedAt: "t", updatedAt: "t" }, + other: { type: "group", id: 7, key: "other", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, +}; describe("resolveScope", () => { it("maps managed group keys to resolutions sorted by id", () => { @@ -15,10 +19,14 @@ describe("resolveScope", () => { ]); }); it("resolves a declared-but-not-yet-created group key to a pending (null id) resolution", () => { - expect(resolveScope(["kids"], state, new Set(["kids"]))).toEqual([{ key: "kids", id: null, type: "group" }]); + expect(resolveScope(["kids"], state, new Set(["kids"]))).toEqual([ + { key: "kids", id: null, type: "group" }, + ]); }); it("orders resolved (in-state, by id) before pending (by key)", () => { - expect(resolveScope(["pending_b", "kids_area", "pending_a"], state, new Set(["pending_a", "pending_b"]))).toEqual([ + expect( + resolveScope(["pending_b", "kids_area", "pending_a"], state, new Set(["pending_a", "pending_b"])), + ).toEqual([ { key: "kids_area", id: 42, type: "group" }, { key: "pending_a", id: null, type: "group" }, { key: "pending_b", id: null, type: "group" }, diff --git a/tests/permission.integration.test.ts b/tests/permission.integration.test.ts index 3615d6c..8fdda14 100644 --- a/tests/permission.integration.test.ts +++ b/tests/permission.integration.test.ts @@ -63,7 +63,12 @@ describe.runIf(live)("permission round-trip (live, read-only)", () => { return { right: name, scope: t.dataId.map((id) => `fixture_group_${id}`) }; }); - const desired: DesiredPermission = { key: "fixture", domainType: "group_type_role", domainId: DOMAIN_ID, grants }; + const desired: DesiredPermission = { + key: "fixture", + domainType: "group_type_role", + domainId: DOMAIN_ID, + grants, + }; // Read-only: buildPermissionPlan only issues GETs. const { items, fetchErrors } = await buildPermissionPlan(client, state, [desired]); diff --git a/tests/person-status-resource.test.ts b/tests/person-status-resource.test.ts index d28b716..d0bfb78 100644 --- a/tests/person-status-resource.test.ts +++ b/tests/person-status-resource.test.ts @@ -43,16 +43,31 @@ describe("the person-status registry entry", () => { // declare no required fields at all). The executor sends the declared bag as a full-replace PUT, // so anything left unmanaged here would be dropped from the body — a 400, or a silent blanking. expect([...knownFields("person-status")].sort()).toEqual([ - "isMember", "isSearchable", "name", "securityLevelId", "shorty", "sortKey", + "isMember", + "isSearchable", + "name", + "securityLevelId", + "shorty", + "sortKey", ]); expect( RESOURCES["person-status"]?.managedFields({ - id: 3, name: "3 - Group Active", shorty: "GA", isMember: true, - isSearchable: false, sortKey: 30, securityLevelId: 1, extra: 1, + id: 3, + name: "3 - Group Active", + shorty: "GA", + isMember: true, + isSearchable: false, + sortKey: 30, + securityLevelId: 1, + extra: 1, }), ).toEqual({ - name: "3 - Group Active", shorty: "GA", isMember: true, - isSearchable: false, sortKey: 30, securityLevelId: 1, + name: "3 - Group Active", + shorty: "GA", + isMember: true, + isSearchable: false, + sortKey: 30, + securityLevelId: 1, }); }); @@ -90,7 +105,9 @@ describe("ct.personStatus in the config DSL", () => { try { const { ct, resources } = createContext(); ct.personStatus({ key: "s", name: "3 - Group Active", nameTranslated: "Group Active" }); - expect(String(spy.mock.calls[0]![0])).toContain('person-status "s": unknown field "nameTranslated" (ignored)'); + expect(String(spy.mock.calls[0]![0])).toContain( + 'person-status "s": unknown field "nameTranslated" (ignored)', + ); expect(resources[0]?.fields).toHaveProperty("nameTranslated", "Group Active"); // still passed through } finally { spy.mockRestore(); @@ -102,9 +119,20 @@ describe("resolving a personStatus reference", () => { const client = { get: vi.fn(async () => [{ id: 3, name: "3 - Group Active" }]) }; it("prefers a MANAGED status in state over the live /statuses catalog", async () => { - const state: State = { version: 1, host: HOST, resources: { - group_active: { type: "person-status", id: 8, key: "group_active", fields: {}, adoptedAt: "t", updatedAt: "t" }, - }}; + const state: State = { + version: 1, + host: HOST, + resources: { + group_active: { + type: "person-status", + id: 8, + key: "group_active", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }, + }, + }; const resolver = new Resolver({ client: client as never, state, desired: [] }); expect(await resolver.resolve(ref.personStatus("group_active"), "site")).toBe(8); }); @@ -153,8 +181,15 @@ describe("declare a status AND grants on it, in one config (the #96 trap)", () = const { items } = await buildPermissionPlan(client, state, [perm], desired); const createStatus: Plan = { - items: [{ type: "person-status", key: "group_active", id: null, action: "create", - changes: [{ field: "name", from: undefined, to: "3 - Group Active" }] }], + items: [ + { + type: "person-status", + key: "group_active", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "3 - Group Active" }], + }, + ], }; await executePlan(createStatus, { client, state, statePath: "unused", save: async () => {} }); expect(state.resources.group_active?.id).toBe(9); diff --git a/tests/plan-detailed-exitcode-command.test.ts b/tests/plan-detailed-exitcode-command.test.ts index a551d4a..e51e936 100644 --- a/tests/plan-detailed-exitcode-command.test.ts +++ b/tests/plan-detailed-exitcode-command.test.ts @@ -110,7 +110,12 @@ describe("ct plan --detailed-exitcode", () => { key: "team", domainType: "group_role", domainId: 1, - diff: { toPut: [{ authId: 5, dataId: [], type: "grant" }], toDelete: [], preserved: [] }, + diff: { + toPut: [{ authId: 5, dataId: [], type: "grant" }], + toDelete: [], + preserved: [], + preservedUnknown: [], + }, }, ]; await runPlan(["--detailed-exitcode"]); diff --git a/tests/plan.test.ts b/tests/plan.test.ts index 2f9f316..f20a114 100644 --- a/tests/plan.test.ts +++ b/tests/plan.test.ts @@ -35,7 +35,9 @@ describe("computePlan", () => { const plan = computePlan([desired("mainz", { name: "Mainz" })], stateOf(), new Map()); expect(plan.items).toHaveLength(1); expect(plan.items[0]).toMatchObject({ action: "create", key: "mainz", id: null }); - expect(plan.items[0]?.changes).toEqual([{ field: "name", from: undefined, to: "Mainz", source: "config" }]); + expect(plan.items[0]?.changes).toEqual([ + { field: "name", from: undefined, to: "Mainz", source: "config" }, + ]); }); it("is a no-op when desired matches actual (id 0 handled)", () => { @@ -79,11 +81,7 @@ describe("computePlan", () => { // A raw array from a programmatic caller (import command, test harness) could carry duplicates — // desiredByKey would collapse them while the plan loop emits both. Reject up front. expect(() => - computePlan( - [desired("dup", { name: "A" }), desired("dup", { name: "B" })], - stateOf(), - new Map(), - ), + computePlan([desired("dup", { name: "A" }), desired("dup", { name: "B" })], stateOf(), new Map()), ).toThrow(/Duplicate desired key "dup"/); }); @@ -94,7 +92,9 @@ describe("computePlan", () => { actualOf({ mainz: { name: "Mainz", shortName: "MZ" } }), ); expect(plan.items[0]).toMatchObject({ action: "update", id: 5 }); - expect(plan.items[0]?.changes).toEqual([{ field: "name", from: "Mainz", to: "Mainz HQ", source: "config" }]); + expect(plan.items[0]?.changes).toEqual([ + { field: "name", from: "Mainz", to: "Mainz HQ", source: "config" }, + ]); }); it("does not flag a mere object-key-order difference as a change", () => { @@ -182,7 +182,7 @@ describe("computePlan", () => { // apart from "both happened, independently". Computed from the SAME three values already // available (last-known state snapshot, desired config, fetched actual) — no new fetch. describe("changes[].source attribution (#24)", () => { - it("tags a plain config change as \"config\" (ChurchTools still matches the last-known snapshot)", () => { + it('tags a plain config change as "config" (ChurchTools still matches the last-known snapshot)', () => { const plan = computePlan( [desired("mainz", { name: "Mainz HQ" })], stateOf(managed("mainz", 5, { name: "Mainz" })), @@ -193,7 +193,7 @@ describe("changes[].source attribution (#24)", () => { ]); }); - it("tags a pure manual edit as \"drift\" (config unchanged, ChurchTools moved)", () => { + it('tags a pure manual edit as "drift" (config unchanged, ChurchTools moved)', () => { const plan = computePlan( [desired("mainz", { name: "Mainz" })], // config still says the last-known value stateOf(managed("mainz", 5, { name: "Mainz" })), @@ -205,7 +205,7 @@ describe("changes[].source attribution (#24)", () => { expect(plan.items[0]?.drift).toEqual([{ field: "name", from: "Mainz", to: "Changed In CT" }]); }); - it("tags \"config+drift\" when both the config AND ChurchTools moved independently", () => { + it('tags "config+drift" when both the config AND ChurchTools moved independently', () => { const plan = computePlan( [desired("mainz", { name: "New Config Name" })], stateOf(managed("mainz", 5, { name: "Old Name" })), @@ -216,14 +216,14 @@ describe("changes[].source attribution (#24)", () => { ]); }); - it("always tags a create's changes as \"config\" (nothing to drift from yet)", () => { + it('always tags a create\'s changes as "config" (nothing to drift from yet)', () => { const plan = computePlan([desired("mainz", { name: "Mainz" })], stateOf(), new Map()); expect(plan.items[0]?.changes).toEqual([ { field: "name", from: undefined, to: "Mainz", source: "config" }, ]); }); - it("always tags a recreate's changes as \"config\"", () => { + it('always tags a recreate\'s changes as "config"', () => { const plan = computePlan( [desired("mainz", { name: "Mainz" })], stateOf(managed("mainz", 5, { name: "Mainz" })), @@ -243,9 +243,7 @@ describe("changes[].source attribution (#24)", () => { stateOf(managedT("group", "team", 9, { name: "Team" })), // no campusId in the snapshot actualOf({ team: { name: "Team", campusId: 4 } }), ); - expect(plan.items[0]?.changes).toEqual([ - { field: "campusId", from: 4, to: 7, source: "config" }, - ]); + expect(plan.items[0]?.changes).toEqual([{ field: "campusId", from: 4, to: 7, source: "config" }]); expect(plan.items[0]?.drift).toBeUndefined(); }); }); diff --git a/tests/query-refs.test.ts b/tests/query-refs.test.ts index 3c7d4a8..014fdd8 100644 --- a/tests/query-refs.test.ts +++ b/tests/query-refs.test.ts @@ -8,7 +8,13 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; -import { VAR_REF_KINDS, portablizeRuleset, type RoleCatalogEntry } from "../src/config/query-refs.js"; +import { + VAR_REF_KINDS, + formatPortablizeWarnings, + portablizeRuleset, + scanUnportablized, + type RoleCatalogEntry, +} from "../src/config/query-refs.js"; import { q, churchQuery } from "../src/config/query.js"; import { normalizeRuleset } from "../src/engine/dynamic.js"; import { deepMapRefs, refKey, type Ref, type RefKind } from "../src/resolve/refs.js"; @@ -55,7 +61,12 @@ describe("portablizeRuleset (#76 Stage 2)", () => { it("rewrites every managed id in a `oneof` id list and keeps operand order", () => { const ruleset = { query: churchQuery(q.oneof("ctgroup.id", [148, 1228])) }; const { ruleset: out } = portablizeRuleset(ruleset, { - idToKeyByKind: { group: new Map([[148, "jugend-mainz"], [1228, "jugend-berlin"]]) }, + idToKeyByKind: { + group: new Map([ + [148, "jugend-mainz"], + [1228, "jugend-berlin"], + ]), + }, }); const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; expect(filter.oneof[0]).toEqual({ var: "ctgroup.id" }); @@ -65,24 +76,33 @@ describe("portablizeRuleset (#76 Stage 2)", () => { ]); }); - it("leaves an unmanaged id numeric and collects a { var, id } warning", () => { + it("leaves an unmanaged id numeric and collects a warning naming the reason (#101)", () => { const ruleset = { query: churchQuery(q.oneof("ctgroup.id", [148, 999])) }; const { ruleset: out, warnings } = portablizeRuleset(ruleset, { idToKeyByKind: { group: new Map([[148, "jugend-mainz"]]) }, }); const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; expect(filter.oneof[1]).toEqual([{ __ctRef: true, kind: "group", key: "jugend-mainz" }, 999]); - expect(warnings).toEqual([{ var: "ctgroup.id", id: 999 }]); + expect(warnings).toEqual([ + { + var: "ctgroup.id", + id: 999, + reason: "unmanaged", + detail: "not under management — `ct adopt group ` for each (then re-adopt) makes them portable", + }, + ]); }); - it("never touches a catalog-less var (groupStatusId) — no rewrite, no warning", () => { + it("never REWRITES a catalog-less var (groupStatusId), but does report it left numeric (#101)", () => { const ruleset = { query: churchQuery(q.oneof("ctgroup.groupStatusId", [1, 2, 4])) }; const { ruleset: out, warnings } = portablizeRuleset(ruleset, { idToKeyByKind: { group: new Map([[1, "nope"]]) }, }); const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; - expect(filter.oneof[1]).toEqual([1, 2, 4]); - expect(warnings).toEqual([]); + expect(filter.oneof[1]).toEqual([1, 2, 4]); // never rewritten — no logical form exists + // …but silence here is what #101 was filed about: the ids ARE host-specific, so they are reported. + expect(warnings.map((w) => w.id)).toEqual([1, 2, 4]); + expect(new Set(warnings.map((w) => w.reason))).toEqual(new Set(["no-ref-kind"])); }); it("does not mutate its input ruleset", () => { @@ -99,7 +119,10 @@ describe("portablizeRuleset (#76 Stage 2)", () => { [84, { groupTypeId: 12, name: "Leiter" }], [16, { groupTypeId: 2, name: "Leiter" }], // SAME name as 84, different group type ]); - const groupTypeIdToKey = new Map([[12, "local_lead"], [2, "team"]]); + const groupTypeIdToKey = new Map([ + [12, "local_lead"], + [2, "team"], + ]); it("disambiguates two same-named roles by their group type", () => { const ruleset = { query: churchQuery(q.oneof("role.id", [84, 16])) }; @@ -128,7 +151,33 @@ describe("portablizeRuleset (#76 Stage 2)", () => { { __ctRef: true, kind: "group-type-role", groupType: "local_lead", role: "Leiter" }, 999, // not in roleCatalog → numeric ]); - expect(warnings).toEqual([{ var: "role.id", id: 999 }]); + expect(warnings).toEqual([ + { + var: "role.id", + id: 999, + reason: "role-unknown", + detail: "no /group/roles row on this host carries these groupTypeRoleIds", + }, + ]); + }); + + it("distinguishes 'role unknown' from 'role's group type unmanaged' (#101)", () => { + const ruleset = { query: churchQuery(q.oneof("role.id", [84])) }; + const { warnings } = portablizeRuleset(ruleset, { + idToKeyByKind: {}, + roleCatalog, // 84 → group type 12 … + groupTypeIdToKey: new Map(), // … which is NOT managed here + }); + expect(warnings).toEqual([ + { + var: "role.id", + id: 84, + reason: "role-group-type-unmanaged", + detail: + "the role's group type is not managed — " + + "adopt that group type to make the (group-type, role) pair portable", + }, + ]); }); it("leaves role.id numeric (with a warning) when no roleCatalog is supplied at all", () => { @@ -136,7 +185,14 @@ describe("portablizeRuleset (#76 Stage 2)", () => { const { ruleset: out, warnings } = portablizeRuleset(ruleset, { idToKeyByKind: {} }); const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; expect(filter.oneof[1]).toEqual([84]); - expect(warnings).toEqual([{ var: "role.id", id: 84 }]); + expect(warnings).toEqual([ + { + var: "role.id", + id: 84, + reason: "role-unknown", + detail: "no /group/roles row on this host carries these groupTypeRoleIds", + }, + ]); }); }); @@ -147,7 +203,11 @@ describe("portablizeRuleset (#76 Stage 2)", () => { it("rewrites the integer field to a group-type-role marker via the same role catalog", () => { const ruleset = { query: churchQuery(q.eq("person.isArchived", 0)), - process: { queryResultOnly: { none: { handleMembership: { groupMemberStatus: "active", groupTypeRoleId: 66 } } } }, + process: { + queryResultOnly: { + none: { handleMembership: { groupMemberStatus: "active", groupTypeRoleId: 66 } }, + }, + }, }; const { ruleset: out, warnings } = portablizeRuleset(ruleset, { idToKeyByKind: {}, @@ -167,7 +227,9 @@ describe("portablizeRuleset (#76 Stage 2)", () => { }); it("leaves the field numeric with a warning when the role is unknown", () => { - const ruleset = { process: { queryResultOnly: { none: { handleMembership: { groupTypeRoleId: 4242 } } } } }; + const ruleset = { + process: { queryResultOnly: { none: { handleMembership: { groupTypeRoleId: 4242 } } } }, + }; const { ruleset: out, warnings } = portablizeRuleset(ruleset, { idToKeyByKind: {}, roleCatalog, @@ -176,7 +238,14 @@ describe("portablizeRuleset (#76 Stage 2)", () => { const hm = (out.process as { queryResultOnly: { none: { handleMembership: Record } } }) .queryResultOnly.none.handleMembership; expect(hm.groupTypeRoleId).toBe(4242); - expect(warnings).toEqual([{ var: "groupTypeRoleId", id: 4242 }]); + expect(warnings).toEqual([ + { + var: "groupTypeRoleId", + id: 4242, + reason: "role-unknown", + detail: "no /group/roles row on this host carries these groupTypeRoleIds", + }, + ]); }); }); @@ -197,10 +266,17 @@ describe("portablizeRuleset (#76 Stage 2)", () => { [17, { groupTypeId: 2, name: "Organisator" }], [66, { groupTypeId: 9, name: "Mitglied" }], // the process.groupTypeRoleId target ]); - const groupTypeIdToKey = new Map([[12, "local_lead"], [2, "team"], [9, "struktur"]]); + const groupTypeIdToKey = new Map([ + [12, "local_lead"], + [2, "team"], + [9, "struktur"], + ]); // Group 1246 is deliberately UNMANAGED (not in the map) — the escape hatch: it stays numeric. const idToKeyByKind: Partial>> = { - group: new Map([[112, "bereich_kids"], [8, "team_kidsdienst"]]), + group: new Map([ + [112, "bereich_kids"], + [8, "team_kidsdienst"], + ]), }; const opts = { idToKeyByKind, roleCatalog, groupTypeIdToKey }; @@ -208,9 +284,12 @@ describe("portablizeRuleset (#76 Stage 2)", () => { const { ruleset: portable } = portablizeRuleset(normalized, opts); const json = JSON.stringify(portable); // A query role marker (84 → local_lead/Leiter) and the process-field marker (66 → struktur/Mitglied). - expect(json).toContain('{"__ctRef":true,"kind":"group-type-role","groupType":"local_lead","role":"Leiter"}'); - const hm = (portable.process as { queryResultOnly: { none: { handleMembership: Record } } }) - .queryResultOnly.none.handleMembership; + expect(json).toContain( + '{"__ctRef":true,"kind":"group-type-role","groupType":"local_lead","role":"Leiter"}', + ); + const hm = ( + portable.process as { queryResultOnly: { none: { handleMembership: Record } } } + ).queryResultOnly.none.handleMembership; expect(hm.groupTypeRoleId).toEqual({ __ctRef: true, kind: "group-type-role", @@ -225,12 +304,20 @@ describe("portablizeRuleset (#76 Stage 2)", () => { // deepMapRefs the resolver uses. Markers sit exactly where the ids were, so the whole ruleset // (query filter AND the out-of-query process.groupTypeRoleId) restores byte-identical. const keyToId = new Map(); - for (const [id, key] of [[112, "bereich_kids"], [8, "team_kidsdienst"]] as const) { + for (const [id, key] of [ + [112, "bereich_kids"], + [8, "team_kidsdienst"], + ] as const) { keyToId.set(refKey({ __ctRef: true, kind: "group", key }), id); } for (const [id, entry] of roleCatalog) { keyToId.set( - refKey({ __ctRef: true, kind: "group-type-role", groupType: groupTypeIdToKey.get(entry.groupTypeId)!, role: entry.name }), + refKey({ + __ctRef: true, + kind: "group-type-role", + groupType: groupTypeIdToKey.get(entry.groupTypeId)!, + role: entry.name, + }), id, ); } @@ -238,13 +325,48 @@ describe("portablizeRuleset (#76 Stage 2)", () => { expect(back).toEqual(normalized); }); - it("leaves the unmanaged group id (1246) and the groupStatusId lists numeric, warning only for 1246", () => { + it("leaves the unmanaged group id (1246) and the groupStatusId lists numeric, and reports BOTH (#101)", () => { const { ruleset: portable, warnings } = portablizeRuleset(normalized, opts); const json = JSON.stringify(portable); expect(json).toContain("1246"); // unmanaged group id survives numeric - expect(warnings).toEqual([{ var: "ctgroup.id", id: 1246 }]); // exactly the one escape-hatch id - // groupStatusId oneof ([1]) is never a candidate → never rewritten, never warned. - expect(warnings.some((w) => w.id === 1)).toBe(false); + expect(warnings).toContainEqual({ + var: "ctgroup.id", + id: 1246, + reason: "unmanaged", + detail: "not under management — `ct adopt group ` for each (then re-adopt) makes them portable", + }); + // groupStatusId is never REWRITTEN (no catalog exists) but is still a host-specific id in a + // cross-host file, so #101 reports it rather than letting the capture look fully portable. + expect( + warnings.filter((w) => w.var === "ctgroup.groupStatusId").every((w) => w.reason === "no-ref-kind"), + ).toBe(true); + expect(warnings.some((w) => w.var === "ctgroup.groupStatusId")).toBe(true); + }); + + it("scanUnportablized reports the same ids from the ALREADY-PORTABLIZED file (#101 plan-time check)", () => { + const { ruleset: portable } = portablizeRuleset(normalized, opts); + const left = scanUnportablized(portable); + // Every marker-rewritten id is gone from the report; the numeric leftovers remain. + expect(left.some((w) => w.var === "ctgroup.id" && w.id === 1246)).toBe(true); + expect(left.some((w) => w.id === 112 || w.id === 8)).toBe(false); // now `{ __ctRef }` markers + expect(formatPortablizeWarnings(left).some((l) => l.startsWith("ctgroup.id: 1246 left numeric"))).toBe( + true, + ); + }); + + // The scan has no state, no catalogs and no network, so it can prove POSITION and nothing else. + // Reporting "unmanaged" from here told someone whose group IS adopted that it is not under + // management — on every plan — and handed them a `ct adopt` that fails because it already is. + it("reports only what position proves — never an unchecked 'unmanaged'/'role-unknown' verdict", () => { + const left = scanUnportablized(normalized); + expect(left.some((w) => w.var === "ctgroup.id")).toBe(true); + expect(left.filter((w) => w.var === "ctgroup.id").every((w) => w.reason === "left-numeric")).toBe(true); + expect(left.filter((w) => w.var === "role.id").every((w) => w.reason === "left-numeric")).toBe(true); + expect(left.some((w) => /is not under management|no \/group\/roles row/.test(w.detail))).toBe(false); + // The catalog-less dimension keeps its own reason: that one IS derivable without any lookup. + expect( + left.filter((w) => w.var === "ctgroup.groupStatusId").every((w) => w.reason === "no-ref-kind"), + ).toBe(true); }); }); }); diff --git a/tests/query.test.ts b/tests/query.test.ts index 3e6aee5..1f85b2b 100644 --- a/tests/query.test.ts +++ b/tests/query.test.ts @@ -5,14 +5,15 @@ import { normalizeRuleset } from "../src/engine/dynamic.js"; describe("typed query builder", () => { it("builds a JSONLogic tree", () => { const tree = q.and(q.eq("ctgroup.campusId", 1), q.eq("person.isArchived", false)); - expect(tree).toEqual({ and: [ - { "==": [{ var: "ctgroup.campusId" }, 1] }, - { "==": [{ var: "person.isArchived" }, false] }, - ] }); + expect(tree).toEqual({ + and: [{ "==": [{ var: "ctgroup.campusId" }, 1] }, { "==": [{ var: "person.isArchived" }, false] }], + }); }); it("oneof and isnull", () => { - expect(q.oneof("ctgroup.groupTypeId", [1, 2])).toEqual({ oneof: [{ var: "ctgroup.groupTypeId" }, [1, 2]] }); + expect(q.oneof("ctgroup.groupTypeId", [1, 2])).toEqual({ + oneof: [{ var: "ctgroup.groupTypeId" }, [1, 2]], + }); expect(q.isnull("person.isArchived")).toEqual({ isnull: [{ var: "person.isArchived" }] }); }); @@ -58,8 +59,13 @@ describe("typed query builder", () => { }); it("a built ruleset normalizes stably (matches read-back normalization)", () => { - const ruleset = { description: "x", importance: 0, personIdFieldName: "id", - process: {}, query: churchQuery(q.eq("ctgroup.campusId", 1)) }; + const ruleset = { + description: "x", + importance: 0, + personIdFieldName: "id", + process: {}, + query: churchQuery(q.eq("ctgroup.campusId", 1)), + }; expect(normalizeRuleset(normalizeRuleset(ruleset))).toEqual(normalizeRuleset(ruleset)); }); diff --git a/tests/refresh-command.test.ts b/tests/refresh-command.test.ts new file mode 100644 index 0000000..4cf4849 --- /dev/null +++ b/tests/refresh-command.test.ts @@ -0,0 +1,112 @@ +/** + * `ct refresh` (#105). + * + * `ct apply` writes the ruleset and flips the status; ChurchTools materializes membership on its own + * schedule — so a freshly created auto-group is legitimately empty after a green apply. `--refresh` + * only covers groups CHANGED in that run, so it cannot re-evaluate an existing group and does nothing + * on a no-op plan. These tests pin the lever that closes that gap, and the guardrails on it. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { State } from "../src/state/state.js"; + +const requestMock = vi.fn(async (...args: [string, string]) => { + void args; + return [{ created: 3, updated: 1, deleted: 0 }]; +}); +const getAllMock = vi.fn(async () => ({ data: [{ id: 10 }, { id: 12 }] })); + +vi.mock("../src/api/session.js", () => ({ + authedSession: vi.fn(async () => ({ client: { request: requestMock, getAll: getAllMock }, me: { id: 1 } })), +})); + +const { refreshCommand } = await import("../src/commands/refresh.js"); + +const HOST = "https://mychurch.church.tools"; +const originalHost = process.env.CT_HOST; +let workDir: string; +let statePath: string; + +const state: State = { + version: 1, + host: HOST, + resources: { + auto_a: { type: "group", id: 10, key: "auto_a", fields: {}, adoptedAt: "t", updatedAt: "t" }, + auto_b: { type: "group", id: 12, key: "auto_b", fields: {}, adoptedAt: "t", updatedAt: "t" }, + plain_c: { type: "group", id: 13, key: "plain_c", fields: {}, adoptedAt: "t", updatedAt: "t" }, + a_campus: { type: "campus", id: 0, key: "a_campus", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, +}; + +async function run(args: string[]): Promise { + await refreshCommand().parseAsync([...args, "--state", statePath], { from: "user" }); +} + +beforeEach(() => { + requestMock.mockClear(); + getAllMock.mockClear(); + process.env.CT_HOST = HOST; + workDir = mkdtempSync(join(tmpdir(), "ct-refresh-")); + statePath = join(workDir, "ct-state.json"); + writeFileSync(statePath, JSON.stringify(state), "utf8"); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (originalHost === undefined) delete process.env.CT_HOST; + else process.env.CT_HOST = originalHost; + rmSync(workDir, { recursive: true, force: true }); + process.exitCode = 0; +}); + +describe("ct refresh (#105)", () => { + it("refreshes one named group via the PER-GROUP endpoint", async () => { + await run(["--group", "auto_a"]); + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith("POST", "/dynamicgroups/10/refresh"); + }); + + it("never fans out without --all — refreshing recomputes membership", async () => { + await expect(run([])).rejects.toThrow(/Specify --group for one group, or --all/); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it("--all refreshes every MANAGED dynamic group and nothing else", async () => { + await run(["--all"]); + expect(requestMock.mock.calls.map((c) => c[1])).toEqual([ + "/dynamicgroups/10/refresh", + "/dynamicgroups/12/refresh", + ]); + // plain_c is managed but not an auto-group; a_campus is not a group at all. + }); + + it("never touches the all-groups endpoint (blast radius) or the legacy scheduler ping", async () => { + await run(["--all"]); + const paths = requestMock.mock.calls.map((c) => String(c[1])); + expect(paths).not.toContain("/dynamicgroups/refresh"); + expect(paths.some((p) => p.includes("cron"))).toBe(false); + }); + + it("refuses a managed group that has no ruleset, instead of POSTing into a 404", async () => { + await expect(run(["--group", "plain_c"])).rejects.toThrow(/is not a dynamic group on this host/); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it("refuses an unmanaged key — ct only ever refreshes what the config owns", async () => { + await expect(run(["--group", "nope"])).rejects.toThrow(/not a managed group in this state file/); + }); + + it("rejects --group together with --all", async () => { + await expect(run(["--group", "auto_a", "--all"])).rejects.toThrow(/only one of: --group, --all/); + }); + + it("keeps going after one failure and exits non-zero", async () => { + requestMock.mockRejectedValueOnce(new Error("boom")); + await run(["--all"]); + expect(requestMock).toHaveBeenCalledTimes(2); // the second group still ran + expect(process.exitCode).toBe(1); + }); +}); diff --git a/tests/refs.test.ts b/tests/refs.test.ts index 81bc5fc..aeef5ec 100644 --- a/tests/refs.test.ts +++ b/tests/refs.test.ts @@ -18,7 +18,12 @@ describe("ref helper", () => { expect(ref.status("active")).toEqual({ __ctRef: true, kind: "group-status", key: "active" }); expect(ref.roleDef("leiter")).toEqual({ __ctRef: true, kind: "role-def", key: "leiter" }); expect(ref.group("g")).toEqual({ __ctRef: true, kind: "group", key: "g" }); - expect(ref.groupRole("g", "Leiter")).toEqual({ __ctRef: true, kind: "group-role", group: "g", role: "Leiter" }); + expect(ref.groupRole("g", "Leiter")).toEqual({ + __ctRef: true, + kind: "group-role", + group: "g", + role: "Leiter", + }); }); it("rejects an empty/non-string key", () => { diff --git a/tests/registry.test.ts b/tests/registry.test.ts index fa0c4ff..4a21e16 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -233,9 +233,9 @@ describe("createDefaults — required-but-unmanaged create fields (#73)", () => }); it("group-role fills the required `shorty` from the declared name (truncated to 10)", () => { - expect( - RESOURCES["group-role"]?.createDefaults?.({ name: "Verantwortlicher", groupTypeId: 2 }), - ).toEqual({ shorty: "Verantwort" }); + expect(RESOURCES["group-role"]?.createDefaults?.({ name: "Verantwortlicher", groupTypeId: 2 })).toEqual({ + shorty: "Verantwort", + }); }); it("pads a 1-char group-type name's namePlural up to CT's 2-char minimum", () => { diff --git a/tests/resolver.test.ts b/tests/resolver.test.ts index e425c9b..16b3659 100644 --- a/tests/resolver.test.ts +++ b/tests/resolver.test.ts @@ -36,7 +36,12 @@ describe("Resolver.resolve", () => { }); it("resolves a campus from the live catalog by slug(name)", async () => { - const client = fakeClient({ "/campuses": [{ id: 3, name: "Berlin", shorty: "BE" }, { id: 5, name: "Mainz" }] }); + const client = fakeClient({ + "/campuses": [ + { id: 3, name: "Berlin", shorty: "BE" }, + { id: 5, name: "Mainz" }, + ], + }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); expect(await r.resolve(ref.campus("mainz"), "site")).toBe(5); }); @@ -75,7 +80,12 @@ describe("Resolver.resolve", () => { // /group/memberstatus IS mocked here (as a member-statuses catalog would be on a live host), to // prove the resolver never even looks at it for a group-status ref — group statuses have no // REST catalog to resolve against (a different, unrelated dimension from member statuses). - const client = fakeClient({ "/group/memberstatus": [{ id: 1, name: "Active" }, { id: 2, name: "Candidate" }] }); + const client = fakeClient({ + "/group/memberstatus": [ + { id: 1, name: "Active" }, + { id: 2, name: "Candidate" }, + ], + }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); await expect(r.resolve(ref.status("candidate"), "site")).rejects.toThrow( /Cannot resolve group-status:candidate referenced at site on hostA/, @@ -91,7 +101,7 @@ describe("Resolver.resolve", () => { // SAME message context.ts's eval-time guard uses, not the generic one. const client = fakeClient({}); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); - await expect(r.resolve(ref.status("candidate"), "group \"g\".groupStatusId")).rejects.toThrow( + await expect(r.resolve(ref.status("candidate"), 'group "g".groupStatusId')).rejects.toThrow( 'Cannot resolve group-status:candidate referenced at group "g".groupStatusId on hostA: group statuses ' + "have no REST catalog (GET /group/memberstatus is a different dimension: member statuses, string ids " + '— verified 2026-07-10). Declare a numeric "groupStatusId" instead (e.g. "groupStatusId: 1").', @@ -108,9 +118,14 @@ describe("Resolver.resolve", () => { }); it("throws listing candidates on an ambiguous catalog match", async () => { - const client = fakeClient({ "/campuses": [{ id: 1, name: "Mainz" }, { id: 2, name: "Mainz" }] }); + const client = fakeClient({ + "/campuses": [ + { id: 1, name: "Mainz" }, + { id: 2, name: "Mainz" }, + ], + }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); - await expect(r.resolve(ref.campus("mainz"), "group \"g\"")).rejects.toThrow( + await expect(r.resolve(ref.campus("mainz"), 'group "g"')).rejects.toThrow( /Ambiguous campus:mainz referenced at group "g" on hostA: 2 live campuss match/, ); }); @@ -118,7 +133,7 @@ describe("Resolver.resolve", () => { it("throws a clear error on an unknown reference (kind + key + site + host)", async () => { const client = fakeClient({ "/campuses": [{ id: 1, name: "Berlin" }] }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostB" }); - await expect(r.resolve(ref.campus("mainz"), "group \"g\".campusId")).rejects.toThrow( + await expect(r.resolve(ref.campus("mainz"), 'group "g".campusId')).rejects.toThrow( /Cannot resolve campus:mainz referenced at group "g".campusId on hostB/, ); }); @@ -128,12 +143,15 @@ describe("Resolver.resolve", () => { kids: { type: "group", id: 42, key: "kids", fields: {}, adoptedAt: "t", updatedAt: "t" }, }); const client = fakeClient({ - "/groups/42/roles": [{ id: 2882, name: "Leiter" }, { id: 2883, name: "Mitglied" }], + "/groups/42/roles": [ + { id: 2882, name: "Leiter" }, + { id: 2883, name: "Mitglied" }, + ], }); const r = new Resolver({ client, state, desired: NO_DESIRED }); // slug("Leiter") === "leiter", so either the slug key or the exact name resolves. - expect(await r.resolve(ref.groupRole("kids", "leiter"), "perm \"p\"")).toBe(2882); - expect(await r.resolve(ref.groupRole("kids", "Mitglied"), "perm \"p\"")).toBe(2883); + expect(await r.resolve(ref.groupRole("kids", "leiter"), 'perm "p"')).toBe(2882); + expect(await r.resolve(ref.groupRole("kids", "Mitglied"), 'perm "p"')).toBe(2883); expect(client.calls["/groups/42/roles"]).toBe(1); // fetched once, cached across both refs }); @@ -143,7 +161,7 @@ describe("Resolver.resolve", () => { }); const client = fakeClient({ "/groups/42/roles": [{ id: 2882, name: "Leiter" }] }); const r = new Resolver({ client, state, desired: NO_DESIRED, host: "hostA" }); - await expect(r.resolve(ref.groupRole("kids", "Ghost"), "perm \"p\"")).rejects.toThrow( + await expect(r.resolve(ref.groupRole("kids", "Ghost"), 'perm "p"')).rejects.toThrow( /group #42 has no role named "Ghost".*available: "Leiter".*pass a numeric id/is, ); }); @@ -151,7 +169,7 @@ describe("Resolver.resolve", () => { it("errors when a group_role names a group that isn't managed", async () => { const client = fakeClient({}); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); - await expect(r.resolve(ref.groupRole("ghost", "Leiter"), "perm \"p\"")).rejects.toThrow( + await expect(r.resolve(ref.groupRole("ghost", "Leiter"), 'perm "p"')).rejects.toThrow( /no managed group named "ghost".*pass a numeric id/is, ); }); @@ -160,7 +178,7 @@ describe("Resolver.resolve", () => { const desired: DesiredResource[] = [{ type: "group", key: "kids", fields: {}, dependsOn: [] }]; const client = fakeClient({}); const r = new Resolver({ client, state: emptyState("h"), desired }); - await expect(r.resolve(ref.groupRole("kids", "Leiter"), "perm \"p\"")).rejects.toThrow( + await expect(r.resolve(ref.groupRole("kids", "Leiter"), 'perm "p"')).rejects.toThrow( /declared in this config but not yet created.*Apply the group first/is, ); }); @@ -189,7 +207,10 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { { id: 16, name: "Leiter", groupTypeId: 2 }, // SAME name as #84, different group type { id: 17, name: "Organisator", groupTypeId: 2 }, ]; - const groupTypesCatalog = [{ id: 12, name: "Local Lead" }, { id: 2, name: "Team" }]; + const groupTypesCatalog = [ + { id: 12, name: "Local Lead" }, + { id: 2, name: "Team" }, + ]; it("resolves a (group-type, role) pair to its groupTypeRoleId, disambiguating same-named roles", async () => { const client = fakeClient({ "/group/grouptypes": groupTypesCatalog, "/group/roles": rolesCatalog }); @@ -204,7 +225,14 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { it("resolves the group-type half from managed state (no group-type catalog fetch)", async () => { const state = stateWith({ - local_lead: { type: "group-type", id: 12, key: "local_lead", fields: {}, adoptedAt: "t", updatedAt: "t" }, + local_lead: { + type: "group-type", + id: 12, + key: "local_lead", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }, }); const client = fakeClient({ "/group/roles": rolesCatalog }); const r = new Resolver({ client, state, desired: NO_DESIRED }); @@ -215,7 +243,7 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { it("errors clearly when no role of that name exists on the group type (lists candidates)", async () => { const client = fakeClient({ "/group/grouptypes": groupTypesCatalog, "/group/roles": rolesCatalog }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); - await expect(r.resolve(ref.groupTypeRole("team", "Ghost"), "ruleset \"r\"")).rejects.toThrow( + await expect(r.resolve(ref.groupTypeRole("team", "Ghost"), 'ruleset "r"')).rejects.toThrow( /group-type-role\(groupType=team, role=Ghost\) referenced at ruleset "r" on hostA: group type #2 has no role named "Ghost".*available: "Leiter", "Organisator".*pass a numeric id/is, ); }); @@ -254,11 +282,21 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { describe("Resolver.resolveValue", () => { it("deep-rewrites refs to ids and fetches each catalog at most once", async () => { - const client = fakeClient({ "/campuses": [{ id: 5, name: "Mainz" }, { id: 6, name: "Berlin" }] }); + const client = fakeClient({ + "/campuses": [ + { id: 5, name: "Mainz" }, + { id: 6, name: "Berlin" }, + ], + }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); const value = { campusId: ref.campus("mainz"), - query: { or: [{ "==": [{ var: "ctgroup.campusId" }, ref.campus("mainz")] }, { "==": [{ var: "ctgroup.campusId" }, ref.campus("berlin")] }] }, + query: { + or: [ + { "==": [{ var: "ctgroup.campusId" }, ref.campus("mainz")] }, + { "==": [{ var: "ctgroup.campusId" }, ref.campus("berlin")] }, + ], + }, untouched: 42, }; const out = await r.resolveValue(value, "site"); @@ -323,7 +361,7 @@ describe("catalogs are read PAGINATED (#99 review)", () => { ], }); const r = new Resolver({ client, state, desired: NO_DESIRED }); - expect(await r.resolve(ref.groupRole("kids", "Leiter"), "perm \"p\"")).toBe(2882); + expect(await r.resolve(ref.groupRole("kids", "Leiter"), 'perm "p"')).toBe(2882); }); }); @@ -342,6 +380,9 @@ describe("reresolvePendingValue", () => { }); it("passes non-pending values through untouched", () => { - expect(reresolvePendingValue({ campusId: 4, n: [1, 2] }, emptyState("h"))).toEqual({ campusId: 4, n: [1, 2] }); + expect(reresolvePendingValue({ campusId: 4, n: [1, 2] }, emptyState("h"))).toEqual({ + campusId: 4, + n: [1, 2], + }); }); }); diff --git a/tests/session.test.ts b/tests/session.test.ts index 55fde7b..433f5fc 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -46,9 +46,7 @@ describe("authedSession host↔token binding (issue #30)", () => { readCredentials.mockResolvedValue({ host: "https://prod.church.tools", token: "prod-secret" }); process.env.CT_HOST = "https://staging.church.tools"; - await expect(authedSession()).rejects.toThrow( - /ct auth login --host https:\/\/staging\.church\.tools/, - ); + await expect(authedSession()).rejects.toThrow(/ct auth login --host https:\/\/staging\.church\.tools/); }); it("tolerates a trailing slash mismatch (host is normalized before comparison)", async () => { diff --git a/tests/synthetic-dynamic.test.ts b/tests/synthetic-dynamic.test.ts index 4507e4b..40f5b9c 100644 --- a/tests/synthetic-dynamic.test.ts +++ b/tests/synthetic-dynamic.test.ts @@ -17,32 +17,76 @@ const getClient = (client: unknown) => client as unknown as Pick { it("injects normalized dynamic into desired.fields and actual for an opted-in managed group", async () => { expect(SYNTHETIC_FIELDS.some((f) => f.field === "dynamic")).toBe(true); - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const desired: DesiredResource[] = [ - { type: "group", key: "g", fields: { name: "G" }, dependsOn: [], - dynamic: { status: "manual", ruleset: { description: "x", query: {}, process: {} } } }, + { + type: "group", + key: "g", + fields: { name: "G" }, + dependsOn: [], + dynamic: { status: "manual", ruleset: { description: "x", query: {}, process: {} } }, + }, ]; - const client = { get: vi.fn(async (p: string) => - p.endsWith("/ruleset") ? { description: "x", query: {}, process: {}, dynamicGroupUpdateStarted: "t" } - : { dynamicGroupStatus: "manual" }) }; - const out = await dynamicField().fold({ client: client as unknown as Pick, state, desired, actual }); + const client = { + get: vi.fn(async (p: string) => + p.endsWith("/ruleset") + ? { description: "x", query: {}, process: {}, dynamicGroupUpdateStarted: "t" } + : { dynamicGroupStatus: "manual" }, + ), + }; + const out = await dynamicField().fold({ + client: client as unknown as Pick, + state, + desired, + actual, + }); expect(out.errors).toEqual([]); - expect(actual.get("g")?.dynamic).toEqual({ status: "manual", ruleset: { description: "x", query: {}, process: {} } }); - expect(out.desired[0]?.fields.dynamic).toEqual({ status: "manual", ruleset: { description: "x", query: {}, process: {} } }); + expect(actual.get("g")?.dynamic).toEqual({ + status: "manual", + ruleset: { description: "x", query: {}, process: {} }, + }); + expect(out.desired[0]?.fields.dynamic).toEqual({ + status: "manual", + ruleset: { description: "x", query: {}, process: {} }, + }); }); it("tolerates a 404 on the ruleset fetch — group is not (yet) a dynamic group", async () => { - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const desired: DesiredResource[] = [ - { type: "group", key: "g", fields: { name: "G" }, dependsOn: [], - dynamic: { status: "active", ruleset: { description: "x", query: {}, process: {} } } }, + { + type: "group", + key: "g", + fields: { name: "G" }, + dependsOn: [], + dynamic: { status: "active", ruleset: { description: "x", query: {}, process: {} } }, + }, ]; - const client = { get: vi.fn(async () => { throw new CtApiError("Not Found", 404, null); }) }; - const out = await dynamicField().fold({ client: client as unknown as Pick, state, desired, actual }); + const client = { + get: vi.fn(async () => { + throw new CtApiError("Not Found", 404, null); + }), + }; + const out = await dynamicField().fold({ + client: client as unknown as Pick, + state, + desired, + actual, + }); expect(out.errors).toEqual([]); expect(actual.get("g")?.dynamic).toEqual({ status: "none", ruleset: {} }); }); @@ -55,17 +99,29 @@ describe("dynamic synthetic field — fold", () => { // FIX 5: the ruleset GET succeeded, so this group HAS a real ruleset. A subsequent status-GET // failure must NOT be swallowed into { status: "none", ruleset: {} } (which would discard the // ruleset and propose a spurious re-PUT) — it degrades the plan via `errors`. - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const desired: DesiredResource[] = [ - { type: "group", key: "g", fields: { name: "G" }, dependsOn: [], - dynamic: { status: "active", ruleset: { description: "x", query: {}, process: {} } } }, + { + type: "group", + key: "g", + fields: { name: "G" }, + dependsOn: [], + dynamic: { status: "active", ruleset: { description: "x", query: {}, process: {} } }, + }, ]; - const client = { get: vi.fn(async (p: string) => { - if (p.endsWith("/ruleset")) return { description: "x", query: {}, process: {} }; - throw new CtApiError("Server Error", 500, null); // status GET fails AFTER a good ruleset GET - }) }; + const client = { + get: vi.fn(async (p: string) => { + if (p.endsWith("/ruleset")) return { description: "x", query: {}, process: {} }; + throw new CtApiError("Server Error", 500, null); // status GET fails AFTER a good ruleset GET + }), + }; const out = await dynamicField().fold({ client: getClient(client), state, desired, actual }); expect(out.errors).toHaveLength(1); expect(out.errors[0]).toMatch(/dynamic g status \(#5\)/); @@ -76,56 +132,100 @@ describe("dynamic synthetic field — fold", () => { // docs/handbuch/dynamic-groups.md tells users to KEEP the dynamic block when demoting, so the authored // ruleset is still present with status "none". The actual side is the { status:"none", ruleset:{} } // sentinel — folding the full ruleset would diff forever. Both must collapse to the same sentinel. - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const desired: DesiredResource[] = [ - { type: "group", key: "g", fields: { name: "G" }, dependsOn: [], - dynamic: { status: "none", ruleset: { description: "kept", query: { "==": [{ var: "x" }, "1"] }, process: {} } } }, + { + type: "group", + key: "g", + fields: { name: "G" }, + dependsOn: [], + dynamic: { + status: "none", + ruleset: { description: "kept", query: { "==": [{ var: "x" }, "1"] }, process: {} }, + }, + }, ]; // Group is already non-dynamic in CT → ruleset GET 404s → actual sentinel. - const client = { get: vi.fn(async () => { throw new CtApiError("Not Found", 404, null); }) }; + const client = { + get: vi.fn(async () => { + throw new CtApiError("Not Found", 404, null); + }), + }; const out = await dynamicField().fold({ client: getClient(client), state, desired, actual }); expect(out.errors).toEqual([]); const sentinel = { status: "none", ruleset: {} }; - expect(actual.get("g")?.dynamic).toEqual(sentinel); // actual side sentinel - expect(out.desired[0]?.fields.dynamic).toEqual(sentinel); // desired folds to the SAME sentinel + expect(actual.get("g")?.dynamic).toEqual(sentinel); // actual side sentinel + expect(out.desired[0]?.fields.dynamic).toEqual(sentinel); // desired folds to the SAME sentinel // Second plan is a no-op: the two sides are deep-equal, so diffFields reports no dynamic change. - expect(diffFields(out.desired[0]!.fields, actual.get("g")!).find((c) => c.field === "dynamic")).toBeUndefined(); + expect( + diffFields(out.desired[0]!.fields, actual.get("g")!).find((c) => c.field === "dynamic"), + ).toBeUndefined(); }); it("folds many opted-in groups concurrently, each with its own (ruleset, status) pair (#35 item 1)", async () => { // Behavior must be identical to the old serial loop: every opted-in managed group gets its actual // dynamic filled from its own ruleset+status GETs, keyed by group id. - const state: State = { version: 1, host: "h", resources: { - g1: { type: "group", id: 1, key: "g1", fields: { name: "G1" }, adoptedAt: "t", updatedAt: "t" }, - g2: { type: "group", id: 2, key: "g2", fields: { name: "G2" }, adoptedAt: "t", updatedAt: "t" }, - g3: { type: "group", id: 3, key: "g3", fields: { name: "G3" }, adoptedAt: "t", updatedAt: "t" }, - } }; + const state: State = { + version: 1, + host: "h", + resources: { + g1: { type: "group", id: 1, key: "g1", fields: { name: "G1" }, adoptedAt: "t", updatedAt: "t" }, + g2: { type: "group", id: 2, key: "g2", fields: { name: "G2" }, adoptedAt: "t", updatedAt: "t" }, + g3: { type: "group", id: 3, key: "g3", fields: { name: "G3" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([ - ["g1", { name: "G1" }], ["g2", { name: "G2" }], ["g3", { name: "G3" }], + ["g1", { name: "G1" }], + ["g2", { name: "G2" }], + ["g3", { name: "G3" }], ]); const desired: DesiredResource[] = ["g1", "g2", "g3"].map((key) => ({ - type: "group", key, fields: { name: key.toUpperCase() }, dependsOn: [], + type: "group", + key, + fields: { name: key.toUpperCase() }, + dependsOn: [], dynamic: { status: "manual", ruleset: { description: key, query: {}, process: {} } }, })); - const client = { get: vi.fn(async (p: string) => { - const id = p.match(/dynamicgroups\/(\d+)\//)![1]; - return p.endsWith("/ruleset") - ? { description: `rs${id}`, query: {}, process: {} } - : { dynamicGroupStatus: "manual" }; - }) }; + const client = { + get: vi.fn(async (p: string) => { + const id = p.match(/dynamicgroups\/(\d+)\//)![1]; + return p.endsWith("/ruleset") + ? { description: `rs${id}`, query: {}, process: {} } + : { dynamicGroupStatus: "manual" }; + }), + }; const out = await dynamicField().fold({ client: getClient(client), state, desired, actual }); expect(out.errors).toEqual([]); - expect(actual.get("g1")?.dynamic).toEqual({ status: "manual", ruleset: { description: "rs1", query: {}, process: {} } }); - expect(actual.get("g2")?.dynamic).toEqual({ status: "manual", ruleset: { description: "rs2", query: {}, process: {} } }); - expect(actual.get("g3")?.dynamic).toEqual({ status: "manual", ruleset: { description: "rs3", query: {}, process: {} } }); + expect(actual.get("g1")?.dynamic).toEqual({ + status: "manual", + ruleset: { description: "rs1", query: {}, process: {} }, + }); + expect(actual.get("g2")?.dynamic).toEqual({ + status: "manual", + ruleset: { description: "rs2", query: {}, process: {} }, + }); + expect(actual.get("g3")?.dynamic).toEqual({ + status: "manual", + ruleset: { description: "rs3", query: {}, process: {} }, + }); expect(client.get).toHaveBeenCalledTimes(6); // ruleset + status per group }); it("ignores groups that did not opt into dynamic", async () => { - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const desired: DesiredResource[] = [{ type: "group", key: "g", fields: { name: "G" }, dependsOn: [] }]; const client = { get: vi.fn() }; @@ -139,47 +239,84 @@ describe("dynamic synthetic field — apply", () => { it("PUTs the ruleset wrapped in the [RuleSet] array envelope (#77), then the status", async () => { const request = vi.fn(async () => ({})); const state: State = { version: 1, host: "h", resources: {} }; - await dynamicField().apply({ client: { request } as unknown as Pick, state, id: 5, - change: { field: "dynamic", from: undefined, - to: { status: "active", ruleset: { description: "x", query: {}, process: {} } } } }); + await dynamicField().apply({ + client: { request } as unknown as Pick, + state, + id: 5, + change: { + field: "dynamic", + from: undefined, + to: { status: "active", ruleset: { description: "x", query: {}, process: {} } }, + }, + }); // PUT envelope: `{ dynamicGroupRuleSet: [ruleset] }` — object root, array property (live-decoded, #77). - expect(request).toHaveBeenNthCalledWith(1, "PUT", "/dynamicgroups/5/ruleset", - { dynamicGroupRuleSet: [{ description: "x", query: {}, process: {} }] }); - expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "active" }); + expect(request).toHaveBeenNthCalledWith(1, "PUT", "/dynamicgroups/5/ruleset", { + dynamicGroupRuleSet: [{ description: "x", query: {}, process: {} }], + }); + expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { + dynamicGroupStatus: "active", + }); }); it("status-only change (ruleset byte-identical) PUTs only the status, skipping the ruleset re-PUT (#35 item 15)", async () => { const request = vi.fn(async () => ({})); const state: State = { version: 1, host: "h", resources: {} }; const ruleset = { description: "x", query: {}, process: {} }; - await dynamicField().apply({ client: { request } as unknown as Pick, state, id: 5, - change: { field: "dynamic", + await dynamicField().apply({ + client: { request } as unknown as Pick, + state, + id: 5, + change: { + field: "dynamic", from: { status: "active", ruleset }, // same ruleset, only the status flips - to: { status: "inactive", ruleset } } }); + to: { status: "inactive", ruleset }, + }, + }); expect(request).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledWith("PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "inactive" }); + expect(request).toHaveBeenCalledWith("PUT", "/dynamicgroups/5/status", { + dynamicGroupStatus: "inactive", + }); expect(request).not.toHaveBeenCalledWith("PUT", "/dynamicgroups/5/ruleset", expect.anything()); }); it("still PUTs both when the ruleset changed alongside the status", async () => { const request = vi.fn(async () => ({})); const state: State = { version: 1, host: "h", resources: {} }; - await dynamicField().apply({ client: { request } as unknown as Pick, state, id: 5, - change: { field: "dynamic", + await dynamicField().apply({ + client: { request } as unknown as Pick, + state, + id: 5, + change: { + field: "dynamic", from: { status: "active", ruleset: { description: "old", query: {}, process: {} } }, - to: { status: "active", ruleset: { description: "new", query: {}, process: {} } } } }); - expect(request).toHaveBeenNthCalledWith(1, "PUT", "/dynamicgroups/5/ruleset", - { dynamicGroupRuleSet: [{ description: "new", query: {}, process: {} }] }); - expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "active" }); + to: { status: "active", ruleset: { description: "new", query: {}, process: {} } }, + }, + }); + expect(request).toHaveBeenNthCalledWith(1, "PUT", "/dynamicgroups/5/ruleset", { + dynamicGroupRuleSet: [{ description: "new", query: {}, process: {} }], + }); + expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { + dynamicGroupStatus: "active", + }); }); it("demotes to a normal group when status is none: DELETE ruleset then status none", async () => { const request = vi.fn(async () => ({})); const state: State = { version: 1, host: "h", resources: {} }; - await dynamicField().apply({ client: { request } as unknown as Pick, state, id: 5, - change: { field: "dynamic", from: { status: "active", ruleset: {} }, to: { status: "none", ruleset: {} } } }); + await dynamicField().apply({ + client: { request } as unknown as Pick, + state, + id: 5, + change: { + field: "dynamic", + from: { status: "active", ruleset: {} }, + to: { status: "none", ruleset: {} }, + }, + }); expect(request).toHaveBeenNthCalledWith(1, "DELETE", "/dynamicgroups/5/ruleset"); - expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "none" }); + expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { + dynamicGroupStatus: "none", + }); }); it("tolerates a 404 on the demote DELETE (never-dynamic / already-demoted group) and still PUTs status none", async () => { @@ -189,11 +326,17 @@ describe("dynamic synthetic field — apply", () => { }); const state: State = { version: 1, host: "h", resources: {} }; await expect( - dynamicField().apply({ client: { request } as unknown as Pick, state, id: 5, - change: { field: "dynamic", from: undefined, to: { status: "none", ruleset: {} } } }), + dynamicField().apply({ + client: { request } as unknown as Pick, + state, + id: 5, + change: { field: "dynamic", from: undefined, to: { status: "none", ruleset: {} } }, + }), ).resolves.toBeUndefined(); // 404 swallowed — apply does not abort expect(request).toHaveBeenNthCalledWith(1, "DELETE", "/dynamicgroups/5/ruleset"); - expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "none" }); + expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { + dynamicGroupStatus: "none", + }); }); it("re-throws a non-404 error on the demote DELETE (real failures still abort)", async () => { @@ -203,8 +346,12 @@ describe("dynamic synthetic field — apply", () => { }); const state: State = { version: 1, host: "h", resources: {} }; await expect( - dynamicField().apply({ client: { request } as unknown as Pick, state, id: 5, - change: { field: "dynamic", from: undefined, to: { status: "none", ruleset: {} } } }), + dynamicField().apply({ + client: { request } as unknown as Pick, + state, + id: 5, + change: { field: "dynamic", from: undefined, to: { status: "none", ruleset: {} } }, + }), ).rejects.toThrow(/Server Error/); }); }); @@ -213,7 +360,11 @@ describe("resolveRulesetRef", () => { it("resolves { ref } relative to the given baseDir (config dir), not the process cwd", () => { const dir = mkdtempSync(join(tmpdir(), "ct-ref-")); writeFileSync(join(dir, "rs.json"), JSON.stringify({ description: "from-file", query: {}, process: {} })); - expect(resolveRulesetRef({ ref: "./rs.json" }, dir)).toEqual({ description: "from-file", query: {}, process: {} }); + expect(resolveRulesetRef({ ref: "./rs.json" }, dir)).toEqual({ + description: "from-file", + query: {}, + process: {}, + }); }); it("passes an inline ruleset through unchanged", () => { @@ -222,8 +373,9 @@ describe("resolveRulesetRef", () => { it("throws a clear error naming the group and resolved path when the ref file is missing", () => { const dir = mkdtempSync(join(tmpdir(), "ct-ref-")); - expect(() => resolveRulesetRef({ ref: "./missing.json" }, dir, "all_mainz")) - .toThrow(/group "all_mainz".*cannot read.*missing\.json/is); + expect(() => resolveRulesetRef({ ref: "./missing.json" }, dir, "all_mainz")).toThrow( + /group "all_mainz".*cannot read.*missing\.json/is, + ); }); it("throws a clear error when the ref file is not valid JSON", () => { @@ -243,26 +395,133 @@ describe("dynamic { ref } ruleset — resolved relative to the config file", () ); return dir; }; - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: {}, adoptedAt: "t", updatedAt: "t" } } }; - const foldClient = () => ({ get: vi.fn(async (p: string) => - p.endsWith("/ruleset") ? { description: "actual", query: {}, process: {} } : { dynamicGroupStatus: "manual" }) }); + const state: State = { + version: 1, + host: "h", + resources: { g: { type: "group", id: 5, key: "g", fields: {}, adoptedAt: "t", updatedAt: "t" } }, + }; + const foldClient = () => ({ + get: vi.fn(async (p: string) => + p.endsWith("/ruleset") + ? { description: "actual", query: {}, process: {} } + : { dynamicGroupStatus: "manual" }, + ), + }); it("threads the config dir so { ref } resolves against the config file, not the cwd", async () => { const dir = mkConfig(`{ ref: "./rules.json" }`); - writeFileSync(join(dir, "rules.json"), JSON.stringify({ description: "from-ref", importance: 0, query: {}, process: {} })); + writeFileSync( + join(dir, "rules.json"), + JSON.stringify({ description: "from-ref", importance: 0, query: {}, process: {} }), + ); const { resources, configDir } = await loadConfig(join(dir, "ct.config.ts")); const actual = new Map>([["g", { name: "G" }]]); - const out = await foldSynthetic({ client: getClient(foldClient()), state, desired: resources, actual, configDir }); - expect(out.desired.find((d) => d.key === "g")?.fields.dynamic) - .toMatchObject({ status: "manual", ruleset: { description: "from-ref" } }); + const out = await foldSynthetic({ + client: getClient(foldClient()), + state, + desired: resources, + actual, + configDir, + }); + expect(out.desired.find((d) => d.key === "g")?.fields.dynamic).toMatchObject({ + status: "manual", + ruleset: { description: "from-ref" }, + }); }); it("surfaces a clear error (group + path) when the { ref } file is missing", async () => { const dir = mkConfig(`{ ref: "./missing.json" }`); const { resources, configDir } = await loadConfig(join(dir, "ct.config.ts")); const actual = new Map>([["g", { name: "G" }]]); - await expect(foldSynthetic({ client: getClient(foldClient()), state, desired: resources, actual, configDir })) - .rejects.toThrow(/group "g".*cannot read.*missing\.json/is); + await expect( + foldSynthetic({ client: getClient(foldClient()), state, desired: resources, actual, configDir }), + ).rejects.toThrow(/group "g".*cannot read.*missing\.json/is); + }); +}); + +describe("dynamic synthetic field — un-portablized ruleset reporting (#101)", () => { + /** + * A ruleset carrying another host's ids round-trips byte-identically against the host it was + * written for, so the plan is green and the auto-group quietly collects the wrong people on the + * other host. Plan time is the only place a config author sees this before it matters. + */ + it("warns at plan time, naming the dimension, the ids and why they stayed numeric", async () => { + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; + const actual = new Map>([["g", { name: "G" }]]); + const ruleset = { + description: "x", + query: { and: [{ oneof: [{ var: "ctgroup.id" }, [1246]] }] }, + process: {}, + }; + const desired: DesiredResource[] = [ + { + type: "group", + key: "g", + fields: { name: "G" }, + dependsOn: [], + dynamic: { status: "active", ruleset }, + }, + ]; + const client = { + get: vi.fn(async (p: string) => (p.endsWith("/ruleset") ? ruleset : { dynamicGroupStatus: "active" })), + }; + const errs: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((s) => { + errs.push(String(s)); + return true; + }); + try { + await dynamicField().fold({ client: getClient(client), state, desired, actual }); + } finally { + spy.mockRestore(); + } + const out = errs.join(""); + expect(out).toContain('dynamic group "g": ruleset carries 1 host-specific id(s)'); + expect(out).toMatch(/ctgroup\.id: 1246 left numeric/); + }); + + it("stays silent for a fully portable ruleset — the warning must mean something", async () => { + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; + const actual = new Map>([["g", { name: "G" }]]); + const ruleset = { + description: "x", + query: { and: [{ oneof: [{ var: "ctgroup.id" }, [{ __ctRef: true, kind: "group", key: "other" }]] }] }, + process: {}, + }; + const desired: DesiredResource[] = [ + { + type: "group", + key: "g", + fields: { name: "G" }, + dependsOn: [], + dynamic: { status: "active", ruleset }, + }, + ]; + const client = { + get: vi.fn(async (p: string) => (p.endsWith("/ruleset") ? ruleset : { dynamicGroupStatus: "active" })), + }; + const errs: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((s) => { + errs.push(String(s)); + return true; + }); + try { + await dynamicField().fold({ client: getClient(client), state, desired, actual }); + } finally { + spy.mockRestore(); + } + expect(errs.join("")).not.toContain("host-specific"); }); }); diff --git a/tests/tokenStore-multihost.test.ts b/tests/tokenStore-multihost.test.ts index f6d11a9..9a4a6cd 100644 --- a/tests/tokenStore-multihost.test.ts +++ b/tests/tokenStore-multihost.test.ts @@ -19,26 +19,24 @@ const PROD = "https://mychurch.church.tools"; /** Model a keychain as account → stored blob; wire the `security` CLI mock to read/write it. */ function mockKeychain(store: Map): void { - execFileMock.mockImplementation( - (_cmd: string, args: string[], cb: (e: unknown, v: unknown) => void) => { - const sub = args[0]; - const account = args[args.indexOf("-a") + 1]!; - if (sub === "find-generic-password") { - const val = store.get(account); - if (val === undefined) return cb(new Error("not found"), null); - return cb(null, { stdout: val, stderr: "" }); - } - if (sub === "add-generic-password") { - store.set(account, args[args.indexOf("-w") + 1]!); - return cb(null, { stdout: "", stderr: "" }); - } - if (sub === "delete-generic-password") { - store.delete(account); - return cb(null, { stdout: "", stderr: "" }); - } - return cb(new Error(`unexpected security subcommand ${sub}`), null); - }, - ); + execFileMock.mockImplementation((_cmd: string, args: string[], cb: (e: unknown, v: unknown) => void) => { + const sub = args[0]; + const account = args[args.indexOf("-a") + 1]!; + if (sub === "find-generic-password") { + const val = store.get(account); + if (val === undefined) return cb(new Error("not found"), null); + return cb(null, { stdout: val, stderr: "" }); + } + if (sub === "add-generic-password") { + store.set(account, args[args.indexOf("-w") + 1]!); + return cb(null, { stdout: "", stderr: "" }); + } + if (sub === "delete-generic-password") { + store.delete(account); + return cb(null, { stdout: "", stderr: "" }); + } + return cb(new Error(`unexpected security subcommand ${sub}`), null); + }); } beforeEach(() => {