Skip to content

Commit c5f4a06

Browse files
test(ui): live e2e signs in a read-not-write member and asserts the Console refuses coherently (#27552) (#27768)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg4i9JiKjEjwtuVzd4gKy9hB)_ ## 维护者速读 - 改了什么:实时界面测试(Live E2E)以前只用管理员登录,现在多了一个「能看不能改」的普通成员身份,并新增一条场景:成员打开管理员的询价记录时,编辑框只读并写明「你没有权限编辑这条记录」、没有保存按钮,记录页上没有「编辑」按钮也没有双击编辑;管理员对照组能编辑同一条记录并保存成功。 - 为什么改:权限类缺陷(#27138、#27146、#27147)是 Console 和服务端之间最常见的合并后才发现的问题,管理员什么都能过,所以原来的实时测试看不到这一类。 - 风险与回滚:只加测试文件和 nightly 列表里的一项,不改产品代码、不改种子数据、不进 CI 必跑车道;回滚即 revert 本 PR。 - 先看:`ui/e2e/live/read-only-member-record.spec.ts`(三步断言)、`ui/e2e/live/member.ts`(管理员建成员并登录)、`ui/package.json` 的 `test:e2e:live:nightly` 一行。 - 两分钟验证:起实时栈后在 `ui/` 跑 `pnpm exec playwright test --config=playwright.live.config.ts read-only-member-record.spec.ts`,预期 1 passed(本地约 38 秒)。 - 需要你做的:无,席位落地。 Fixes #27552 Clause-②: no Metadata-protocol: none ## What changes - **A second, non-admin identity for the live harness** (`ui/e2e/live/member.ts`). `signInMember(adminRequest, permissionSet)` has the admin create a member holding one of the showcase's own permission sets through `POST /api/v1/auth/admin/create-user` (password, `mustChangePassword: false`, `permissionSet`), signs it in, and returns a storageState with the same two halves `global-setup.ts` writes for the admin (the API-origin cookie and the APP-origin `auth-session-token`). One member per set at a fixed address, so a re-run against a long-lived backend reuses it (`USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` is accepted). The state is returned, not written to disk, so no path needs rooting. - **A new live spec** (`ui/e2e/live/read-only-member-record.spec.ts`). The member holds `showcase_manager`: on `showcase_inquiry` (OWD `private`) it reads org-wide and writes only its own rows. The admin creates an inquiry, so the member may read it and may not write it: a row verdict, the shape #27138 had. In order: 1. premise through the server: the member's GET answers 200, its PATCH answers 403 `PERMISSION_DENIED`; 2. as the member, `?form=ID` opens the edit dialog with `closed-affordance-notice` visible, `name`/`email`/`company`/`message` disabled and no submit button in `modal-form-footer`; the record page under it has no Edit button and no inline-edit pencils; the member's browser sent no non-GET request to the object; 3. control, as the admin: the same record page offers Edit and the pencils (the same locators step 2 finds absent), the same form has no notice and an enabled field, and a save through the form answers 2xx and the record reads back the new value. - **Nightly only** (`ui/package.json`): the spec joins `test:e2e:live:nightly`. Per the card's promotion rule it moves to `test:e2e:live:ci` only after 3/3 consecutive green on the live pair plus 5/5 of the enlarged allowlist, inside #27550's 2-minute budget. ## Tests Live stack in this container: this tree's built CLI serving `examples/app-showcase` through `ui/e2e/live/ci/start-backend.sh` (`objectstack dev --seed-admin --fresh`, port 4127), the Console from `ui/apps/console` on vite (port 5327) proxying to it. <details><summary>Spec runs: green 3/3 consecutive, about 38 s each on the vite dev server</summary> ``` run 1 exit 0 1 passed (39.9s) run 2 exit 0 1 passed (39.3s) run 3 exit 0 1 passed (38.3s) ``` Runs 2 and 3 took the member-already-exists path. </details> <details><summary>The spec can fail: the row write verdict removed, the spec goes red</summary> Mutation through `scripts/ablation-replace.mjs` on `ui/packages/permissions/src/RecordFieldAccessScope.tsx` (vite serves source, so no build was needed): the anchor `answer?.writable === false ? 'record-not-writable'` replaced so the projection's `writable: false` is ignored, which is the #27138 shape (the dialog offers editable fields on a row the server will refuse). ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob 948fb29 -> 93c6ea3bfde0 ✘ … › as the member: a read-only form with a named notice, no edit affordance, no write sent Error: expect(locator).toBeVisible() failed Locator: getByRole('dialog').getByTestId('closed-affordance-notice') Error: element(s) not found ablation-replace: ok restored: blob == HEAD (948fb29) and `git diff HEAD` is empty ``` </details> <details><summary>What was measured before writing the spec</summary> - `POST /api/v1/auth/sign-up/email` on the dev server answers 403 `SELF_REGISTRATION_CLOSED` (audience posture `invite_only`), so a sign-up step in the global setup cannot make the member; the admin's create-user door can. - As the `showcase_manager` member: GET of an admin-owned inquiry 200, PATCH 403 `PERMISSION_DENIED` ("You do not have access to this record…"); PATCH of a project 403 (object-level, from the `everyone` baseline's read-only grant). - Console as the member on that inquiry: header shows `More actions` and `Timeline` only (no Edit), the `⋯` menu holds Refresh / Add to favorites / Share (no Delete), no inline-edit pencils; `?form=ID` draws the dialog read-only with "You don't have permission to edit this record. The fields are read-only." and Cancel / Close only. As the admin: Edit, six pencils, Delete in the menu, the dialog editable with Update. </details> - `pnpm --dir ui run type-check:e2e` exit 0; `tsc --listFiles` includes both new files. - `pnpm --dir ui exec vitest run scripts/__tests__/live-e2e-storage-state-roots-9519.test.ts scripts/__tests__/check-test-path-roots.test.ts scripts/__tests__/e2e-type-check.test.ts`: 3 files, 43 tests passed. - `pnpm --dir ui exec eslint e2e/live/member.ts e2e/live/read-only-member-record.spec.ts` exit 0; `node ui/scripts/check-test-path-roots.mjs` OK. <details><summary>Derived gates on head 3937f50: 31 derived, 31 run, all exit 0, plus check-sdui-manifest exit 0</summary> `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE`: 31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN. ``` node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-osv-exemptions.mjs :: exit 0 node scripts/check-osv-exemptions.mjs --self-test :: exit 0 node scripts/osv-base-relative.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:llms-txt :: exit 0 pnpm check:changeset-fixed :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:manifest-repository-directory :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:override-consistency :: exit 0 pnpm check:pnpm-flag-spellings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:turbo-task-graph :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-export-contract-resolve :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm check:workspace-manifest-cycles :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 ``` </details> ## Acceptance notes - The card offered two ways to make the member: a seeded member in `examples/app-showcase`, or a sign-up step in `global-setup.ts` with a second storageState. Sign-up is closed on the dev server (measured above), and a seed change is not needed: the admin create-user door with the showcase's existing `showcase_manager` set gives the member and the read-not-write row. So neither `global-setup.ts` nor the showcase changed. The member is made by the one spec that needs it rather than by the global setup, so the required `Live E2E UI` lane, which shares the global setup, pays nothing for it while the spec is nightly-only. It also keeps this PR off `global-setup.ts`, which sibling #27551 may touch. When the spec is promoted, moving `signInMember` into the global setup is a one-call change if a second spec wants the identity. - `ui/e2e/live/member.ts` is a new file beside the spec, under the claimed `ui/e2e/live/` surface; it is the "second identity" half of the card's Task 1 kept reusable for the next access scenario (#27146 / #27147 shapes). - Scope chosen for the record: a ROW verdict (object allows edit, this row refuses), because that is the #27138 escape. The object-level refusal (the member's read-only grant on `showcase_project`) was measured and left out of the spec; the existing unit suites already pin the object-level form lock (objectui#11000). - Durations: about 38 s per run against the vite dev server here, including the cold compile of three page loads. That alone would take a third of #27550's 2-minute budget, so the promotion decision should re-measure it on the CI lane's `vite preview` build. - No published package changes (`ui/package.json` is the private workspace root), so no changeset. - Harness PR step skipped: per the dispatch order, this PR carries no assignee and no review request. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CbDmdfNUqBtfbQtu5T)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent ca167f6 commit c5f4a06

3 files changed

Lines changed: 229 additions & 1 deletion

File tree

‎ui/e2e/live/member.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { request, expect, type APIRequestContext } from '@playwright/test';
2+
3+
/**
4+
* A second, non-admin identity for the live suite (objectstack#27552).
5+
*
6+
* The global setup signs in as the seeded admin only, and the admin passes
7+
* every access check, so no spec signed in that way can see a Console⇄server
8+
* access defect. This module makes a member who holds ONE of the showcase's own
9+
* permission sets (plus the `everyone` baseline the dev server adds), signs it
10+
* in, and hands back a storageState a spec opens a browser context with.
11+
*
12+
* Why the admin creates the member rather than the member signing up: the dev
13+
* server's audience posture is `invite_only`, so `POST /auth/sign-up/email`
14+
* answers 403 `SELF_REGISTRATION_CLOSED` (measured on `objectstack dev --fresh`).
15+
* `POST /auth/admin/create-user` takes a password and the permission set to
16+
* grant in one call, which is the Setup "New user" door.
17+
*
18+
* One member per permission set, at a fixed address, so a re-run against a
19+
* long-lived backend signs the same member in again instead of piling up
20+
* users: an "already exists" answer is accepted. Whether that member really
21+
* holds the set is not taken on trust here; a spec states the access it relies
22+
* on through the server first (read answered, write refused), so a member
23+
* created some other way fails there, by name.
24+
*
25+
* The storageState has the same two halves `global-setup.ts` writes for the
26+
* admin: the better-auth cookie for the API origin and the Bearer token the
27+
* console adapter reads from the APP origin's localStorage. It is returned,
28+
* never written to disk, so no path has to be rooted.
29+
*/
30+
const APP = process.env.LIVE_APP_URL || 'http://localhost:5180';
31+
const API = process.env.LIVE_API_URL || 'http://localhost:3000';
32+
const MEMBER_PASSWORD = 'live-e2e-member-1';
33+
34+
export type MemberStorageState = {
35+
cookies: Awaited<ReturnType<APIRequestContext['storageState']>>['cookies'];
36+
origins: { origin: string; localStorage: { name: string; value: string }[] }[];
37+
};
38+
39+
export interface LiveMember {
40+
email: string;
41+
/** The Bearer token the member's own API calls send. */
42+
token: string;
43+
/** Open a browser context with this to act as the member. */
44+
storageState: MemberStorageState;
45+
}
46+
47+
/**
48+
* Create (or reuse) the member holding `permissionSet` and sign it in.
49+
* `admin` is a request context signed in as an administrator: a spec's
50+
* `request` fixture, which carries the global setup's admin cookie.
51+
*/
52+
export async function signInMember(admin: APIRequestContext, permissionSet: string): Promise<LiveMember> {
53+
const email = `live-e2e.${permissionSet}@objectos.ai`;
54+
const created = await admin.post(`${API}/api/v1/auth/admin/create-user`, {
55+
data: {
56+
email,
57+
password: MEMBER_PASSWORD,
58+
name: `Live E2E ${permissionSet}`,
59+
mustChangePassword: false,
60+
permissionSet,
61+
},
62+
});
63+
if (!created.ok()) {
64+
const body = await created.json().catch(() => ({}));
65+
expect(
66+
body?.error?.code,
67+
`create-user for ${email} answered ${created.status()}: ${JSON.stringify(body)}`,
68+
).toBe('USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL');
69+
}
70+
71+
const ctx = await request.newContext();
72+
try {
73+
const res = await ctx.post(`${API}/api/v1/auth/sign-in/email`, {
74+
data: { email, password: MEMBER_PASSWORD },
75+
headers: { 'Content-Type': 'application/json' },
76+
});
77+
expect(res.ok(), `member sign-in for ${email} answered ${res.status()}`).toBe(true);
78+
const token = res.headers()['set-auth-token'];
79+
expect(token, 'member sign-in returned a `set-auth-token` header').toBeTruthy();
80+
const { cookies } = await ctx.storageState();
81+
return {
82+
email,
83+
token,
84+
storageState: {
85+
cookies,
86+
origins: [{ origin: APP, localStorage: [{ name: 'auth-session-token', value: token }] }],
87+
},
88+
};
89+
} finally {
90+
await ctx.dispose();
91+
}
92+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
import { signInMember } from './member';
3+
4+
/**
5+
* objectstack#27552 — a member who may READ a record but not WRITE it is
6+
* refused coherently by the Console, against the real server.
7+
*
8+
* Access is the defect class the admin-only live suite cannot see: the seeded
9+
* admin passes every check. #27138 is the escape this spec stands guard over:
10+
* the edit dialog offered editable fields on a row the caller could read but
11+
* not write, and the save was refused whole.
12+
*
13+
* The member holds the showcase's own `showcase_manager` set: on
14+
* `showcase_inquiry` (OWD `private`) it reads org-wide but writes only its own
15+
* rows (`readScope: 'org'`, `writeScope: 'own'`). An inquiry the admin creates
16+
* is therefore readable and not writable for the member: a ROW verdict, not an
17+
* object-level one, which is the shape #27138 had.
18+
*
19+
* What is asserted, in order:
20+
* 1. the premise, through the server: the member's read of the record answers
21+
* and its update is refused (`PERMISSION_DENIED`, 403), so the UI half below
22+
* can never pass on a record the member could write after all;
23+
* 2. as the member, the edit form opened by URL (`?form=`) says the record is
24+
* read-only (`closed-affordance-notice`), draws every field disabled and
25+
* offers no submit; the record page under it offers neither the Edit button
26+
* nor the inline-edit pencils; and the member's browser sent no write;
27+
* 3. control, as the admin: the same form on the same record has no notice,
28+
* the same record page offers Edit and the pencils (so step 2's absences are
29+
* not a selector that finds nothing), and a save through the form persists.
30+
*/
31+
const API = process.env.LIVE_API_URL || 'http://localhost:3000';
32+
const OBJECT = 'showcase_inquiry';
33+
const recordPath = (id: string) => `/apps/showcase_app/${OBJECT}/record/${id}`;
34+
const formPath = (id: string) => `${recordPath(id)}?form=${encodeURIComponent(id)}`;
35+
36+
// The record header's Edit CTA and the record body's inline-edit pencils carry
37+
// no test id; their accessible names are the `detail.edit` and
38+
// `detail.editInlineHint` strings, en or zh.
39+
const editButton = (page: Page) => page.getByRole('button', { name: /^(Edit|编辑)$/ });
40+
const inlineEditPencils = (page: Page) => page.getByRole('button', { name: /^(Double-click to edit|双击编辑)$/ });
41+
42+
test.use({ viewport: { width: 1440, height: 900 } });
43+
44+
test('a member who may read but not write an inquiry gets a read-only form and no edit affordance; the admin edits it', async ({
45+
browser,
46+
page,
47+
request,
48+
playwright,
49+
}) => {
50+
// Three page loads in two browser contexts, one of them cold.
51+
test.setTimeout(120_000);
52+
// The record: created by the admin, so the admin owns it.
53+
const name = `Read-only member e2e ${Date.now()}`;
54+
const createRes = await request.post(`${API}/api/v1/data/${OBJECT}`, {
55+
data: { name, email: 'read-only-member@example.com', message: 'Created by the live access spec.' },
56+
});
57+
expect(createRes.ok(), `admin create answered ${createRes.status()}`).toBe(true);
58+
const created = await createRes.json();
59+
const id: string = created.id ?? created.record?.id ?? created.data?.id;
60+
expect(id, 'the created inquiry id').toBeTruthy();
61+
62+
const member = await signInMember(request, 'showcase_manager');
63+
64+
await test.step('premise: the server lets the member read the record and refuses its update', async () => {
65+
const api = await playwright.request.newContext({ extraHTTPHeaders: { Authorization: `Bearer ${member.token}` } });
66+
try {
67+
const read = await api.get(`${API}/api/v1/data/${OBJECT}/${id}`);
68+
expect(read.status(), 'the member reads the admin-owned inquiry').toBe(200);
69+
const write = await api.patch(`${API}/api/v1/data/${OBJECT}/${id}`, { data: { company: 'Member write' } });
70+
expect(write.status(), 'the member update is refused').toBe(403);
71+
expect((await write.json()).code).toBe('PERMISSION_DENIED');
72+
} finally {
73+
await api.dispose();
74+
}
75+
});
76+
77+
await test.step('as the member: a read-only form with a named notice, no edit affordance, no write sent', async () => {
78+
const context = await browser.newContext({
79+
storageState: member.storageState,
80+
baseURL: test.info().project.use.baseURL,
81+
viewport: { width: 1440, height: 900 },
82+
});
83+
try {
84+
const memberPage = await context.newPage();
85+
const writes: string[] = [];
86+
memberPage.on('request', (r) => {
87+
if (r.method() !== 'GET' && r.url().includes(`/api/v1/data/${OBJECT}`)) writes.push(`${r.method()} ${r.url()}`);
88+
});
89+
90+
await memberPage.goto(formPath(id));
91+
const dialog = memberPage.getByRole('dialog');
92+
await expect(dialog).toBeVisible({ timeout: 30_000 });
93+
// The notice is drawn from the server's row verdict, so once it shows the
94+
// verdict has arrived and the absences below are not read too early.
95+
await expect(dialog.getByTestId('closed-affordance-notice')).toBeVisible({ timeout: 30_000 });
96+
for (const field of ['name', 'email', 'company', 'message']) {
97+
await expect(dialog.locator(`[name="${field}"]`), `${field} is drawn read-only`).toBeDisabled();
98+
}
99+
await expect(dialog.getByTestId('modal-form-footer').locator('button[type="submit"]')).toHaveCount(0);
100+
101+
await memberPage.goto(recordPath(id));
102+
await expect(memberPage.getByRole('heading', { name, exact: true })).toBeVisible({ timeout: 30_000 });
103+
await expect(memberPage.getByRole('button', { name: /^(More actions|更多操作)$/ })).toBeVisible();
104+
await expect(editButton(memberPage)).toHaveCount(0);
105+
await expect(inlineEditPencils(memberPage)).toHaveCount(0);
106+
107+
expect(writes, 'the member browser attempted no write').toEqual([]);
108+
} finally {
109+
await context.close();
110+
}
111+
});
112+
113+
await test.step('control, as the admin: the same form is editable and its save persists', async () => {
114+
await page.goto(recordPath(id));
115+
await expect(editButton(page)).toBeVisible({ timeout: 30_000 });
116+
await expect(inlineEditPencils(page).first()).toBeVisible();
117+
118+
await page.goto(formPath(id));
119+
const dialog = page.getByRole('dialog');
120+
await expect(dialog).toBeVisible({ timeout: 30_000 });
121+
const company = dialog.locator('input[name="company"]');
122+
await expect(company).toBeEnabled({ timeout: 30_000 });
123+
await expect(dialog.getByTestId('closed-affordance-notice')).toHaveCount(0);
124+
125+
const value = `Admin edit ${Date.now()}`;
126+
await company.fill(value);
127+
const saved = page.waitForResponse(
128+
(r) => r.request().method() !== 'GET' && r.url().includes(`/api/v1/data/${OBJECT}/${id}`),
129+
);
130+
await dialog.getByTestId('modal-form-footer').locator('button[type="submit"]').click();
131+
expect((await saved).ok(), 'the admin save answered 2xx').toBe(true);
132+
133+
const after = await (await request.get(`${API}/api/v1/data/${OBJECT}/${id}`)).json();
134+
expect((after.record ?? after.data ?? after).company).toBe(value);
135+
});
136+
});

‎ui/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@
112112
"test:e2e:ui": "playwright test --ui",
113113
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
114114
"test:e2e:live:ci": "playwright test --config=playwright.live.config.ts screen-flow.spec.ts action-modal.spec.ts master-detail.spec.ts saved-view-filter.spec.ts studio-object-designer.spec.ts studio-editor.spec.ts",
115-
"test:e2e:live:nightly": "playwright test --config=playwright.live.config.ts cascading-options.spec.ts declared-sharing-rules-reach-13091.spec.ts field-conditional-rules.spec.ts form-view-subforms.spec.ts grid-conditional-rules.spec.ts grid-file-upload.spec.ts grid-parent-rules.spec.ts inline-edit-polish-2572.spec.ts list-row-action-cel.spec.ts record-history-display.spec.ts required-when-submit.spec.ts summary-rollup.spec.ts user-filters.spec.ts",
115+
"test:e2e:live:nightly": "playwright test --config=playwright.live.config.ts cascading-options.spec.ts declared-sharing-rules-reach-13091.spec.ts field-conditional-rules.spec.ts form-view-subforms.spec.ts grid-conditional-rules.spec.ts grid-file-upload.spec.ts grid-parent-rules.spec.ts inline-edit-polish-2572.spec.ts list-row-action-cel.spec.ts read-only-member-record.spec.ts record-history-display.spec.ts required-when-submit.spec.ts summary-rollup.spec.ts user-filters.spec.ts",
116116
"test:e2e:import-harness": "playwright test --config=playwright.import-harness.config.ts",
117117
"test:e2e:import-console": "playwright test --config=playwright.import-console.config.ts"
118118
},

0 commit comments

Comments
 (0)