Skip to content

Commit ac9c2f0

Browse files
fix(platform-objects): withhold the sign-in phone fields from org peers in the user directory (#27431)
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no file under `skills/**` teaches the `sys_user` phone fields or the user directory's field security (`git grep -i -c 'phone_number\|phoneNumber' -- 'skills/**'`: zero hits, exit 1; control `git grep -c sys_user -- 'skills/**'`: hits), so no skill text describes the served set this narrows. ## 维护者速读 - 改了什么:`sys_user.phone_number` / `phone_number_verified` 从 `Account` 字段组移入 `Admin` 字段组;普通成员读用户目录时不再拿到其他用户的登录手机号。 - 为什么改:手机号是 better-auth 的登录标识,不是通讯录数据;`Admin` 组已由内置权限集做字段级只读屏蔽(成员屏蔽、管理员保留),沿用同一机制,不新增规则。 - 风险与回滚:这是一次接受集收窄(BREAKING,按 `patch` 发布):成员按这两个字段过滤、排序、分组、聚合会被 403 拒绝,`$search` 静默不再扫手机号列,成员读自己那行也看不到手机号(本人手机号从登录会话读取);回滚 = 把两个字段的 group 改回 `Account`。 - 先看:`packages/platform-objects/src/identity/sys-user.object.ts`(两个字段的 `group`);`.changeset/user-directory-sign-in-phone-fields.md`(BREAKING 横幅与 FROM/TO);`packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts`(成员按姓名搜同事命中、按手机号搜无结果)。 - 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run src/identity-admin-field-group.test.ts`,预期 19 passed。 - 需要你做的:无,席位落地(本人行问题已在 6497499302 裁决「接受」)。 ## What changes The user directory no longer returns another user's sign-in phone number to non-admin readers. Security hardening, declared as an accept-set narrowing. - `sys_user.phone_number` and `sys_user.phone_number_verified` (the better-auth phone sign-in identifier and its verified flag) move from the `Account` field group to the `Admin` field group. - The `Admin` group is already the declaration the shipped permission sets read (`identityAdminFieldSecurity` in `plugin-security`'s `default-permission-sets.ts`): `member_default`, `viewer_readonly` and `external_default` mark every group field `readable: false, editable: false`; `admin_full_access`, `organization_admin` and its no-bypass variant keep it. No new rule, list or field attribute: the server's existing field-level security (field masker, predicate guard, activity redaction, metadata-plane masking) does the work, so the Console needs no change. - **Positions that narrow for a non-admin member** (the changeset carries the full FROM / TO under its `**BREAKING** (an accept-set narrowing)` banner): - row position: both fields are withheld from every `sys_user` row served, the member's own row included (field-level read is row-blind, ADR-0066); - filter, sort, group-by and aggregate positions naming either field are refused `403 PERMISSION_DENIED`; - `$search`: the default search set is narrowed silently, so a user picker keeps answering on `name` and `email` and a search for a colleague's number returns no row. - `banned` / `ban_reason` / `ban_expires`: reviewed, no change. `banned` stays served to org peers by an earlier ruling (directory status every user picker filters on); `ban_reason` and `ban_expires` are already in the `Admin` group and already withheld. - Changeset: `@objectstack/platform-objects` `patch` (a non-protocol narrowing ships `patch` and keeps its BREAKING banner); exactly one ADR-0087 disposition, `not-required (no-migration-prescription)`: no metadata key, spelling, export or stored shape moves. ## Acceptance notes - **Own row (ruled).** Field-level read security is row-blind (ADR-0066, 2026-10-26 note: `readable` stays row-blind; `when` beside `readable: false` is refused at parse). So a member's OWN row on the generic data API is withheld the phone fields too, exactly as for every other `Admin`-group field. The maintainer ruled this accepted in comment 6497499302 (「接受」): ship as is, the own number stays reachable through the auth session, no row-aware read vote. better-auth's phone-number plugin declares `phoneNumber` / `phoneNumberVerified` with `returned: true`, so the session user carries them when the plugin is enabled. - System-context readers (sign-in, SMS delivery, admin import, the auth adapter) are unaffected. - A deployment that wants members to read colleagues' phone numbers grants a set naming the two fields `readable: true` (field grants merge most-permissively). - Rework round (contract review 6501154665, FAIL on the declaration alone): this head adds the `(narrowing)` arm, the BREAKING banner with FROM / TO and the ADR-0087 marker to the changeset, the `Skills-docs:` line here, and the should-fix `$search` cases at the HTTP door. No package source changed in this round. Rework run dispatched from PM session session_017xRBcVy4VXmkQjn2MGeaWk. ## Tests - `packages/plugins/plugin-security/src/identity-admin-field-group.test.ts`: the two phone fields are declared inside the group; a member (`member_default`) is withheld both and the field masker strips them from a colleague row; each admin set keeps both and the masker serves them. - `packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts` (real showcase boot, HTTP door): the colleague fixture carries a synthetic phone; a member's directory read projecting the phone fields is served the colleague's id, name and email without them; the org owner and the platform admin read the stored values. New this round: a member `$search` on `sys_user` by the colleague's name is served `200` with the colleague listed; a member `$search` by the colleague's synthetic number is served `200` with no row; the platform admin's `$search` by the same number lists the colleague (the control that the term matches when the column is scanned). The existing generic group pins (member reads colleague, list door, activity door, member own row, filter refusal) cover the phone fields too. ## Verification All readings below were taken on head `a77f981d` (the final head; `git rev-parse --short HEAD` = `a77f981d`). No package source changed in this round; the package-suite readings of the previous round on `a195fe983` (unchanged sources) stand for `platform-objects` and `plugin-security`. <details><summary>Declaration gates (this round's must-fix)</summary> - `pnpm check:adr-0087-registration`: exit 0. `✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.` — `.changeset/user-directory-sign-in-phone-fields.md [BREAKING+clause-②-narrowing] not-required (no-migration-prescription)`. Self-test 705 assertions. - `node scripts/check-changeset-no-major.mjs` (local, no event): exit 0, no `major`; level axis NOT APPLICABLE (no payload, by construction). - `node scripts/check-changeset-no-major.mjs --event EVENT` with a `pull_request` payload built from this staged body: exit 0. `✓ LEVEL AXIS: this PR declares clause-② no (narrowing) and metadata-protocol none ... a non-protocol change at patch`. Control: the same run over a payload built from the previous body reads `direction arm: none declared` — the arm is read from the body, not assumed. </details> <details><summary>Dogfood (the narrowing owes the battery)</summary> - Closure build `pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build`: lock VERDICT command-exit 0. - Touched file `identity-admin-fields-org-peer.dogfood.test.ts`: Test Files 1 passed, Tests 18 passed (15 before this round, plus the three `$search` cases). Lock VERDICT command-exit 0. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0. - Full battery `pnpm --filter @objectstack/dogfood test` (no shard, no path) on `a77f981d`: Test Files 348 passed, 3 skipped (351); Tests 2812 passed, 20 skipped (2832). Lock VERDICT command-exit 0. The CI `Dogfood Regression Gate` shards were skipped by their selector on this head, so this local run is the battery's evidence. </details> <details><summary>Ablation of the new search pins (one-shot, not kept)</summary> Mutation via `scripts/ablation-replace.mjs`: `phone_number`'s `group: 'Admin'` replaced with a marker group (anchor 1 → 0, blob `67491e481b64` → `c9c848e94377`), `platform-objects` rebuilt, marker present in 4 built files per `ablation-dist-preflight.mjs`. - Dogfood file: 2 failed / 16 passed — the member projection read and the member search by the colleague's number (it found the colleague). The member name search and the admin control stayed green, as expected. - Restore leg: blob equal to HEAD (`67491e481b64`), `git diff HEAD` empty, whole-tree `git status --porcelain` clean, rebuilt, marker absent from all 66 built files (`--absent`); file green again, 18/18. </details> <details><summary>Derived gate families</summary> `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` on `a77f981d`: 65 commands; 64 exit 0, 1 exit 3. `--ran` reconciliation: 65 derived, 64 run, 1 NOT-MEASURED, 0 UNRUN. NOT MEASURED: `check:dual-build-cjs-loads`, reason: PREREQUISITE NOT MET (seven packages outside this diff's closure have no `dist/` in this worktree); CI carries it in `Lint & Repo Gates` (success on this head). `node scripts/check-nul-bytes.mjs` exit 0. Repo-wide scans (`pnpm lint`, `test:repo`) are left to CI. </details> <details><summary>CI on `a77f981d`</summary> Every non-skipped check-run concluded `success`, the ten required contexts among them: `Lint & Repo Gates`, `TypeScript Type Check`, `Test Core`, `Build Core`, `Governed Surface Queue Guard`, `Test UI`; `Lint UI`, `Type Check UI`, `Build & E2E UI` and `Live E2E UI` were skipped by their path filter. The earlier `Lint & Repo Gates` red on `99bfe6d4` was its lint lanes concluding `cancelled` when the push of `a77f981d` superseded that run (run 40904302735 concluded `cancelled`), not a gate verdict. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_017xRBcUoS2G4jgqRskpExS7)_ <sub>Body refreshed by the PM seat 2026-10-29T16:49Z from os-dev report 6502824437.</sub> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 99aeee6 commit ac9c2f0

5 files changed

Lines changed: 143 additions & 5 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
fix(platform-objects): the user directory no longer returns another user's sign-in phone number to non-admin readers
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the shipped non-admin permission sets' field read on two `sys_user` columns: the two fields change their declared field group, and the group is the declaration those sets already read. The remedy is a permission set's existing `fields` entry, never a rewrite of anyone's code or metadata. No exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only); the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered). -->
11+
12+
**BREAKING** (an accept-set narrowing), shipped as `patch`: a non-protocol narrowing takes `patch` and keeps this banner and its ADR-0087 disposition.
13+
14+
`sys_user.phone_number` and `sys_user.phone_number_verified` are the better-auth phone sign-in identifier and its verified flag — account administration data, not directory data. Both are now declared in the identity object's `Admin` field group, which the shipped permission sets already treat as field-level security: the non-admin sets (`member_default`, `viewer_readonly`, `external_default`) mark every field of that group `readable: false`, and the admin sets (`admin_full_access`, `organization_admin` and its no-bypass variant) keep it.
15+
16+
**FROM.** On `origin/main`, an org member holding only the non-admin sets could, on the generic data door (`GET /api/v1/data/sys_user`, `POST /api/v1/data/sys_user/query`, the record read and the activity stream):
17+
18+
- read both fields on every org peer's row and on their own;
19+
- write a **filter** naming either field, and have it evaluated;
20+
- **sort** by either field;
21+
- **group by** either field, or name it as an **aggregate** operand;
22+
- run a `$search` whose default field set scanned `phone_number` (it is `searchable: true`, one of the object's three searchable fields beside `name` and `email`).
23+
24+
**TO.** For the same member:
25+
26+
- **Row position.** Both fields are withheld from every `sys_user` row served — a colleague's direct read, the list read, a `fields=` projection naming them, and the activity stream's recorded changes. The member's **own row** is withheld them too: field-level read security does not distinguish rows (ADR-0066).
27+
- **Filter, sort, group-by, aggregate.** A query naming either field in its filter (`where` / `having`), its sort, its group-by or an aggregation is refused `403 PERMISSION_DENIED` before the engine is asked, at every verb that carries a query — a bulk write's own predicate included.
28+
- **`$search`.** The default search set is narrowed **silently**: `phone_number` is dropped from the fields a member's `$search` scans, so a user picker keeps answering on `name` and `email`, and a search for a colleague's number returns no row rather than a refusal.
29+
- **Writes.** Both fields stay `readonly: true` and are written through better-auth only, so no reachable write changes.
30+
31+
**Unaffected.** Platform admins and organization admins read, filter, sort and search both fields exactly as before. System- and auth-context readers — sign-in by phone, SMS delivery, the admin import's match on `phone_number`, the better-auth adapter — read under a context no permission set reaches. The signed-in user's own number is read from the auth session: better-auth's session user carries both phone fields when the phone-number sign-in plugin is enabled.
32+
33+
**Field group.** Wherever a form lays fields out by their declared group, the two fields now sit in `Admin` instead of `Account`.
34+
35+
**Grant-back.** No configuration change is needed for shipped apps. A deployment that wants org members to keep reading, filtering on or searching colleagues' phone numbers grants a permission set naming `sys_user.phone_number` (and `sys_user.phone_number_verified`) with `readable: true` in its `fields` entries; field grants merge most-permissively, so that one set out-votes the baseline's withhold. A client that filtered or sorted members' `sys_user` queries on either field drops that predicate or runs it with an administrator's grant.

‎packages/platform-objects/src/identity/sys-user.object.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -916,13 +916,28 @@ export const SysUser = ObjectSchema.create({
916916
// #2766 V1.5 — phone-number sign-in identifier (better-auth phoneNumber
917917
// plugin, mapped via buildPhoneNumberPluginSchema). Unique when present;
918918
// null for email-only accounts. Written through better-auth, not CRUD.
919+
//
920+
// Declared in the `Admin` group, with `phone_number_verified` below, on
921+
// purpose: a sign-in identifier is account-administration data, not
922+
// directory data. The group is the declaration the shipped permission sets
923+
// read (`identityAdminFieldSecurity` in plugin-security's
924+
// `default-permission-sets.ts`): the non-admin sets (`member_default`,
925+
// `viewer_readonly`, `external_default`) mark every group field
926+
// `readable: false` and the admin sets keep it, so an org member reading
927+
// the user directory is served a colleague's name and email but not their
928+
// sign-in phone number. Field-level read security is row-blind (ADR-0066,
929+
// 2026-10-26 note), so the member's OWN row on the generic data API is
930+
// withheld it too; the holder's own number is read through auth context
931+
// (better-auth's session user, which returns both phone fields), as every
932+
// other group field is. Moving either field out of the group serves it to
933+
// every org peer again.
919934
phone_number: Field.text({
920935
label: 'Phone Number',
921936
required: false,
922937
readonly: true,
923938
searchable: true,
924939
maxLength: 32,
925-
group: 'Account',
940+
group: 'Admin',
926941
description:
927942
'Sign-in phone number (E.164 recommended). Unique per user; managed by ' +
928943
'better-auth when the phoneNumber plugin is enabled.',
@@ -932,7 +947,7 @@ export const SysUser = ObjectSchema.create({
932947
label: 'Phone Verified',
933948
defaultValue: false,
934949
readonly: true,
935-
group: 'Account',
950+
group: 'Admin', // withheld from org peers with `phone_number` (see the note above it)
936951
description:
937952
'Whether the phone number has been verified (OTP verification requires ' +
938953
'SMS infrastructure; false until that ships). System-managed.',

‎packages/plugins/plugin-security/src/identity-admin-field-group.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import type { PermissionSet } from '@objectstack/spec/security';
3939
import { ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN_GRANTS } from '@objectstack/spec';
4040
import { SysUser } from '@objectstack/platform-objects/identity';
4141
import { PermissionEvaluator } from './permission-evaluator.js';
42+
import { FieldMasker } from './field-masker.js';
4243
import { defaultPermissionSets } from './objects/default-permission-sets.js';
4344

4445
const IDENTITY = 'sys_user';
@@ -58,6 +59,12 @@ const DECLARED_ADMIN_GROUP: string[] = Object.entries(SysUser.fields as Record<s
5859
*/
5960
const PEER_DIRECTORY_FIELDS = ['name', 'email', 'image', 'banned'];
6061

62+
/**
63+
* The better-auth phone sign-in identifier and its verified flag — account
64+
* administration data, declared inside the group so org peers are withheld it.
65+
*/
66+
const SIGN_IN_PHONE_FIELDS = ['phone_number', 'phone_number_verified'];
67+
6168
const ADMIN_SETS = [ADMIN_FULL_ACCESS, ...ORGANIZATION_ADMIN_GRANTS];
6269
const WITHHOLDING_SETS = ['member_default', 'viewer_readonly', 'external_default'];
6370

@@ -90,6 +97,12 @@ describe('[#21237] the identity object Admin group — withheld from org peers,
9097
for (const control of PEER_DIRECTORY_FIELDS) expect(DECLARED_ADMIN_GROUP).not.toContain(control);
9198
});
9299

