Skip to content

Commit 3d90f3d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-9g21fi
2 parents 7b29406 + d77dc07 commit 3d90f3d

2 files changed

Lines changed: 91 additions & 4 deletions

File tree

‎ui/e2e/live/data-writes.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { Request } from '@playwright/test';
2+
3+
/**
4+
* Which browser requests WRITE the records of one object, named route by route.
5+
*
6+
* "Every non-GET under `/api/v1/data/:object` is a write" stopped being true
7+
* when the record page began loading through `POST /api/v1/data/:object/:id/page`:
8+
* a read with a request body, so a detector built on the HTTP method alone
9+
* counted a member's own page load as two writes. The write set is therefore
10+
* NAMED here, so a future read-shaped POST cannot be mistaken for a write,
11+
* and a real write still is.
12+
*
13+
* Source: the route ledgers, `packages/rest/src/rest-route-ledger.ts` (the
14+
* `crud`, `data-actions`, `record-shares` and `batch` families) and
15+
* `packages/runtime/src/route-ledger.ts` (the `/actions` domain). The rows were
16+
* transcribed from them once; nothing re-derives this list, so a write route
17+
* added to a ledger is not watched here until it is added below.
18+
*
19+
* - PATCH, PUT and DELETE anywhere under `/data/:object` are writes by method.
20+
* - A POST is a write only on a route named in `WRITE_POSTS`. The two
21+
* read-shaped POSTs in the ledger, `POST /data/:object/query` and
22+
* `POST /data/:object/:id/page`, are deliberately absent.
23+
* - `POST /batch`, the cross-object transaction, names its objects in the
24+
* body; it writes `object` when one of its operations names it, or when
25+
* the body cannot be read.
26+
*
27+
* Each route also matches under the environment-scoped mirror
28+
* (`/api/v1/environments/:environmentId/...`).
29+
*/
30+
const WRITE_POSTS = [
31+
'POST /api/v1/data/:object',
32+
'POST /api/v1/data/:object/:id/clone',
33+
'POST /api/v1/data/:object/:id/shares',
34+
'POST /api/v1/data/:object/import',
35+
'POST /api/v1/data/:object/import/jobs',
36+
'POST /api/v1/data/:object/batch',
37+
'POST /api/v1/data/:object/createMany',
38+
'POST /api/v1/data/:object/updateMany',
39+
'POST /api/v1/data/:object/deleteMany',
40+
'POST /api/v1/actions/:object/:action',
41+
'POST /api/v1/actions/:object/:action/:recordId',
42+
'POST /api/v1/actions/_activation/:object/:action',
43+
] as const;
44+
45+
const WRITE_METHODS = new Set(['PATCH', 'PUT', 'DELETE']);
46+
const ENV_MIRROR = '(?:/environments/[^/]+)?';
47+
48+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
49+
50+
/** `/api/v1/data/:object/:id` → a pathname regex, `:object` bound to `object`. */
51+
function routePattern(path: string, object: string): RegExp {
52+
const rest = path
53+
.replace(/^\/api\/v1/, '')
54+
.split('/')
55+
.map((seg) => (seg === ':object' ? escape(object) : seg.startsWith(':') ? '[^/]+' : escape(seg)))
56+
.join('/');
57+
return new RegExp(`^/api/v1${ENV_MIRROR}${rest}/?$`);
58+
}
59+
60+
function batchNames(request: Request, object: string): boolean {
61+
let body: unknown;
62+
try {
63+
body = request.postDataJSON();
64+
} catch {
65+
return true;
66+
}
67+
const operations = (body as { operations?: unknown } | null)?.operations;
68+
if (!Array.isArray(operations)) return true;
69+
return operations.some((op) => (op as { object?: unknown } | null)?.object === object);
70+
}
71+
72+
/** A predicate over browser requests: does this one write a record of `object`? */
73+
export function writesRecordsOf(object: string): (request: Request) => boolean {
74+
const dataPrefix = new RegExp(`^/api/v1${ENV_MIRROR}/data/${escape(object)}(?:/|$)`);
75+
const posts = WRITE_POSTS.map((row) => routePattern(row.slice('POST '.length), object));
76+
const crossObjectBatch = new RegExp(`^/api/v1${ENV_MIRROR}/batch/?$`);
77+
return (request) => {
78+
const method = request.method();
79+
const path = new URL(request.url()).pathname;
80+
if (WRITE_METHODS.has(method)) return dataPrefix.test(path);
81+
if (method !== 'POST') return false;
82+
if (posts.some((p) => p.test(path))) return true;
83+
return crossObjectBatch.test(path) && batchNames(request, object);
84+
};
85+
}

‎ui/e2e/live/read-only-member-record.spec.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { test, expect, type Page } from '@playwright/test';
22
import { signInMember } from './member';
3+
import { writesRecordsOf } from './data-writes';
34

45
/**
56
* objectstack#27552 — a member who may READ a record but not WRITE it is
@@ -32,6 +33,9 @@ const API = process.env.LIVE_API_URL || 'http://localhost:3000';
3233
const OBJECT = 'showcase_inquiry';
3334
const recordPath = (id: string) => `/apps/showcase_app/${OBJECT}/record/${id}`;
3435
const formPath = (id: string) => `${recordPath(id)}?form=${encodeURIComponent(id)}`;
36+
// Named write routes, not "any non-GET": the record page itself loads through
37+
// the read `POST /api/v1/data/:object/:id/page` (see `./data-writes`).
38+
const writesInquiry = writesRecordsOf(OBJECT);
3539

3640
// The record header's Edit CTA and the record body's inline-edit pencils carry
3741
// no test id; their accessible names are the `detail.edit` and
@@ -84,7 +88,7 @@ test('a member who may read but not write an inquiry gets a read-only form and n
8488
const memberPage = await context.newPage();
8589
const writes: string[] = [];
8690
memberPage.on('request', (r) => {
87-
if (r.method() !== 'GET' && r.url().includes(`/api/v1/data/${OBJECT}`)) writes.push(`${r.method()} ${r.url()}`);
91+
if (writesInquiry(r)) writes.push(`${r.method()} ${r.url()}`);
8892
});
8993

9094
await memberPage.goto(formPath(id));
@@ -124,9 +128,7 @@ test('a member who may read but not write an inquiry gets a read-only form and n
124128

125129
const value = `Admin edit ${Date.now()}`;
126130
await company.fill(value);
127-
const saved = page.waitForResponse(
128-
(r) => r.request().method() !== 'GET' && r.url().includes(`/api/v1/data/${OBJECT}/${id}`),
129-
);
131+
const saved = page.waitForResponse((r) => writesInquiry(r.request()));
130132
await dialog.getByTestId('modal-form-footer').locator('button[type="submit"]').click();
131133
expect((await saved).ok(), 'the admin save answered 2xx').toBe(true);
132134

0 commit comments

Comments
 (0)