From 2ba4d44d96c26a73b43246d2e3c37ea876928635 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:08:18 +0000 Subject: [PATCH 1/4] test(dogfood): measure sys_member transfer_ownership and the caller's step-down on a booted stack Claude-Session: https://claude.ai/code/session_01Jjomb6DDwUaLVDFvej7759 Co-authored-by: Claude --- ...ansfer-ownership-step-down.dogfood.test.ts | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts diff --git a/packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts b/packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts new file mode 100644 index 00000000000..c4c3ed3ebe4 --- /dev/null +++ b/packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts @@ -0,0 +1,190 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_member.transfer_ownership` says what its door does — measured on a real + * boot, on the wire, by reading both `sys_member` rows after every write. + * + * The action sends better-auth's `POST /organization/update-member-role` with + * `{ memberId: TARGET, role: 'owner' }`. That endpoint writes the TARGET's role + * and nothing else: the caller stays an owner, so the organization ends with + * two. The action used to promise the caller "You will be demoted to admin"; + * nothing demoted anyone. Its copy now says the selected member becomes an + * owner and the caller keeps their role, and names the step that really steps + * the caller down: Change Role (`update_member_role`) on their own row. + * + * ## What this measures, in one organization beside a second one + * + * 1. the SERVED copy of `transfer_ownership` (`GET /meta/object/sys_member`): + * no transfer, no demotion promised; the step-down named; + * 2. the action's own request, as an owner, on a plain member: the target + * becomes an owner and the caller is STILL an owner — both rows read; + * 3. the step-down the copy names: the served `update_member_role` predicate + * offers it on the caller's OWN row, and the same door, sent by the caller + * for their own row with `role: 'admin'`, steps them down — both rows read; + * 4. the vendor's last-owner guard: the new sole owner cannot step down the + * same way, refused with the vendor's own code, and its row stays `owner`. + * + * The second organization holds both principals with other grades; its rows + * are read last and are unchanged, so every write above is the organization's + * own and not a per-user one. + * + * Harness note: `bootStack` boots the production `single` shape (ADR-0131 D3); + * the multi-organization runtime (`@objectstack/organizations`) is not a + * dependency of this suite, so its gates skip here (`enterprise-organizations.ts`). + * The two organizations are rows written in system context, the shape + * `org-admin-affordance-reach.dogfood.test.ts` uses — the only writer + * better-auth-managed tables accept (ADR-0092). The door under test is the + * vendor's own `update-member-role` either way. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { celEngine } from '@objectstack/formula'; +import { bootShowcase } from './showcase-boot.js'; + +const SYSTEM_CTX = { isSystem: true }; + +type ServedAction = { + name: string; + label?: string; + confirmText?: string; + successMessage?: string; + target?: string; + bodyExtra?: Record; + visible?: unknown; +}; + +async function findRows(ql: any, object: string, where: any, limit = 50): Promise { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); +} + +/** The sign-up reconciler's membership row lands after the signup transaction. */ +async function waitForMembership(ql: any, userId: string): Promise { + for (let i = 0; i < 40; i++) { + const rows = await findRows(ql, 'sys_member', { user_id: userId }, 5); + if (rows.length > 0) return rows[0]; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`no sys_member row appeared for ${userId}`); +} + +describe('sys_member.transfer_ownership makes the target an owner; the caller steps down through Change Role', () => { + let stack: VerifyStack; + let ql: any; + let orgId: string; + let otherOrgId: string; + const owner = {} as { token: string; userId: string; rowId: string }; + const target = {} as { token: string; userId: string; rowId: string }; + let served: Map; + let features: Record; + + /** Both principals' roles in an organization, read from storage. */ + const rolesIn = async (organizationId: string) => { + const rows = await findRows(ql, 'sys_member', { organization_id: organizationId }, 50); + const roleOf = (userId: string) => rows.find((r) => String(r.user_id) === userId)?.role; + return { owner: roleOf(owner.userId), target: roleOf(target.userId) }; + }; + + /** The action's request as the console sends it: the row id under `recordIdParam`, plus `bodyExtra`. */ + const updateMemberRole = (token: string, memberId: string, role: string) => + stack.apiAs(token, 'POST', '/auth/organization/update-member-role', { memberId, role, organizationId: orgId }); + + beforeAll(async () => { + stack = await bootShowcase({}); + await stack.signIn(); // the seeded dev admin: boots the identity plane + ql = await stack.kernel.getServiceAsync('objectql'); + + // Two signed-up principals, neither a platform admin, so the verdicts + // below are the membership grade's alone. + for (const [who, email] of [[owner, 'transfer.owner@example.com'], [target, 'transfer.target@example.com']] as const) { + who.token = await stack.signUp(email, 'Transfer!Pass123', email); + const [user] = await findRows(ql, 'sys_user', { email }, 1); + who.userId = String(user.id); + await waitForMembership(ql, who.userId); + } + + const org = await ql.insert('sys_organization', { name: 'Transfer Org', slug: 'transfer-org' }, { context: SYSTEM_CTX }); + orgId = String(org.id); + const other = await ql.insert('sys_organization', { name: 'Transfer Other Org', slug: 'transfer-other-org' }, { context: SYSTEM_CTX }); + otherOrgId = String(other.id); + + owner.rowId = String((await ql.insert('sys_member', { user_id: owner.userId, organization_id: orgId, role: 'owner' }, { context: SYSTEM_CTX })).id); + target.rowId = String((await ql.insert('sys_member', { user_id: target.userId, organization_id: orgId, role: 'member' }, { context: SYSTEM_CTX })).id); + // The other organization: the same two people, the grades reversed. + await ql.insert('sys_member', { user_id: owner.userId, organization_id: otherOrgId, role: 'member' }, { context: SYSTEM_CTX }); + await ql.insert('sys_member', { user_id: target.userId, organization_id: otherOrgId, role: 'owner' }, { context: SYSTEM_CTX }); + + const meta = await stack.apiAs(owner.token, 'GET', '/meta/object/sys_member'); + expect(meta.status).toBe(200); + const body = (await meta.json()) as { item?: { actions?: ServedAction[] } }; + served = new Map((body.item?.actions ?? []).map((a) => [a.name, a])); + + const config = await stack.apiAs(owner.token, 'GET', '/auth/config'); + expect(config.status).toBe(200); + features = ((await config.json()) as { data: { features: Record } }).data.features; + }, 240_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('the served copy promises what the door does: the target becomes an owner, the caller keeps their role', () => { + const action = served.get('transfer_ownership'); + expect(action, 'transfer_ownership is served').toBeDefined(); + // The request is unchanged: better-auth's update-member-role, role owner. + expect(action!.target).toBe('/api/v1/auth/organization/update-member-role'); + expect(action!.bodyExtra).toEqual({ role: 'owner' }); + + const copy = [action!.label, action!.confirmText, action!.successMessage].join(' | '); + // No demotion and no transfer is promised anywhere in the copy. + expect(copy).not.toMatch(/demot/i); + expect(copy).not.toMatch(/transfer/i); + expect(action!.label).toBe('Make Owner'); + // The confirm names the outcome and the real step-down, by the step-down's own label. + expect(action!.confirmText).toContain('You stay an owner'); + expect(action!.confirmText).toContain(served.get('update_member_role')?.label ?? 'update_member_role is not served'); + }); + + it('the action, as an owner, makes the target an owner and leaves the caller an owner', async () => { + expect(await rolesIn(orgId)).toEqual({ owner: 'owner', target: 'member' }); + + const res = await updateMemberRole(owner.token, target.rowId, 'owner'); + expect(res.status, await res.clone().text()).toBe(200); + + // Both rows: two owners. The caller was not demoted. + expect(await rolesIn(orgId)).toEqual({ owner: 'owner', target: 'owner' }); + }, 60_000); + + it('Change Role is offered on the caller\'s own row, and steps the previous owner down to admin', async () => { + const changeRole = served.get('update_member_role'); + expect(changeRole?.target).toBe('/api/v1/auth/organization/update-member-role'); + + // The console's verdict for the caller on their OWN row, from the served + // predicate and the caller's served session. + const session = await stack.apiAs(owner.token, 'GET', '/auth/get-session'); + expect(session.status).toBe(200); + const user = ((await session.json()) as { user: Record }).user; + const [ownRow] = await findRows(ql, 'sys_member', { id: owner.rowId }, 1); + const verdict = celEngine.evaluate(changeRole!.visible as never, { record: ownRow, user: user as never, extra: { features } }); + if (!verdict.ok) throw new Error(`update_member_role faulted: ${JSON.stringify(verdict)}`); + expect(verdict.value, 'Change Role is offered on the caller\'s own row').toBe(true); + + const res = await updateMemberRole(owner.token, owner.rowId, 'admin'); + expect(res.status, await res.clone().text()).toBe(200); + + // Both rows: the previous owner is an admin, the new owner is the owner. + expect(await rolesIn(orgId)).toEqual({ owner: 'admin', target: 'owner' }); + }, 60_000); + + it('the vendor keeps one owner: the sole owner cannot step down the same way', async () => { + const res = await updateMemberRole(target.token, target.rowId, 'admin'); + expect(res.status).toBe(400); + expect(((await res.json()) as { code?: string }).code).toBe('YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER'); + expect(await rolesIn(orgId)).toEqual({ owner: 'admin', target: 'owner' }); + }, 60_000); + + it('the other organization\'s rows are untouched', async () => { + expect(await rolesIn(otherOrgId)).toEqual({ owner: 'member', target: 'owner' }); + }); +}); From 2df8231c88427c23e854b7e5144d4e6d62557e7e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:17:53 +0000 Subject: [PATCH 2/4] fix(platform-objects): sys_member transfer_ownership says the target becomes an owner and the caller keeps their role better-auth's update-member-role writes only the target's role, so the caller was never demoted to admin as the confirm promised. The label, confirm, success message and comment now say what the door does and name Change Role on one's own row as the step-down. Claude-Session: https://claude.ai/code/session_01Jjomb6DDwUaLVDFvej7759 Co-authored-by: Claude --- .../src/identity/sys-member.object.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 8429dce5b16..c3487c977c2 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -227,15 +227,22 @@ export const SysMember = ObjectSchema.create({ successMessage: 'Member removed', refreshAfter: true, }, - // Transfer ownership is modeled as `update-member-role` with role=owner - // (better-auth's organization plugin auto-demotes the previous owner - // to admin). Kept as a separate action so the row menu can present a - // distinct destructive-style affordance with the right confirm copy — - // mixing it into `update_member_role` would hide the ownership-handoff - // semantics behind a generic role dropdown. + // Make Owner is `update-member-role` with role=owner, and better-auth's + // `updateMemberRole` writes the TARGET's role and nothing else: the caller + // stays an owner, so the organization ends with two. The copy promises + // exactly that. Stepping down is the caller's own Change Role + // (`update_member_role`) on their own row, which the confirm names; the + // vendor refuses it only while the caller is the last owner + // (`YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER`). Measured on a + // booted stack: `member-transfer-ownership-step-down.dogfood.test.ts`. + // Kept as a separate action so the row menu can present a distinct + // danger-variant affordance with its own confirm — granting the creator + // role hidden in `update_member_role`'s generic role dropdown would be + // easy to miss. The `name` stays `transfer_ownership`: it is the action's + // id and its MEMBERSHIP_REACH row's key, which consumers name. { name: 'transfer_ownership', - label: 'Transfer Ownership', + label: 'Make Owner', icon: 'crown', variant: 'danger', mode: 'custom', @@ -256,8 +263,8 @@ export const SysMember = ObjectSchema.create({ visible: "has(record.role) && record.role != 'owner'", requiresFeature: 'organization', requiresMembershipReach: 'transfer_ownership', - confirmText: 'Transfer ownership of this organization to the selected member? You will be demoted to admin and lose owner-only privileges.', - successMessage: 'Ownership transferred', + confirmText: 'Make the selected member an owner of this organization? They get every owner privilege, including deleting the organization. You stay an owner too; to step down, use Change Role on your own membership.', + successMessage: 'Member is now an owner', refreshAfter: true, }, ], From f7c5c63e42cab0bad30148ae24c053b3a8b85926 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:19:33 +0000 Subject: [PATCH 3/4] chore(platform-objects): regenerate the en objects bundle and re-translate transfer_ownership's copy en is regenerated by check-i18n-bundles --write; the zh-CN, ja-JP and es-ES values are hand-written translations, which merge mode keeps, so they are re-translated to the same honest copy. Claude-Session: https://claude.ai/code/session_01Jjomb6DDwUaLVDFvej7759 Co-authored-by: Claude --- .../src/apps/translations/en.objects.generated.ts | 6 +++--- .../src/apps/translations/es-ES.objects.generated.ts | 6 +++--- .../src/apps/translations/ja-JP.objects.generated.ts | 6 +++--- .../src/apps/translations/zh-CN.objects.generated.ts | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 7a0942cb959..785d046f9a3 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -688,9 +688,9 @@ export const enObjects: NonNullable = { successMessage: "Member removed" }, transfer_ownership: { - label: "Transfer Ownership", - confirmText: "Transfer ownership of this organization to the selected member? You will be demoted to admin and lose owner-only privileges.", - successMessage: "Ownership transferred" + label: "Make Owner", + confirmText: "Make the selected member an owner of this organization? They get every owner privilege, including deleting the organization. You stay an owner too; to step down, use Change Role on your own membership.", + successMessage: "Member is now an owner" } } }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index bc65351b76c..0e2107e022a 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -688,9 +688,9 @@ export const esESObjects: NonNullable = { successMessage: "Miembro eliminado" }, transfer_ownership: { - label: "Transferir propiedad", - confirmText: "¿Transferir la propiedad de esta organización al miembro seleccionado? Pasarás a ser administrador y perderás los privilegios exclusivos del propietario.", - successMessage: "Propiedad transferida" + label: "Hacer propietario", + confirmText: "¿Hacer propietario de esta organización al miembro seleccionado? Obtendrá todos los privilegios de propietario, incluido eliminar la organización. Tú sigues siendo propietario; para dejar de serlo, usa «Cambiar rol» en tu propia membresía.", + successMessage: "El miembro ahora es propietario" } } }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index f41fb422c2b..78b3569f559 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -688,9 +688,9 @@ export const jaJPObjects: NonNullable = { successMessage: "メンバーを削除しました" }, transfer_ownership: { - label: "所有権の移譲", - confirmText: "この組織の所有権を選択したメンバーに移譲しますか?あなたは管理者に降格され、所有者のみの権限を失います。", - successMessage: "所有権を移譲しました" + label: "所有者にする", + confirmText: "選択したメンバーをこの組織の所有者にしますか?このメンバーには、組織の削除を含む所有者のすべての権限が付与されます。あなたも引き続き所有者です。退く場合は、自分のメンバーシップで「ロールを変更」を使用してください。", + successMessage: "メンバーが所有者になりました" } } }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 2ee763c06f0..6776e74cf58 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -688,9 +688,9 @@ export const zhCNObjects: NonNullable = { successMessage: "成员已移除" }, transfer_ownership: { - label: "转移所有权", - confirmText: "确定要将该组织的所有权转移给所选成员吗?您将被降级为管理员,并失去仅所有者拥有的权限。", - successMessage: "已转移所有权" + label: "设为所有者", + confirmText: "确定要将所选成员设为该组织的所有者吗?对方将获得所有者的全部权限,包括删除该组织。您仍是所有者;如需卸任,请在您自己的成员记录上使用“更改角色”。", + successMessage: "该成员现已成为所有者" } } }, From ccac32c4409f3b4c94816599894a1b908af770c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:20:07 +0000 Subject: [PATCH 4/4] test(platform-objects,dogfood): pin transfer_ownership's honest copy and measure the caller's step-down; changeset Claude-Session: https://claude.ai/code/session_01Jjomb6DDwUaLVDFvej7759 Co-authored-by: Claude --- .changeset/24273-transfer-ownership-honest.md | 19 ++++ .../transfer-ownership-action-copy.test.ts | 90 +++++++++++++++++++ ...ansfer-ownership-step-down.dogfood.test.ts | 7 ++ 3 files changed, 116 insertions(+) create mode 100644 .changeset/24273-transfer-ownership-honest.md create mode 100644 packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts diff --git a/.changeset/24273-transfer-ownership-honest.md b/.changeset/24273-transfer-ownership-honest.md new file mode 100644 index 00000000000..918ea1a8f06 --- /dev/null +++ b/.changeset/24273-transfer-ownership-honest.md @@ -0,0 +1,19 @@ +--- +'@objectstack/platform-objects': patch +--- + +fix(platform-objects): `sys_member`'s `transfer_ownership` row action says the selected member becomes an owner and the caller keeps their role + +The action sends better-auth's `POST /organization/update-member-role` with `role: 'owner'`. That endpoint writes the selected member's role and nothing else, so the caller stays an owner and the organization ends with two. The action's confirm used to promise "You will be demoted to admin and lose owner-only privileges"; nobody was demoted. + +The copy now says what happens, in all four locales: + +- label: FROM "Transfer Ownership" TO "Make Owner"; +- confirm: the selected member becomes an owner with every owner privilege, the caller stays an owner, and to step down the caller uses Change Role on their own membership; +- success message: FROM "Ownership transferred" TO "Member is now an owner". + +To hand ownership over, run Make Owner on the new owner's row, then Change Role on your own row to `admin`. better-auth refuses that second step only while you are the organization's last owner (`YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER`). + +The action's `name`, `target`, request body, parameters, `requiresMembershipReach` and `variant` are unchanged, so a page or test that names `transfer_ownership` keeps working. A test that expected the caller to be `admin` after the action expected a demotion the endpoint never performed: it now reads `owner`, and reads `admin` only after the caller's own Change Role. + +Clause-②: no (provisional: copy only; if the measurement forces another shape, the dev reports before building it; owes the seat's review of record at `TIER_DEFAULT` before the queue) diff --git a/packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts b/packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts new file mode 100644 index 00000000000..2e3e56c0c1b --- /dev/null +++ b/packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `sys_member.transfer_ownership`'s copy says what its door does. +// +// The action sends better-auth's `update-member-role` with `role: 'owner'`, and +// that endpoint writes the TARGET's role and nothing else: the caller stays an +// owner (measured on a booted stack by +// `packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts`). +// The copy used to promise the caller "You will be demoted to admin"; nothing +// demoted anyone, and the organization ended with two owners. So the copy may +// promise neither a demotion nor a transfer, and the confirm must name the step +// that really steps the caller down — Change Role (`update_member_role`) on +// their own row — by that action's own label, in every locale. +// +// The translated bundles are pinned as well: merge mode keeps every existing +// translated value, so a revised English source leaves the old promise served +// in zh-CN / ja-JP / es-ES while `check:i18n` reads "in sync". +import { describe, expect, it } from 'vitest'; +import { enObjects } from '../apps/translations/en.objects.generated.js'; +import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js'; +import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js'; +import { esESObjects } from '../apps/translations/es-ES.objects.generated.js'; +import { SysMember } from './sys-member.object.js'; + +interface CopyAction { + name?: string; + label?: string; + confirmText?: string; + successMessage?: string; + target?: string; + bodyExtra?: Record; + recordIdParam?: string; + variant?: string; + visible?: unknown; +} + +function declared(name: string): CopyAction { + const found = ((SysMember as { actions?: CopyAction[] }).actions ?? []).find((a) => a.name === name); + expect(found, `${name} is declared`).toBeDefined(); + return found as CopyAction; +} + +const bundled = (bundle: unknown, name: string): CopyAction | undefined => + (bundle as Record }>)?.sys_member?._actions?.[name]; + +const LOCALES = [ + ['en', enObjects], + ['zh-CN', zhCNObjects], + ['ja-JP', jaJPObjects], + ['es-ES', esESObjects], +] as const; + +describe('sys_member.transfer_ownership — declared copy', () => { + const action = declared('transfer_ownership'); + + it('keeps the request and the identity consumers name', () => { + expect(action.target).toBe('/api/v1/auth/organization/update-member-role'); + expect(action.bodyExtra).toEqual({ role: 'owner' }); + expect(action.recordIdParam).toBe('memberId'); + expect(action.variant).toBe('danger'); + // `requiresMembershipReach: 'transfer_ownership'`, lowered: the owner alone. + expect(String((action.visible as { source?: string })?.source)).toContain("'org_owner' in current_user.positions"); + }); + + it('promises neither a demotion nor a transfer', () => { + for (const text of [action.label, action.confirmText, action.successMessage]) { + expect(text).not.toMatch(/demot|transfer/i); + } + expect(action.confirmText).toContain('You stay an owner'); + }); + + it('names the step-down by the Change Role action\'s own label', () => { + expect(action.confirmText).toContain(declared('update_member_role').label); + }); +}); + +describe('sys_member.transfer_ownership — every locale bundle', () => { + it.each(LOCALES)('%s names its own Change Role label in the confirm', (_locale, bundle) => { + const copy = bundled(bundle, 'transfer_ownership'); + const changeRole = bundled(bundle, 'update_member_role')?.label; + expect(changeRole, 'update_member_role is translated').toBeTruthy(); + expect(copy?.confirmText).toContain(changeRole); + }); + + it('en is the declared copy', () => { + const copy = bundled(enObjects, 'transfer_ownership'); + const action = declared('transfer_ownership'); + expect(copy).toEqual({ label: action.label, confirmText: action.confirmText, successMessage: action.successMessage }); + }); +}); diff --git a/packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts b/packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts index c4c3ed3ebe4..58ecbeb1a29 100644 --- a/packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts +++ b/packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts @@ -114,6 +114,12 @@ describe('sys_member.transfer_ownership makes the target an owner; the caller st // The other organization: the same two people, the grades reversed. await ql.insert('sys_member', { user_id: owner.userId, organization_id: otherOrgId, role: 'member' }, { context: SYSTEM_CTX }); await ql.insert('sys_member', { user_id: target.userId, organization_id: otherOrgId, role: 'owner' }, { context: SYSTEM_CTX }); + // The session's grade (`positions`) is the ACTIVE organization's, as in the + // console after the organization switcher: activate the one under test. + for (const who of [owner, target]) { + const res = await stack.apiAs(who.token, 'POST', '/auth/organization/set-active', { organizationId: orgId }); + expect(res.status, `activates the organization: ${await res.clone().text()}`).toBe(200); + } const meta = await stack.apiAs(owner.token, 'GET', '/meta/object/sys_member'); expect(meta.status).toBe(200); @@ -165,6 +171,7 @@ describe('sys_member.transfer_ownership makes the target an owner; the caller st const session = await stack.apiAs(owner.token, 'GET', '/auth/get-session'); expect(session.status).toBe(200); const user = ((await session.json()) as { user: Record }).user; + expect(user.positions as string[], 'the caller is an owner on the session face').toContain('org_owner'); const [ownRow] = await findRows(ql, 'sys_member', { id: owner.rowId }, 1); const verdict = celEngine.evaluate(changeRole!.visible as never, { record: ownRow, user: user as never, extra: { features } }); if (!verdict.ok) throw new Error(`update_member_role faulted: ${JSON.stringify(verdict)}`);