100+
it('the sign-in phone number and its verified flag are declared inside the group (a sign-in identifier is not directory data)', () => {
101+
// Moving either back out of the group serves a colleague's sign-in phone
102+
// number to every org member through the user directory.
103+
for (const field of SIGN_IN_PHONE_FIELDS) expect(DECLARED_ADMIN_GROUP, field).toContain(field);
104+
});
105+
93106
it.each(WITHHOLDING_SETS)('%s withholds exactly the declared group (readable: false), and nothing else on the identity object', (name) => {
94107
const entries = identityFieldEntries(setByName(name));
95108
expect(Object.keys(entries).sort()).toEqual(DECLARED_ADMIN_GROUP);
@@ -144,6 +157,21 @@ describe('[#21237] the identity object Admin group — withheld from org peers,
144157
for (const field of DECLARED_ADMIN_GROUP) expect(perms[field]?.readable, field).toBe(false);
145158
});
146159

160+
it('an org member is withheld the sign-in phone fields, and the masker strips them from a colleague row it serves', () => {
161+
const perms = mask(['member_default']);
162+
for (const field of SIGN_IN_PHONE_FIELDS) expect(perms[field], field).toEqual({ readable: false, editable: false });
163+
const colleague = { id: 'u2', name: 'Colleague', email: 'c@example.test', phone_number: '+15550000002', phone_number_verified: true };
164+
const served = new FieldMasker().maskResults([colleague], perms, IDENTITY) as Record<string, unknown>[];
165+
expect(served[0]).toEqual({ id: 'u2', name: 'Colleague', email: 'c@example.test' });
166+
});
167+
168+
it.each(ADMIN_SETS)('an admin holding %s keeps the sign-in phone fields, and the masker serves them', (name) => {
169+
const perms = mask(['member_default', name]);
170+
for (const field of SIGN_IN_PHONE_FIELDS) expect(perms[field], field).toEqual({ readable: true, editable: true });
171+
const colleague = { id: 'u2', name: 'Colleague', phone_number: '+15550000002', phone_number_verified: true };
172+
expect(new FieldMasker().maskResults([colleague], perms, IDENTITY)).toEqual([colleague]);
173+
});
174+
147175
it.each(ADMIN_SETS)('an admin holding %s on top of the baseline keeps every group field, readable and editable', (name) => {
148176
const perms = mask(['member_default', name]);
149177
for (const field of DECLARED_ADMIN_GROUP) {

‎packages/plugins/plugin-security/src/objects/default-permission-sets.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,11 +278,15 @@ function identityAdminGroupFields(): string[] {
278278
* member's user-context write naming one is refused by the field-level write
279279
* gate. Every reader of these fields on a member's OWN row reads through system
280280
* or auth context (the auth manager's gates and stamps, the better-auth
281-
* adapter, the authorization resolver's `ai_seat` synthesis), which no
281+
* adapter and its session user — which carries the holder's own sign-in phone
282+
* number — the authorization resolver's `ai_seat` synthesis), which no
282283
* permission set reaches. The deactivation flag is declared OUTSIDE the group
283284
* on purpose (#21237): it is directory status that every user picker filters
284-
* on, so withholding it would refuse each member's picker query. A fresh
285-
* object per set, like `denyWritesOnManagedObjects()`.
285+
* on, so withholding it would refuse each member's picker query. The sign-in
286+
* phone number and its verified flag are declared INSIDE it on purpose: a
287+
* sign-in identifier is not directory data, and a user picker's `$search`
288+
* stops scanning it for a member by the queryable-field narrowing, not by a
289+
* refusal. A fresh object per set, like `denyWritesOnManagedObjects()`.
286290
*/
287291
const identityAdminFieldSecurity = (
288292
posture: 'withhold' | 'keep',

‎packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
// field-level write gate, and a mixed payload does not partially succeed;
3535
// - the user picker's candidate query (the not-deactivated filter) as the
3636
// member: served, deactivated peers excluded;
37+
// - the user picker's `$search` as the member: a colleague's name finds the
38+
// colleague, a colleague's sign-in phone number finds no row (the search
39+
// set is narrowed, not refused), with the admin's search as the control;
3740
// - the ORG OWNER and a PLATFORM ADMIN (who holds no org-admin grant): the
3841
// full group on both doors, and the platform admin's write unchanged.
3942
//
@@ -63,7 +66,13 @@ const SYNTHETIC: Record<string, unknown> = {
6366
last_login_ip: 'SYNTHETIC-ADDRESS-ONE',
6467
failed_login_count: 2,
6568
ban_reason: 'SYNTHETICREASON21237',
69+
// The better-auth sign-in identifier, declared in the group: a synthetic
70+
// number in the reserved fictional 555-01xx range.
71+
phone_number: '+12025550137',
72+
phone_number_verified: true,
6673
};
74+
/** The sign-in phone fields — account administration data, not directory data. */
75+
const SIGN_IN_PHONE = ['phone_number', 'phone_number_verified'];
6776
const SYNTHETIC_NEXT: Record<string, unknown> = { last_login_ip: 'SYNTHETIC-ADDRESS-TWO', failed_login_count: 3 };
6877

6978
/** Object-level activity read, granted to the member for the activity door. */
@@ -225,6 +234,53 @@ describe('[#21237] the identity object Admin group at the HTTP door: org peers a
225234
expect(servedGroup(row!)).toEqual([]);
226235
});
227236

237+
it('a member directory read projecting the sign-in phone is served the colleague name and email without it', async () => {
238+
const res = await stack.apiAs(token.member, 'GET', '/data/sys_user?fields=id,name,email,phone_number,phone_number_verified&$top=100');
239+
expect(res.status).toBe(200);
240+
const row = rowsOf(await res.json()).find((r) => String(r.id) === uid.colleague);
241+
expect(row, 'colleague listed').toBeTruthy();
242+
expect(row).toHaveProperty('name');
243+
expect(row).toHaveProperty('email');
244+
for (const field of SIGN_IN_PHONE) expect(row, field).not.toHaveProperty(field);
245+
});
246+
247+
// The user picker's `$search`: `phone_number` is `searchable: true`, so it is
248+
// in the object's default search set. For a member the set is NARROWED to the
249+
// fields the member may query (the queryable-field narrowing), never refused:
250+
// the picker keeps answering on the name, and the colleague's number answers
251+
// with no row. The admin search for the same number is the control that the
252+
// term really matches the stored value when the column is scanned.
253+
const searchIds = async (who: string, term: string) => {
254+
const res = await stack.apiAs(token[who], 'GET', `/data/sys_user?$search=${encodeURIComponent(term)}&$top=100`);
255+
return { status: res.status, ids: rowsOf(await res.json()).map((r) => String(r.id)) };
256+
};
257+
const COLLEAGUE_NAME = 'Org Peer colleague';
258+
const COLLEAGUE_PHONE_DIGITS = String(SYNTHETIC.phone_number).replace(/^\+/, '');
259+
260+
it('a member user-picker search by a colleague name is served the colleague', async () => {
261+
const { status, ids } = await searchIds('member', COLLEAGUE_NAME);
262+
expect(status).toBe(200);
263+
expect(ids).toContain(uid.colleague);
264+
});
265+
266+
it('a member search by a colleague sign-in phone number is answered with no row, not refused', async () => {
267+
const { status, ids } = await searchIds('member', COLLEAGUE_PHONE_DIGITS);
268+
expect(status).toBe(200);
269+
expect(ids).toEqual([]);
270+
});
271+
272+
it('the platform admin search by the same phone number is served the colleague (the control for the empty answer above)', async () => {
273+
const { status, ids } = await searchIds('admin', COLLEAGUE_PHONE_DIGITS);
274+
expect(status).toBe(200);
275+
expect(ids).toContain(uid.colleague);
276+
});
277+
278+
it.each(['owner', 'admin'])('the %s reads the colleague sign-in phone fields as stored', async (who) => {
279+
const { status, row } = await readRow(who, 'colleague');
280+
expect(status).toBe(200);
281+
for (const field of SIGN_IN_PHONE) expect(row[field], field).toEqual(SYNTHETIC[field]);
282+
});
283+
228284
it('through the activity door the member is served the colleague rows with no group key in any recorded change', async () => {
229285
const { status, rows } = await activityRows('member', 'colleague');
230286
expect(status).toBe(200);

0 commit comments

Comments
 (0)