Skip to content

Commit 13e85be

Browse files
feat(spec,plugin-auth): a demoStaff entry names its line manager, and os dev links it (#26763)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBCFW2vVVh9Bwz714cxX7PDq)_ Fixes #26616 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: #26764 ## 维护者速读 - 改了什么:`demoStaff` 条目新增可选键 `manager`(填同一列表里另一条目的 email);`os dev` 启动时在所有演示账号建好之后、种子数据之前写入 `sys_user.manager_id`。 - 为什么改:新起的 demo 里每个账号都没有直属上级,审批流的 `manager` 一级找不到人,只能手工去 Setup → Users → Set Manager 补。 - 风险与回滚:只影响本地 `os dev` 启动且声明了 `manager` 的应用;已有上级(含手工改过的)一律不覆盖。回滚即 revert 本 PR,未声明 `manager` 的栈行为不变。 - 先看:`packages/plugins/plugin-auth/src/demo-staff.ts` 的 `linkDemoStaffManagers`;`packages/spec/src/stack.zod.ts` 的 `collectDemoStaffManagerErrors`;`packages/spec/src/system/demo-staff.zod.ts` 的 `manager`。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run src/demo-staff-provisioning.test.ts`,应见 11 个用例全绿(含 8、9 两个上级用例)。 - 需要你做的:无,席位落地。 ## What changes **Spec (`packages/spec/src/system/demo-staff.zod.ts`).** A `demoStaff` entry gains one optional key, `manager`: the `email` of another entry of the same `demoStaff` list, compared without regard to case. A value that is not an address (the likely mistake: writing the manager's display name) is refused at the entry's path with a message saying it takes another entry's email. `reportsTo`, `managerEmail`, `managerId` (which also catches `manager_id`, one probe) and `supervisor` are aliases that point to `manager`. **Why the email and not the name.** The address is the identity key on every path that provisions an account: the provisioning finds each entry's account by it (`findUserIdByEmail`), it is what makes a re-run idempotent, and the admin bulk import resolves its `manager_id` column by an identity key too, never a display name. A display name is a Tier-1 profile field the person can edit on their own profile, and it is the seed's reference key, not the account's. The schema already checks the address's shape, so a typo in it is caught at the entry before the stack check runs. **Stack check (`packages/spec/src/stack.zod.ts`, `collectDemoStaffManagerErrors`, called from `collectDemoStaffErrors`).** `defineStack` (and so `os validate` and every load) refuses, with `STACK_CROSS_REFERENCE_INVALID` 422, each of: - a `manager` that is the address of no entry; - an entry that names itself (case-insensitive); - managers that form a loop, reported once per loop with every member and its `stack.demoStaff[i]` origin; an entry that only hangs from a loop is not named in it. None of these is skipped: each would leave a manager link that the boot cannot write. **Provisioning (`packages/plugins/plugin-auth/src/demo-staff.ts`, the first reader).** `provisionDemoStaff` keeps its first pass unchanged and adds a second one, `linkDemoStaffManagers`, after every entry has had its turn and still on the same `kernel:ready`, so before the application seed. For each entry with a `manager`: - no account for the entry: skipped, because the first pass already reported why it has none; - no account for the manager: reported on `failed` under the subordinate's address, naming whether the manager's entry was not provisioned or is not an entry at all; - the account's `manager_id` already equals the declared manager: nothing written; - the account already has a different manager: left as it is and listed on the new `managersKept` (an `info` line on the boot log names them); - no manager: written through `applyUserManagerLink`, the one derivation behind `POST /api/v1/auth/admin/set-user-manager`. Its refusals (self, cycle, depth, cross-organization, directory-owned) apply unchanged and are reported with their `code` and `reason`; this module carries no copy of them. `DemoStaffOutcome` gains `managersLinked` and `managersKept`. `auth-plugin.ts` adds both to the boot's summary lines. **A re-run never overwrites a manager set by hand.** The declaration wins only over absence. A manager that an operator cleared by hand reads as no manager, so the next boot writes the declared one again; the alternative (remembering which links the boot wrote) needs state outside `sys_user` that nothing else reads, for the one case where an operator wants a demo account to have no manager at all and also kept `manager` in the stack. **Reference docs.** `content/docs/references/system/demo-staff.mdx` regenerated (`gen:docs`), `authorable-surface/system.json` gains `system/DemoStaff:manager`. ## Why A fresh demo's approval ladder starts at the requester's manager, and `manager_id` was NULL on every demo account (measured in hotclm on 18.2.0: 11 of 11), so rung 1 reached nobody until someone set it by hand. Only the platform can write `manager_id`, so the application could not declare it. ## Tests Runs through `scripts/pm/os-verify-lock.sh`. Source as of `8e1dde389`; head `a516ef709` adds only the engine-double ledger line. <details><summary>Spec: src/stack-demo-staff.test.ts (9 new cases) and the full suite</summary> - New `describe('manager — …')`: accepts a manager declared after the entry, case-insensitively; accepts a three-link chain that reaches a top; refuses a display name at `demoStaff.0.manager` with the message; refuses `reportsTo` / `managerEmail` naming `manager`; refuses a manager naming no entry (`STACK_CROSS_REFERENCE_INVALID` 422 + first sentence); refuses self-reference (case-insensitive); refuses a two-entry loop once, naming both; refuses a three-entry loop that a fourth entry hangs from, naming only the loop. - `pnpm --filter @objectstack/spec test` on `8e1dde389`'s parent tree: `Test Files 1 failed | 730 passed (731)`, `Tests 1 failed | 21928 passed | 1 todo`. The one failure was `src/shared/alias-integrity.test.ts`: my `managerId` and `manager_id` aliases share the probe `managerid`. Fixed by dropping `manager_id` (commit `8e1dde389`); re-run of `alias-integrity.test.ts` + `stack-demo-staff.test.ts`: `Test Files 2 passed`, `Tests 42 passed`. - `pnpm --filter @objectstack/spec typecheck`: exit 0. </details> <details><summary>plugin-auth: unit and real-boot pins, and the full suite</summary> - `src/demo-staff.test.ts`, new `describe('provisioning — each declared manager is linked …')`, 6 cases on an engine double that records every update: fresh (the manager declared after the entry is linked, one `manager_id` update); re-run with the link in place (no update); re-run with a manager set by hand (no update, `managersKept: ['counsel1@legal.example']`); re-run after a manager was cleared (written again); the manager's entry not provisioned (reported on the subordinate, exact message, no update); the set-manager write refusing (`[PERMISSION_DENIED, idp_provisioned]`, no update). - `src/demo-staff-provisioning.test.ts` (real `ObjectKernel`, ObjectQL on in-memory SQLite, real `AuthPlugin` + `SecurityPlugin`), new cases 8 and 9: a fresh boot leaves `manager_id` set for both orders of declaration (`managersLinked: 2`); the next boot keeps a manager re-pointed by hand and re-writes a cleared one (`managersLinked: 1`, `managersKept: ['para1@legal.example']`, no `warn`). `Test Files 1 passed`, `Tests 11 passed`. - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: `Test Files 198 passed (198)`, `Tests 3788 passed | 10 skipped`. - `pnpm --filter @objectstack/plugin-auth typecheck` (after building plugin-auth's own dist, which `examples/basic-usage.ts` imports): exit 0. </details> <details><summary>Ablation (one, on the behaviour the card asks to keep)</summary> `node scripts/ablation-replace.mjs --file packages/plugins/plugin-auth/src/demo-staff.ts --anchor " if (current !== null) {" --replacement " if (current !== null && false) {" -- … vitest run src/demo-staff.test.ts`: anchor x1 → x0, blob `5ed3b373be04` → `7c15b3750852`; `× a re-run leaves a manager set by hand as it is, and says so`, `Tests 1 failed | 19 passed`; restored, blob == HEAD and `git diff HEAD` empty. The test imports `./demo-staff.js` from `src`, so no rebuild was needed for the mutation to reach it. </details> ## Acceptance notes - **No example app declares `demoStaff`.** `git grep -c demoStaff -- examples` answers 0 files on `8e1dde389` (control: `git grep -l "positions:" -- examples` answers 9), so no example is pinned. The consumers are hotclm (objectstack-ai/hotclm#28 and its PR objectstack-ai/hotclm#171, whose README keeps the manual Set Manager step for this alone) and HotCRM's sales managers. - **The manager resolves within the list one `defineStack` sees.** `demoStaff` is refused inside `packages[].manifest`, so the list is the stack's own top level. Under `composeStacks`, each input was already `defineStack`-ed, so an entry naming a manager that only another publisher declares is refused at its own stack. The card asks for the same list, and nothing composes demo staff across publishers today. - **The 20-link depth cap does not bound a declared chain.** `MAX_MANAGER_CHAIN_DEPTH` lives in plugin-auth, outside `packages/spec`, so the load-time check counts no links. At the boot the set-manager write walks upward from the proposed manager only: declared top-first, the link that crosses 20 is refused and reported on the subordinate; declared subordinate-first, each link is written while the chain above it is still short, and a longer chain lands. - **Attribution.** At boot there is no request, so `authSystemWriteContext()` answers `{ isSystem: true }` with no `attributedUserId`; the write is a system write, as the account creation itself is. - **Skills-docs.** `skills/objectstack-platform/SKILL.md` (ceiling 5997 tokens) and `skills/objectstack-platform/evals/config-plugins-ops.json` (ceiling 1469) sit at zero headroom in `scripts/check-skills-token-ratchet.mjs`, and raising a ceiling is not this card's call, so the teaching line (`manager` names another entry's email; `os dev` links it and never overwrites one set by hand) and an eval case are owed as a follow-up. The reference page is regenerated here. ## Verification <details><summary>Derived local gates: 120 of 120 run, all exit 0 (dispatch-gates --ran reconciled)</summary> - `node scripts/pm/dispatch-gates.mjs --commands` derived 112 families on `8e1dde389`; run serially with no build in flight: 103 exit 0, 7 exit 3 (prerequisite: `packages/spec/dist` was older than the last source edit, plus the two whole-repo built-output gates), 2 exit 1: - `check:engine-double-contract`: `demo-staff.test.ts now pins 2 engine double(s), ledger records 1` (coverage grew). Fixed with `--write`, committed in `a516ef709`. - `spec check:generated`: `api-surface/` stale against the stale dist. After `pnpm --filter @objectstack/spec build`, it is green with no regenerated file. - After rebuilding spec, the nine non-green ones were re-run: all exit 0 (`check:engine-double-contract`, `spec check:generated`, `check:api-surface`, `check:browser-reachable-entries`, `check:dual-source-exports`, `check:entry-nameability`, `check:exported-any`, `check:skill-examples`, `check:dual-build-cjs-loads`). - The ledger commit `a516ef709` added 8 families to the derivation (`check:agent-test-spelling`, `check:bash32-floor`, `check:cli-command-ids`, `check:entry-guard`, `check:parse-guard`, `check:pnpm-filter-targets`, `check:pnpm-flag-spellings`, `check:scripts-symbol-anchors`): all run, all exit 0. - Final: `✓ dispatch-gates --ran: 120 derived famil(ies) accounted for — 120 run, 0 NOT-MEASURED`. - Not run locally, declared to CI: `pnpm --filter @objectstack/spec test:repo` (no test in that project changed) and the repo-wide `pnpm lint`. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint on the seven changed source files exits 0 (the repo-wide `pnpm lint` is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzehKYBxrrAG5Jid68s --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzehKYBxrrAG5Jid68s)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ef7067f commit 13e85be

11 files changed

Lines changed: 548 additions & 9 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-auth': minor
4+
---
5+
6+
feat(spec,plugin-auth): a `demoStaff` entry names its line manager, and the development boot writes it
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
10+
11+
A `demoStaff` entry declares one optional key, `manager`: the `email` of another entry of the same `demoStaff` list, compared without regard to case. It is named by address because the address identifies an account everywhere the platform provisions one; a display name is a label a person can edit. Before, the entry was strict and refused the key, so a fresh demo's `manager` approval rung reached nobody until someone ran Setup → Users → Set Manager by hand.
12+
13+
`defineStack` (and so `os validate`) refuses, with `STACK_CROSS_REFERENCE_INVALID` 422, a `manager` that is the address of no entry, an entry that names itself, and managers that form a loop. A manager written as a display name is refused at the entry's path, saying it takes an address. `reportsTo`, `managerEmail`, `managerId` (and so `manager_id`) and `supervisor` are pointed to `manager`.
14+
15+
On an `os dev` boot, `@objectstack/plugin-auth` links each declared manager in a second pass, once every entry has its account and still before the application seed runs. Each link goes through the same checks as `POST /api/v1/auth/admin/set-user-manager`. A link is written only when the account has no manager, so a manager someone set by hand is left as it is on every later boot; a manager cleared by hand reads as no manager and is written again. A link that cannot be written (the manager's own entry was not provisioned, or the set-manager checks refuse it) is reported on the boot log under the subordinate's address. `DemoStaffOutcome` gains `managersLinked` and `managersKept`.

‎content/docs/references/system/demo-staff.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const result = DemoStaffSchema.parse(data);
3232
| **email** | `string` | ✅ | Sign-in address of the demo account |
3333
| **position** | `string` | optional | A position this application declares in `positions`, assigned to the demo account |
3434
| **permissionSets** | `string[]` | optional | Permission sets this application declares in `permissions`, granted to the demo account |
35+
| **manager** | `string` | optional | Line manager of the demo account: the email of another demoStaff entry, written as its manager_id by the development boot |
3536

3637

3738
---

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2517,16 +2517,23 @@ export class AuthPlugin implements Plugin {
25172517
}
25182518
},
25192519
defaultOrganizationId: async () => (tenancy ? await tenancy.defaultOrgId() : null),
2520+
logger: ctx.logger,
25202521
});
25212522

