Skip to content

Commit 7730c2f

Browse files
committed
test(dogfood): a member with no sys_file read attaches through the panel's flow on the showcase boot
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
1 parent d2a2cde commit 7730c2f

1 file changed

Lines changed: 225 additions & 0 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The console Attachments panel's flow, at the HTTP level, on the showcase
4+
// boot: presigned upload → raw PUT → the upload-complete door with `attachTo`,
5+
// which commits the file and writes its `sys_attachment` row as one unit, as
6+
// the signed-in member (`FileAttachmentService.attachUploadAs`).
7+
//
8+
// The member holds what a real app grants a member who may attach files: the
9+
// app's own `showcase_contributor` (they read and edit their own invoices) and
10+
// a set with `sys_attachment` read and create, and nothing on `sys_file`. A
11+
// `sys_attachment` create through the generic data door names `file_id` as
12+
// any `lookup` value, which the caller must be able to read, so that member's
13+
// two-step attach (complete, then `POST /data/sys_attachment`) is refused
14+
// `reference_not_found`. The attach on completion vouches for the one upload
15+
// it commits, and is what the panel calls.
16+
//
17+
// Controls: another user's upload id, an id that names no file, a record the
18+
// member may not attach to, the generic door's direct create (still refused),
19+
// and a member whose set reads every object (`'*'`), for whom both flows work.
20+
//
21+
// `showcase_invoice` declares `enable.files`; its select RLS
22+
// (`owner == current_user.email`) hides another user's invoice from a
23+
// contributor.
24+
25+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
26+
import { mkdtempSync, rmSync } from 'node:fs';
27+
import { tmpdir } from 'node:os';
28+
import { join } from 'node:path';
29+
import type { VerifyStack } from '@objectstack/verify';
30+
import type { IDataEngine } from '@objectstack/spec/contracts';
31+
import { StorageServicePlugin } from '@objectstack/service-storage';
32+
import { AuditPlugin } from '@objectstack/plugin-audit';
33+
import { SecurityPlugin, appSecurityPluginOptions, securityDefaultPermissionSets } from '@objectstack/plugin-security';
34+
import { PermissionSetSchema } from '@objectstack/spec/security';
35+
import showcaseStack from '@objectstack/example-showcase';
36+
import { bootShowcase } from './showcase-boot.js';
37+
38+
const SYS = { isSystem: true } as const;
39+
const BYTES = 'hello';
40+
41+
/** The grant an app ships to let members attach files: `sys_attachment` only, nothing on `sys_file`. */
42+
const attacherSet = PermissionSetSchema.parse({
43+
name: 'browser_attach_dogfood_attacher',
44+
label: 'Browser attach dogfood: attach files',
45+
objects: { sys_attachment: { allowRead: true, allowCreate: true } },
46+
});
47+
/** A set that reads every object, `sys_file` included — the posture the permission-matrix fixture grants. */
48+
const wildcardReaderSet = PermissionSetSchema.parse({
49+
name: 'browser_attach_dogfood_wildcard_reader',
50+
label: 'Browser attach dogfood: read everything',
51+
objects: { '*': { allowRead: true }, sys_attachment: { allowRead: true, allowCreate: true } },
52+
});
53+
54+
describe('the Attachments panel’s upload flow on a real showcase boot: the upload’s own completion attaches, as the member', () => {
55+
let stack: VerifyStack;
56+
let rootDir: string;
57+
let ql: IDataEngine;
58+
let attacherToken: string;
59+
let otherToken: string;
60+
let wildcardToken: string;
61+
let attacherId: string;
62+
let attacherInvoice: string;
63+
let othersInvoice: string;
64+
let wildcardInvoice: string;
65+
66+
beforeAll(async () => {
67+
rootDir = mkdtempSync(join(tmpdir(), 'browser-attach-dogfood-'));
68+
stack = await bootShowcase({
69+
security: new SecurityPlugin({
70+
...appSecurityPluginOptions(showcaseStack),
71+
defaultPermissionSets: [...securityDefaultPermissionSets, attacherSet, wildcardReaderSet],
72+
}),
73+
extraPlugins: [
74+
new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }),
75+
new AuditPlugin(),
76+
],
77+
});
78+
ql = stack.kernel.getService<IDataEngine>('objectql');
79+
const member = async (email: string, sets: string[]) => {
80+
const token = await stack.signUp(email);
81+
const user = await ql.findOne('sys_user', { where: { email } }, { context: SYS });
82+
for (const permission_set of sets) {
83+
await ql.insert('sys_user_permission_set', { user_id: user!.id, permission_set }, { context: SYS });
84+
}
85+
return { token, id: String(user!.id) };
86+
};
87+
const attacher = await member('browser-attacher@dogfood.test', ['showcase_contributor', attacherSet.name]);
88+
attacherToken = attacher.token;
89+
attacherId = attacher.id;
90+
otherToken = (await member('browser-other@dogfood.test', ['showcase_contributor', attacherSet.name])).token;
91+
wildcardToken = (await member('browser-wildcard@dogfood.test', ['showcase_contributor', wildcardReaderSet.name])).token;
92+
const [seeded] = await ql.find('showcase_invoice', { limit: 1 }, { context: SYS });
93+
const invoice = async (name: string, owner: string) =>
94+
String((await ql.insert('showcase_invoice', { name, owner, account: seeded.account, status: 'draft' }, { context: SYS })).id);
95+
attacherInvoice = await invoice('INV-BROWSER-ATTACHER', 'browser-attacher@dogfood.test');
96+
othersInvoice = await invoice('INV-BROWSER-OTHER', 'someone-else@dogfood.test');
97+
wildcardInvoice = await invoice('INV-BROWSER-WILDCARD', 'browser-wildcard@dogfood.test');
98+
}, 180_000);
99+
100+
afterAll(async () => {
101+
await stack?.stop();
102+
rmSync(rootDir, { recursive: true, force: true });
103+
});
104+
105+
const json = { 'Content-Type': 'application/json' };
106+
const bearer = (token: string) => ({ Authorization: `Bearer ${token}` });
107+
108+
/** Steps 1 and 2 of the panel's flow: presign in the attachments scope, then PUT the bytes. */
109+
async function presignAndPut(token: string): Promise<string> {
110+
const presign = await stack.api('/storage/upload/presigned', {
111+
method: 'POST',
112+
headers: { ...json, ...bearer(token) },
113+
body: JSON.stringify({ filename: 'notes.txt', mimeType: 'text/plain', size: BYTES.length, scope: 'attachments' }),
114+
});
115+
expect(presign.status, 'presign').toBe(200);
116+
const { data } = (await presign.json()) as any;
117+
const put = await stack.raw(String(data.uploadUrl).replace(/^https?:\/\/[^/]+/, ''), {
118+
method: 'PUT',
119+
headers: data.headers ?? { 'content-type': 'text/plain' },
120+
body: BYTES,
121+
});
122+
expect(put.status, 'raw PUT').toBeLessThan(300);
123+
return String(data.fileId);
124+
}
125+
126+
/** Step 3: the upload-complete door, with or without `attachTo`. */
127+
async function complete(token: string, fileId: string, attachTo?: { parentObject: string; parentId: string }) {
128+
const res = await stack.api('/storage/upload/complete', {
129+
method: 'POST',
130+
headers: { ...json, ...bearer(token) },
131+
body: JSON.stringify(attachTo ? { fileId, attachTo } : { fileId }),
132+
});
133+
return { status: res.status, body: (await res.json()) as any };
134+
}
135+
136+
const onInvoice = (parentId: string) => ({ parentObject: 'showcase_invoice', parentId });
137+
const fileRow = (id: string) => ql.findOne('sys_file', { where: { id } }, { context: SYS }) as Promise<any>;
138+
const linksOf = (fileId: string) =>
139+
ql.find('sys_attachment', { where: { file_id: fileId } }, { context: SYS }) as Promise<any[]>;
140+
141+
it('precondition: the member cannot read sys_file through the data door', async () => {
142+
const res = await stack.apiAs(attacherToken, 'GET', '/data/sys_file');
143+
expect(res.status).toBe(403);
144+
});
145+
146+
it('the member attaches end to end: presign 200, PUT, complete with attachTo 200 — one committed file, one attachment in their name', async () => {
147+
const fileId = await presignAndPut(attacherToken);
148+
const out = await complete(attacherToken, fileId, onInvoice(attacherInvoice));
149+
expect(out.status, JSON.stringify(out.body)).toBe(200);
150+
expect(out.body.data).toMatchObject({ fileId, name: 'notes.txt', attachmentId: expect.any(String) });
151+
expect(await fileRow(fileId)).toMatchObject({ status: 'committed', scope: 'attachments', owner_id: attacherId });
152+
const links = await linksOf(fileId);
153+
expect(links).toHaveLength(1);
154+
expect(links[0]).toMatchObject({
155+
id: out.body.data.attachmentId, parent_object: 'showcase_invoice', parent_id: attacherInvoice, uploaded_by: attacherId,
156+
});
157+
// The panel's list read sees it, and the member downloads it.
158+
const list = await stack.apiAs(attacherToken, 'GET', `/data/sys_attachment?parent_id=${attacherInvoice}`);
159+
expect(list.status).toBe(200);
160+
expect(JSON.stringify(await list.json())).toContain(out.body.data.attachmentId);
161+
const url = await stack.api(`/storage/files/${fileId}/url`, { headers: bearer(attacherToken) });
162+
expect(url.status).toBe(200);
163+
// No wider read: sys_file stays refused to them.
164+
expect((await stack.apiAs(attacherToken, 'GET', `/data/sys_file/${fileId}`)).status).toBe(403);
165+
});
166+
167+
it('control: the generic data door keeps its rule — the member’s two-step attach naming their own completed upload is refused reference_not_found', async () => {
168+
const fileId = await presignAndPut(attacherToken);
169+
expect((await complete(attacherToken, fileId)).status).toBe(200);
170+
const res = await stack.apiAs(attacherToken, 'POST', '/data/sys_attachment', {
171+
parent_object: 'showcase_invoice', parent_id: attacherInvoice, file_id: fileId, file_name: 'notes.txt',
172+
});
173+
expect(res.status).toBe(400);
174+
const body = JSON.stringify(await res.json());
175+
expect(body).toContain('VALIDATION_FAILED');
176+
expect(body).toContain('reference_not_found');
177+
expect(await linksOf(fileId)).toEqual([]);
178+
});
179+
180+
it('control: another user’s upload id is refused, and their upload stays pending and unattached', async () => {
181+
const othersFile = await presignAndPut(otherToken);
182+
const out = await complete(attacherToken, othersFile, onInvoice(attacherInvoice));
183+
expect([out.status, out.body.error?.code]).toEqual([403, 'PERMISSION_DENIED']);
184+
expect((await fileRow(othersFile)).status).toBe('pending');
185+
expect(await linksOf(othersFile)).toEqual([]);
186+
});
187+
188+
it('control: an id that names no file is refused FILE_NOT_FOUND / 404', async () => {
189+
const out = await complete(attacherToken, 'no-such-upload', onInvoice(attacherInvoice));
190+
expect([out.status, out.body.error?.code]).toEqual([404, 'FILE_NOT_FOUND']);
191+
});
192+
193+
it('control: a record the member may not attach to (another user’s invoice) is refused, and the upload stays pending and unattached', async () => {
194+
const fileId = await presignAndPut(attacherToken);
195+
const out = await complete(attacherToken, fileId, onInvoice(othersInvoice));
196+
expect(out.status).toBeGreaterThanOrEqual(403);
197+
expect(out.status).toBeLessThan(500);
198+
expect(['ATTACHMENT_PARENT_ACCESS', 'RECORD_NOT_FOUND']).toContain(out.body.error?.code);
199+
expect((await fileRow(fileId)).status).toBe('pending');
200+
expect(await linksOf(fileId)).toEqual([]);
201+
});
202+
203+
it('control: an upload already attached cannot be attached again on completion', async () => {
204+
const fileId = await presignAndPut(attacherToken);
205+
expect((await complete(attacherToken, fileId, onInvoice(attacherInvoice))).status).toBe(200);
206+
const again = await complete(attacherToken, fileId, onInvoice(attacherInvoice));
207+
expect([again.status, again.body.error?.code]).toEqual([409, 'RESOURCE_CONFLICT']);
208+
expect(await linksOf(fileId)).toHaveLength(1);
209+
});
210+
211+
it('control: a member whose set reads every object keeps working — through the attach on completion and through the generic two-step', async () => {
212+
const viaComplete = await presignAndPut(wildcardToken);
213+
const out = await complete(wildcardToken, viaComplete, onInvoice(wildcardInvoice));
214+
expect(out.status, JSON.stringify(out.body)).toBe(200);
215+
expect(await linksOf(viaComplete)).toHaveLength(1);
216+
217+
const viaDataDoor = await presignAndPut(wildcardToken);
218+
expect((await complete(wildcardToken, viaDataDoor)).status).toBe(200);
219+
const res = await stack.apiAs(wildcardToken, 'POST', '/data/sys_attachment', {
220+
parent_object: 'showcase_invoice', parent_id: wildcardInvoice, file_id: viaDataDoor, file_name: 'notes.txt',
221+
});
222+
expect(res.status, JSON.stringify(await res.clone().json())).toBeLessThan(300);
223+
expect(await linksOf(viaDataDoor)).toHaveLength(1);
224+
});
225+
});

0 commit comments

Comments
 (0)