25222523
for (const failure of outcome.failed) {
25232524
ctx.logger.warn(`[auth] demoStaff: ${failure.email} was not provisioned — ${failure.message}`);
25242525
}
2525-
if (outcome.created.length > 0 || outcome.assignmentsAdded > 0) {
2526+
if (outcome.managersKept.length > 0) {
2527+
ctx.logger.info(
2528+
`[auth] demoStaff: ${outcome.managersKept.join(', ')} already had a manager other than the declared one, ` +
2529+
'set by hand; it was left as it is.',
2530+
);
2531+
}
2532+
if (outcome.created.length > 0 || outcome.assignmentsAdded > 0 || outcome.managersLinked > 0) {
25262533
ctx.logger.info(
25272534
`[auth] demoStaff: ${outcome.created.length} demo account(s) created, ${outcome.present.length} already ` +
2528-
`present, ${outcome.assignmentsAdded} assignment(s) added — they sign in with the development seed ` +
2529-
`password (${password}).`,
2535+
`present, ${outcome.assignmentsAdded} assignment(s) added, ${outcome.managersLinked} manager(s) linked — ` +
2536+
`they sign in with the development seed password (${password}).`,
25302537
);
25312538
} else {
25322539
ctx.logger.debug(`[auth] demoStaff: all ${outcome.present.length} demo account(s) already present`);

‎packages/plugins/plugin-auth/src/demo-staff-provisioning.test.ts‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { SecurityPlugin } from '@objectstack/plugin-security';
3131
import { AuthPlugin, type DemoStaffingService } from './auth-plugin.js';
3232
import type { AuthManager } from './auth-manager.js';
3333
import type { DemoStaffOutcome, DemoStaffRefusal } from './demo-staff.js';
34+
import type { DemoStaffParsed } from '@objectstack/spec/system';
3435

3536
const SYS = { isSystem: true } as const;
3637
const BASE = 'http://localhost:3000';
@@ -102,12 +103,12 @@ describe('the development boot provisions the stack`s demo accounts (#25777)', (
102103
vi.restoreAllMocks();
103104
});
104105

105-
async function boot(options: { baseUrl?: string } = {}) {
106+
async function boot(options: { baseUrl?: string; staff?: readonly DemoStaffParsed[] } = {}) {
106107
const step = vi.spyOn(
107108
AuthPlugin.prototype as unknown as { maybeProvisionDemoStaff(ctx: PluginContext): Promise<StaffingAnswer> },
108109
'maybeProvisionDemoStaff',
109110
);
110-
const auth = new AuthPlugin({ secret: SECRET, baseUrl: options.baseUrl ?? BASE, demoStaff: STAFF });
111+
const auth = new AuthPlugin({ secret: SECRET, baseUrl: options.baseUrl ?? BASE, demoStaff: options.staff ?? STAFF });
111112
kernel = new ObjectKernel({ logger: { level: 'silent' } });
112113
await kernel.use(sqliteDriverPlugin());
113114
await kernel.use(new ObjectQLPlugin());
@@ -288,4 +289,42 @@ describe('the development boot provisions the stack`s demo accounts (#25777)', (
288289
// On a development boot the developer loses the accounts: warned.
289290
expect((await rerun()).logs.map((l) => l.level)).toEqual(['warn']);
290291
}, 30_000);
292+
/**
293+
* The managers: Paralegal 1 reports to Legal Counsel 1 (declared before it,
294+
* named in another case), and Legal Counsel 1 to Legal Lead (declared after
295+
* it), so both orders of the second pass are on the real composition.
296+
*/
297+
const MANAGED_STAFF: DemoStaffParsed[] = [
298+
{ ...STAFF[0]!, manager: 'lead@legal.example' },
299+
{ ...STAFF[1]!, manager: 'COUNSEL1@legal.example' },
300+
{ name: 'Legal Lead', email: 'lead@legal.example' },
301+
];
302+
303+
it('8. a fresh dev database: every declared manager is sys_user.manager_id before the seed runs', async () => {
304+
const { answer, rows, userId } = await boot({ staff: MANAGED_STAFF });
305+
expect(answer).toMatchObject({ failed: [], managersLinked: 2, managersKept: [] });
306+
307+
const counsel = await userId('counsel1@legal.example');
308+
const lead = await userId('lead@legal.example');
309+
expect((await rows('sys_user', { id: counsel }))[0]!.manager_id).toBe(lead);
310+
expect((await rows('sys_user', { id: await userId('para1@legal.example') }))[0]!.manager_id).toBe(counsel);
311+
expect((await rows('sys_user', { id: lead }))[0]!.manager_id ?? null).toBeNull();
312+
}, 30_000);
313+
314+
it('9. the next boot leaves a manager set by hand as it is, and writes a cleared one again', async () => {
315+
const { ql, rows, userId, rerun } = await boot({ staff: MANAGED_STAFF });
316+
const admin = await userId(SEED_EMAIL);
317+
const counsel = await userId('counsel1@legal.example');
318+
const para = await userId('para1@legal.example');
319+
// An operator re-points Paralegal 1 to the admin and clears Legal Counsel 1's.
320+
await ql.update('sys_user', { id: para, manager_id: admin }, { context: SYS } as never);
321+
await ql.update('sys_user', { id: counsel, manager_id: null }, { context: SYS } as never);
322+
323+
const { answer, logs } = await rerun();
324+
expect(answer).toMatchObject({ failed: [], managersLinked: 1, managersKept: ['para1@legal.example'] });
325+
expect((await rows('sys_user', { id: para }))[0]!.manager_id).toBe(admin);
326+
expect((await rows('sys_user', { id: counsel }))[0]!.manager_id).toBe(await userId('lead@legal.example'));
327+
expect(logs.filter((l) => l.level === 'warn')).toEqual([]);
328+
expect(logs.map((l) => l.message).join('\n')).toContain('para1@legal.example already had a manager');
329+
}, 30_000);
291330
});

‎packages/plugins/plugin-auth/src/demo-staff.test.ts‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,180 @@ describe('provisioning — a failure after sign-up says the account exists', ()
233233
expect(outcome.assignmentsAdded).toBe(0);
234234
});
235235
});
236+
237+
/**
238+
* A `sys_user` table the manager pass reads and writes, behind the engine
239+
* surface both the provisioning and the set-manager write use. Every update is
240+
* recorded, so "left as it is" is read as "never written", not as a value that
241+
* happens to match. `sys_member` answers no rows: no tenancy fact recorded, so
242+
* the cross-organization screen stays out of these cases, as it does for any
243+
* identity without one.
244+
*/
245+
function managerEngine(users: Array<Record<string, unknown>>) {
246+
const updates: Array<Record<string, unknown>> = [];
247+
const engine: DemoStaffEngine = {
248+
async find(object, query) {
249+
const matched = object === 'sys_user' ? selectRows(users, (query.where ?? {}) as Record<string, unknown>) : [];
250+
return typeof query.limit === 'number' ? matched.slice(0, query.limit) : matched;
251+
},
252+
async update(object, data, options) {
253+
assertEngineUpdateDispatch(data, options as Parameters<typeof assertEngineUpdateDispatch>[1]);
254+
updates.push({ object, ...data });
255+
const row = users.find((u) => u.id === data.id);
256+
if (object === 'sys_user' && row) Object.assign(row, data);
257+
return undefined;
258+
},
259+
async insert() {
260+
throw new Error('these entries carry no position or permission set, so nothing is inserted');
261+
},
262+
};
263+
let next = 0;
264+
const signUp = async ({ email, name }: { email: string; name: string }) => {
265+
users.push({ id: `u_${++next}`, email, name, manager_id: null });
266+
};
267+
return { engine, updates, signUp };
268+
}
269+
270+
const managerLinks = (updates: Array<Record<string, unknown>>) =>
271+
updates.filter((u) => 'manager_id' in u).map((u) => [u.id, u.manager_id]);
272+
273+
describe('provisioning — each declared manager is linked once every entry has its account', () => {
274+
const STAFF = [
275+
// Names a manager declared AFTER it: the link waits for the second pass.
276+
{ name: 'Legal Counsel 1', email: 'counsel1@legal.example', manager: 'LEAD@legal.example' },
277+
{ name: 'Legal Lead', email: 'lead@legal.example' },
278+
];
279+
const deps = (engine: DemoStaffEngine, signUp: (a: { email: string; name: string }) => Promise<void>) => ({
280+
engine,
281+
password: 'admin123',
282+
signUp,
283+
defaultOrganizationId: async () => null,
284+
});
285+
286+
it('a fresh database: the manager declared after the entry is linked, through the set-manager write', async () => {
287+
const users: Array<Record<string, unknown>> = [];
288+
const { engine, updates, signUp } = managerEngine(users);
289+
290+
const outcome = await provisionDemoStaff(STAFF, deps(engine, signUp));
291+
292+
expect(outcome).toMatchObject({
293+
created: ['counsel1@legal.example', 'lead@legal.example'],
294+
failed: [],
295+
managersLinked: 1,
296+
managersKept: [],
297+
});
298+
const byEmail = (email: string) => users.find((u) => u.email === email)!;
299+
expect(byEmail('counsel1@legal.example').manager_id).toBe(byEmail('lead@legal.example').id);
300+
expect(managerLinks(updates)).toEqual([[byEmail('counsel1@legal.example').id, byEmail('lead@legal.example').id]]);
301+
});
302+
303+
it('a re-run writes nothing: the declared link is in place', async () => {
304+
const users: Array<Record<string, unknown>> = [
305+
{ id: 'u_c1', email: 'counsel1@legal.example', manager_id: 'u_lead' },
306+
{ id: 'u_lead', email: 'lead@legal.example', manager_id: null },
307+
];
308+
const { engine, updates, signUp } = managerEngine(users);
309+
310+
const outcome = await provisionDemoStaff(STAFF, deps(engine, signUp));
311+
312+
expect(outcome).toMatchObject({ created: [], failed: [], managersLinked: 0, managersKept: [] });
313+
expect(managerLinks(updates)).toEqual([]);
314+
});
315+
316+
it('a re-run leaves a manager set by hand as it is, and says so', async () => {
317+
const users: Array<Record<string, unknown>> = [
318+
{ id: 'u_c1', email: 'counsel1@legal.example', manager_id: 'u_someone_else' },
319+
{ id: 'u_lead', email: 'lead@legal.example', manager_id: null },
320+
];
321+
const { engine, updates, signUp } = managerEngine(users);
322+
323+
const outcome = await provisionDemoStaff(STAFF, deps(engine, signUp));
324+
325+
expect(outcome).toMatchObject({ failed: [], managersLinked: 0, managersKept: ['counsel1@legal.example'] });
326+
expect(managerLinks(updates)).toEqual([]);
327+
expect(users[0]!.manager_id).toBe('u_someone_else');
328+
});
329+
330+
it('a re-run writes the declared manager again on an account whose manager was cleared', async () => {
331+
const users: Array<Record<string, unknown>> = [
332+
{ id: 'u_c1', email: 'counsel1@legal.example', manager_id: null },
333+
{ id: 'u_lead', email: 'lead@legal.example', manager_id: null },
334+
];
335+
const { engine, updates, signUp } = managerEngine(users);
336+
337+
expect(await provisionDemoStaff(STAFF, deps(engine, signUp))).toMatchObject({ failed: [], managersLinked: 1 });
338+
expect(managerLinks(updates)).toEqual([['u_c1', 'u_lead']]);
339+
});
340+
341+
it('a manager whose entry could not be provisioned is reported on the subordinate, never skipped', async () => {
342+
const users: Array<Record<string, unknown>> = [];
343+
const { engine, updates, signUp } = managerEngine(users);
344+
345+
const outcome = await provisionDemoStaff(
346+
STAFF,
347+
deps(engine, async (account) => {
348+
if (account.email === 'lead@legal.example') throw new Error('the sign-up door refused the address');
349+
await signUp(account);
350+
}),
351+
);
352+
353+
expect(outcome.created).toEqual(['counsel1@legal.example']);
354+
expect(outcome.managersLinked).toBe(0);
355+
expect(outcome.failed).toEqual([
356+
{ email: 'lead@legal.example', message: 'the sign-up door refused the address' },
357+
{
358+
email: 'counsel1@legal.example',
359+
message:
360+
'its manager lead@legal.example has no account, so the manager link was not written: that demo account ' +
361+
'was not provisioned on this boot (its own line says why).',
362+
},
363+
]);
364+
expect(managerLinks(updates)).toEqual([]);
365+
});
366+
367+
it('the set-manager write`s own degradation warning reaches the boot logger', async () => {
368+
// The cross-organization screen cannot read `sys_member`: the set-manager
369+
// write accepts the link without it and says so once, on the logger the
370+
// provisioning was handed.
371+
const users: Array<Record<string, unknown>> = [
372+
{ id: 'u_c1', email: 'counsel1@legal.example', manager_id: null },
373+
{ id: 'u_lead', email: 'lead@legal.example', manager_id: null },
374+
];
375+
const { engine, updates, signUp } = managerEngine(users);
376+
const find = engine.find.bind(engine);
377+
engine.find = async (object, query, options) => {
378+
if (object === 'sys_member') throw new Error('sys_member is unreachable');
379+
return find(object, query, options);
380+
};
381+
const warned: string[] = [];
382+
383+
const outcome = await provisionDemoStaff(STAFF, {
384+
...deps(engine, signUp),
385+
logger: { warn: (msg) => warned.push(msg) },
386+
});
387+
388+
expect(outcome).toMatchObject({ failed: [], managersLinked: 1 });
389+
expect(managerLinks(updates)).toEqual([['u_c1', 'u_lead']]);
390+
expect(warned).toHaveLength(1);
391+
expect(warned[0]).toContain('the cross-organization screen could not read sys_member');
392+
});
393+
394+
it('a link the set-manager write refuses is reported with its code and reason', async () => {
395+
// A directory-owned identity: the set-manager write's refusal 5. The
396+
// provisioning carries no copy of that rule; this is the shared one.
397+
const users: Array<Record<string, unknown>> = [
398+
{ id: 'u_c1', email: 'counsel1@legal.example', manager_id: null, source: 'idp_provisioned' },
399+
{ id: 'u_lead', email: 'lead@legal.example', manager_id: null },
400+
];
401+
const { engine, updates, signUp } = managerEngine(users);
402+
403+
const outcome = await provisionDemoStaff(STAFF, deps(engine, signUp));
404+
405+
expect(outcome.managersLinked).toBe(0);
406+
expect(outcome.failed.map((f) => f.email)).toEqual(['counsel1@legal.example']);
407+
expect(outcome.failed[0]!.message).toContain(
408+
'its manager link to lead@legal.example was refused [PERMISSION_DENIED, idp_provisioned]',
409+
);
410+
expect(managerLinks(updates)).toEqual([]);
411+
});
412+
});

0 commit comments

Comments
 (0)