Skip to content

Commit 1fd68ea

Browse files
feat(service-storage): sys_attachment verifies the caller may open the referenced file (#27424)
Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`sys_attachment` 的写入闸门现在会核验调用者能否打开 `file_id` 指向的文件(插入时,以及更新改动 `file_id` 时),与文件字段写入共用同一个判定。 - 为什么改:附件是 attachments 范围文件经父记录变得可读的途径;引用文件的两扇写入门应当回答同一个问题,由同一个判定回答。 - 风险与回滚:风险在于误拒合法的附件写入(上传者本人、或已能经其他记录打开该文件的人仍放行;系统写不判)。回滚即 revert 本 PR。 - 先看:`packages/services/service-storage/src/attachment-access-hooks.ts`(`installAttachmentFileReferenceGate`)、`packages/services/service-storage/src/referenceable-file.ts`、`packages/services/service-storage/src/storage-service-plugin.ts`(接线)。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run test/attachments-permission-matrix.dogfood.test.ts -t 'names a file the caller may open'`,预期 3 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes `sys_attachment` now verifies that the caller may open the file a row references. - **One predicate, shared.** `findReferenceableFile`, `systemReadContext`, the `FileReferenceReadAuthorizer` type and the refusal sentence move from `file-reference-lifecycle.ts` into a new module, `referenceable-file.ts`. The file-field door keeps using them unchanged (byte-identical refusal text); the attachment gate now uses the same ones. - **The attachment write gate asks it.** A new registration, `installAttachmentFileReferenceGate` (in `attachment-access-hooks.ts`, not exported from the package entry), adds a `beforeInsert` and a `beforeUpdate` hook on `sys_attachment`. Insert: the row's `file_id` must name a file the caller may open. Update: asked once when the payload names a `file_id` that differs from a matched row's. System-context and context-less writes are not judged, exactly as at the other gates in that module. - **Same answer as the file-field door.** A file the caller may not open, and an id that names no file the acting organization reaches, are answered alike: `400 VALIDATION_FAILED`, one ADR-0114 `invalid_value` entry for `file_id` labelled with the field's declared label (`File`). Nothing is written. - **Order.** Installed right after `installAttachmentAccessHooks` (same-priority hooks run in registration order), so the parent rule and the row rule answer first and a caller they refuse keeps their refusal. - **Wiring.** `storage-service-plugin.ts` builds the download door's verdict (`buildFileReferenceReadAuthorizer`) once and hands the same binding to both doors. `installAttachmentAccessHooks`'s exported signature is unchanged. - What keeps working: attaching your own upload (including `attachFileAs`, whose fresh upload is the caller's own), attaching a file you can already open through another record, admin attaching their own files, seeds and lifecycle sweeps. ## Tests - Unit (`attachment-access-hooks.test.ts`, new describe "the file a row names must be one the caller may open", driven through the suite's existing engine double): insert refuses a file the caller cannot open (envelope: `code` + the `file_id` field entry, lookup carries the acting organization); an id naming no file gets the same answer; controls: an openable file is admitted, a system insert is not judged, a caller the parent rule refuses keeps `ATTACHMENT_PARENT_ACCESS` with no file judged, no file gate installed means no file judged; update refuses a `file_id` change to an unopenable file, admits an openable one, and judges nothing when `file_id` is unchanged or absent. - `storage-service-plugin.test.ts`: the hook-roster pin now lists the second `beforeInsert`/`beforeUpdate` pair, with a comment naming it. - Dogfood, HTTP in process (`attachments-permission-matrix.dogfood.test.ts`, new describe `(h)`): insert of a file the member cannot open answers 400 `VALIDATION_FAILED` with the `file_id` entry, no row lands, the member's download of that file still answers 403; control: the member's own upload attaches. Update re-pointing `file_id` at that file answers the same 400 and leaves the row unchanged; control: re-pointing at the member's second own upload answers 200. Control: the file's owner attaches it. ## Acceptance notes - `Clause-②: no`: a runtime permission behaviour change, no contract surface widened (the new installer is module-level, not reachable from the package entry; `installAttachmentAccessHooks` keeps its signature). - The update hook judges the payload's `file_id` once per dispatch; the per-row dispatch binds `input.id`, and the unscoped multi-update shape is refused by the row rule before this hook runs. ## Verification All readings at head `bc7274ef` (objectstack-ai/objectstack). <details><summary>Package tests, typecheck, lint</summary> - `pnpm --filter @objectstack/service-storage test`: Test Files 70 passed (70), Tests 1267 passed (1267). - `pnpm --filter @objectstack/service-storage typecheck`: exit 0. - `eslint --no-inline-config packages/services/service-storage/src packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts`: exit 0. - Lock verdict: `os-verify-lock: VERDICT command-exit 0`. </details> <details><summary>Dogfood</summary> - `test/attachments-permission-matrix.dogfood.test.ts` (dependency closure built first): Tests 22 passed, 1 skipped (23). - Every dogfood file that writes or reads `sys_attachment` or the storage file routes (21 files): Test Files 20 passed, 1 skipped (21); Tests 177 passed, 9 skipped (186). </details> <details><summary>Ablation (one-time proof, not kept)</summary> - Mutation: in `storage-service-plugin.ts`, the `installAttachmentFileReferenceGate(...)` call replaced through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `00a7d069` to `50b2de31`), package rebuilt, `ablation-dist-preflight.mjs --absent` confirmed the call absent from all 4 built files. - Mutated run of the `(h)` cases: the two refusal cases failed on their `400` expectation, the owner control passed. - Restore: `git checkout HEAD -- PATH`, rebuild, preflight confirmed the call back in `dist/`, blob hash equals HEAD (`00a7d069`), working tree clean. </details> <details><summary>Gate families</summary> - `node scripts/pm/dispatch-gates.mjs --commands` (95 families) run locally; `--ran` reconciliation: 95 derived, 95 run, 0 UNRUN. - Exit 0 for all but two, both NOT MEASURED: `check-plugin-teardown-shape --self-test` (exit 3, its pinned fixture commit is outside this shallow checkout) and `check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: unrelated packages have no `dist/` here). CI runs both. - `check:system-context-census` and `check:engine-double-contract` were red on an earlier head and are green at this head (the register row names the new installer; the new unit cases reuse the suite's existing engine double). - `node scripts/check-platform-checklist.mjs --changed-from origin/main`: exit 0, 0 anchors lost. - `check:nul-bytes`: OK. </details> Dispatched by `domain:devx#1 · sunhaolin · session_017xRBcScm27Q3R5mkYLPWdB`. --- _Generated by [Claude Code](https://claude.ai/code/session_017xRBcScm27Q3R5mkYLPWdB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1346052 commit 1fd68ea

9 files changed

Lines changed: 462 additions & 86 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
feat(service-storage): `sys_attachment` verifies the caller may open the file a row references
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An attachment links an `attachments`-scope file to a parent record, and the download door admits a caller through any parent record the file is attached to. The `sys_attachment` write gate now asks, of every insert and of every update that changes `file_id`, the same question a file-class field asks before it references a file: may this caller open the file it names?
11+
12+
- **One predicate, two write doors.** The check is the one the file-field door already runs (`findReferenceableFile`, bound by the storage plugin to the download door's own verdict), now shared by both doors from `referenceable-file.ts`, so a field reference and an attachment reference cannot disagree about which files a caller may take.
13+
- **The refusal is the file-field door's.** A file the caller may not open, and an id that names no file the acting organization reaches, are answered alike: `400 VALIDATION_FAILED` with one `invalid_value` entry for `file_id` ("File cannot reference this file: it is not a file you can open. Choose a file you can open, or upload it again."). Nothing is written.
14+
- **What keeps working.** Attaching a file you uploaded, or one you can already open through another record, is admitted as before, as is `attachFileAs`, which attaches the caller's own fresh upload. System-context and context-less writes (seeds, engine self-writes, lifecycle sweeps) are not judged. The parent and row rules still answer first, so a caller they refuse keeps their refusal. A kernel that composes no download gate serves every file, and so every file may be referenced there.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ One of the largest consumers of the flag;
173173
| 41 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `bindApprovalLockHook` |
174174
| 42 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `bindDelegationWriteGuard` |
175175
| 43 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `isOverrideActor`, `resolveActor`, `sendBack`, `resubmit`, `reassign`, `remind`, `requestInfo`, `comment` |
176-
| 44 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `installAttachmentAccessHooks`, `installAttachmentReadVisibility` |
176+
| 44 | Attachment access hooks return early (insert + update + delete, the referenced-file check on insert + update, and the read AST) | service-storage | Lose: attachment visibility scoping | `installAttachmentAccessHooks`, `installAttachmentFileReferenceGate`, `installAttachmentReadVisibility` |
177177
| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity and audit-log read gates (their read AST), the activity field redaction and the audit-log field redaction (the rows a read serves), the activity summary's message code (the rows a read serves), and the query guard over both objects' value-bearing columns (the read AST) | plugin-audit | Get: the whole activity row and the whole `sys_audit_log` row, its before/after snapshots included, and the activity row's stored summary code (`metadata.summary_message`), on `find` / `findOne` — the audit writer, each read gate's bounded probe and the activity backfill — and a filter, sort or grouping by those columns. Lose: comment visibility scoping, the narrowing of `sys_activity` and of `sys_audit_log` to rows whose parent record the caller can read, the redaction of a parent field's value from an activity row's text and recorded change and from a ledger row's before/after snapshots, the withholding of an activity row recording an update whose every changed field the reader is withheld, of a fired milestone row whose watched fields it is not all served, and of an unstamped row (on `count` and `aggregate` too, so a total agrees with the rows), the removal of that summary code from the activity row's `metadata` (its params are field values), and the refusal of a query over those columns for a reader withheld a field of the objects it can reach | `installCommentAccessHooks`, `installCommentReadVisibility`, `installActivityReadVisibility`, `installAuditLogReadVisibility`, `installActivityFieldRedaction`, `installAuditLogFieldRedaction`, `installActivitySummaryLocale`, `installParentFieldQueryGuard` |
178178
| 46 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `applyPermissionFilter` |
179179
| 47 | The inbound-mail read gates return early (the read AST) | service-mailbox | Get: every `sys_mail_message` and `sys_mail_association` row on `find` / `findOne` — the ingest writer and each gate's own pre-scan, which read the association and mailbox rows a decision needs. Lose: the narrowing of a message to callers who can read a record it is associated with or who own its mailbox (so the unassociated pool is restricted), and of an association to callers who can read the record it names (on `count` and `aggregate` too, so a total agrees with the rows) | `gateMiddleware` |
@@ -651,6 +651,7 @@ and CI refuses one that differs from what the tree produces.
651651
| `packages/services/service-print/src/print-service.ts#refuseUnlessHumanCaller` | 1 | 47d |
652652
| `packages/services/service-settings/src/settings-read-door.ts#settingsReadDoorMiddleware` | 1 | 18b |
653653
| `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentAccessHooks` | 3 | 44 |
654+
| `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentFileReferenceGate` | 2 | 44 |
654655
| `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentReadVisibility` | 1 | 44 |
655656
| `packages/services/service-storage/src/file-attachment-service.ts#requireUserContext` | 1 | 47e |
656657
| `packages/services/service-storage/src/file-reference-lifecycle.ts#referencingCallerOf` | 1 | 47f |

‎packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,60 @@ describe('attachments permission matrix (#2755)', () => {
480480
expect(anon302.status).toBe(401);
481481
});
482482

483+
// ── (h) the file a row names must be one the caller may open ─────────
484+
describe('(h) sys_attachment.file_id names a file the caller may open', () => {
485+
/** The file-field door's refusal, for the `file_id` field. */
486+
const expectFileRefusal = async (res: Response) => {
487+
expect(res.status, await res.clone().text()).toBe(400);
488+
const body = (await res.json()) as any;
489+
expect(body.code).toBe('VALIDATION_FAILED');
490+
expect((body.fields ?? []).map((f: any) => [f.field, f.code])).toEqual([['file_id', 'invalid_value']]);
491+
};
492+
493+
it('insert refuses a file reference the caller cannot open, and no row lands; the caller own upload attaches (control)', async () => {
494+
const adminFile = await uploadFile(stack, adminTok);
495+
expect((await attach(adminTok, 'att_secret', secretId, adminFile)).status).toBeLessThan(300);
496+
expect((await stack.apiAs(memberATok, 'GET', `/storage/files/${adminFile}/url`)).status).toBe(403);
497+
498+
await expectFileRefusal(await attach(memberATok, 'att_case', caseAId, adminFile));
499+
const landed = await ql.find('sys_attachment', {
500+
where: { parent_object: 'att_case', parent_id: caseAId, file_id: adminFile },
501+
context: SYS,
502+
});
503+
expect(landed).toHaveLength(0);
504+
expect((await stack.apiAs(memberATok, 'GET', `/storage/files/${adminFile}/url`)).status).toBe(403);
505+
506+
// Control: the caller's own freshly uploaded file attaches.
507+
const ownFile = await uploadFile(stack, memberATok);
508+
expect((await attach(memberATok, 'att_case', caseAId, ownFile)).status).toBeLessThan(300);
509+
});
510+
511+
it('update refuses re-pointing file_id at a file the caller cannot open; re-pointing at the caller own file succeeds (control)', async () => {
512+
const adminFile = await uploadFile(stack, adminTok);
513+
expect((await attach(adminTok, 'att_secret', secretId, adminFile)).status).toBeLessThan(300);
514+
515+
const ownFile = await uploadFile(stack, memberATok);
516+
expect((await attach(memberATok, 'att_case', caseAId, ownFile)).status).toBeLessThan(300);
517+
const row = await ql.findOne('sys_attachment', { where: { file_id: ownFile }, context: SYS });
518+
519+
await expectFileRefusal(await stack.apiAs(memberATok, 'PATCH', `/data/sys_attachment/${row.id}`, { file_id: adminFile }));
520+
expect((await ql.findOne('sys_attachment', { where: { id: row.id }, context: SYS }))?.file_id).toBe(ownFile);
521+
expect((await stack.apiAs(memberATok, 'GET', `/storage/files/${adminFile}/url`)).status).toBe(403);
522+
523+
// Control: re-pointing at another file the caller uploaded is admitted.
524+
const secondOwn = await uploadFile(stack, memberATok);
525+
const ok = await stack.apiAs(memberATok, 'PATCH', `/data/sys_attachment/${row.id}`, { file_id: secondOwn });
526+
expect(ok.status, await ok.clone().text()).toBe(200);
527+
expect((await ql.findOne('sys_attachment', { where: { id: row.id }, context: SYS }))?.file_id).toBe(secondOwn);
528+
});
529+
530+
it('the owner of the file attaches it to a record they may edit (control)', async () => {
531+
const adminFile = await uploadFile(stack, adminTok);
532+
const res = await attach(adminTok, 'att_case', caseAId, adminFile);
533+
expect(res.status, await res.clone().text()).toBeLessThan(300);
534+
});
535+
});
536+
483537
// ── Part 1: sys_file orphan lifecycle, end to end ─────────────────────
484538
describe('sys_file orphan lifecycle (ADR-0057 reap guard)', () => {
485539
it('tombstones on last-ref delete, keeps shared files alive, un-tombstones on re-attach', async () => {

‎packages/services/service-storage/src/attachment-access-hooks.test.ts‎

Lines changed: 134 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { ControlledByParentWriteOutcome, ExplainAccessRequest } from '@obje
77
import type { ExplainDecision } from '@objectstack/spec/security';
88
import {
99
installAttachmentAccessHooks,
10+
installAttachmentFileReferenceGate,
1011
mayEditAttachmentParent,
1112
type AttachmentSecurityLike,
1213
type AttachmentSharingLike,
@@ -68,14 +69,21 @@ function install(opts: {
6869
/** Parents the CALLER can read through the engine, keyed `object/id`. */
6970
readable?: string[];
7071
messageTranslator?: () => ((key: string, locale: string, params?: Record<string, unknown>) => string) | undefined;
72+
/** `sys_file` rows the engine serves; with `authorizeFileRead`, the file half of the gate is installed too. */
73+
files?: Array<Record<string, unknown>>;
74+
authorizeFileRead?: (file: Record<string, unknown>, context: any) => Promise<boolean>;
7175
}) {
72-
const hooks = new Map<string, (ctx: any) => Promise<void>>();
76+
// Every handler per event, in registration order — the engine's own order
77+
// for same-priority hooks; each event's runner below awaits them in turn.
78+
const hooks = new Map<string, Array<(ctx: any) => Promise<void>>>();
79+
const fileReads: any[] = [];
7380
const parentReads: Array<{ object: string; options: any }> = [];
7481
const parentFind = readableParentFind(opts.readable ?? [], parentReads);
75-
const engine: AttachmentLifecycleEngine = {
82+
const engine: AttachmentLifecycleEngine & { getObject: (name: string) => unknown } = {
7683
registerHook: (event, handler) => {
77-
hooks.set(event, handler as any);
84+
hooks.set(event, [...(hooks.get(event) ?? []), handler as any]);
7885
},
86+
getObject: (name) => (name === 'sys_attachment' ? { fields: { file_id: { label: 'File' } } } : undefined),
7987
find: async (object, options: any) => {
8088
if (object !== 'sys_attachment') return parentFind(object, options);
8189
const rows = (opts.attachments ?? []).filter((r) =>
@@ -84,18 +92,28 @@ function install(opts: {
8492
return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows;
8593
},
8694
findOne: async (_object, options: any) =>
87-
{ assertEngineFindOnePredicate(_object, options); return (opts.attachments ?? []).find((r) =>
95+
{ assertEngineFindOnePredicate(_object, options);
96+
if (_object === 'sys_file') {
97+
fileReads.push(options);
98+
return (opts.files ?? []).find((f) => f.id === options.where.id) ?? null;
99+
}
100+
return (opts.attachments ?? []).find((r) =>
88101
Object.entries(options?.where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; }),
89102
) ?? null; },
90103
update: async () => ({}),
91104
};
92105
const sharing = asTriState(opts.sharing);
93106
installAttachmentAccessHooks(engine, () => sharing, silentLogger(), opts.messageTranslator, () => opts.security);
107+
if (opts.authorizeFileRead) installAttachmentFileReferenceGate(engine, opts.authorizeFileRead);
108+
const run = (event: string) => async (ctx: any) => {
109+
for (const handler of hooks.get(event) ?? []) await handler(ctx);
110+
};
94111
return {
95-
beforeInsert: hooks.get('beforeInsert')!,
96-
beforeUpdate: hooks.get('beforeUpdate')!,
97-
beforeDelete: hooks.get('beforeDelete')!,
112+
beforeInsert: run('beforeInsert'),
113+
beforeUpdate: run('beforeUpdate'),
114+
beforeDelete: run('beforeDelete'),
98115
parentReads,
116+
fileReads,
99117
};
100118
}
101119

@@ -1877,3 +1895,112 @@ describe('a kernel that judges no by-id update keeps the record-sharing and mast
18771895
});
18781896
});
18791897
});
1898+
1899+
describe('attachment access — the file a row names must be one the caller may open', () => {
1900+
const OWN_FILE = { id: 'f-own', owner_id: 'u1', scope: 'attachments', status: 'committed' };
1901+
const OTHER_FILE = { id: 'f-other', owner_id: 'u-admin', scope: 'attachments', status: 'committed' };
1902+
const ROW = { id: 'a1', parent_object: 'task', parent_id: 't1', file_id: 'f-own', uploaded_by: 'u1' };
1903+
1904+
/**
1905+
* The gate with the file half installed: `openable` lists the file ids the
1906+
* caller may open. `bound: false` installs the access hooks alone.
1907+
*/
1908+
function installWithFiles(opts: { openable: string[]; bound?: boolean; canEditParent?: boolean }) {
1909+
const authorizeFileRead = vi.fn(async (file: Record<string, unknown>, context: any) => {
1910+
expect(context.userId).toBe('u1');
1911+
return opts.openable.includes(String(file.id));
1912+
});
1913+
const gate = install({
1914+
attachments: [ROW],
1915+
files: [OWN_FILE, OTHER_FILE],
1916+
sharing: { canEdit: async () => opts.canEditParent ?? true },
1917+
security: null,
1918+
authorizeFileRead: opts.bound === false ? undefined : authorizeFileRead,
1919+
});
1920+
return { ...gate, authorizeFileRead };
1921+
}
1922+
1923+
const attach = (fileId: unknown, session: Record<string, unknown> = { userId: 'u1', organizationId: 'org-1' }) => ({
1924+
object: 'sys_attachment',
1925+
event: 'beforeInsert',
1926+
input: {
1927+
data: { parent_object: 'task', parent_id: 't1', file_id: fileId, file_name: 'x.png' },
1928+
options: { context: { userId: 'u1', tenantId: 'org-1', permissions: [] } },
1929+
},
1930+
session,
1931+
api: apiFor(['task/t1']),
1932+
});
1933+
1934+
const repoint = (data: Record<string, unknown>) => ({
1935+
object: 'sys_attachment',
1936+
event: 'beforeUpdate',
1937+
input: { id: 'a1', data, options: { context: { userId: 'u1', tenantId: 'org-1', permissions: [] } } },
1938+
session: { userId: 'u1', organizationId: 'org-1' },
1939+
api: apiFor(['task/t1']),
1940+
});
1941+
1942+
const refusal = {
1943+
code: 'VALIDATION_FAILED',
1944+
fields: [
1945+
{
1946+
field: 'file_id',
1947+
code: 'invalid_value',
1948+
label: 'File',
1949+
message: 'File cannot reference this file: it is not a file you can open. Choose a file you can open, or upload it again.',
1950+
},
1951+
],
1952+
};
1953+
1954+
it('insert refuses a file reference the caller cannot open, with the file-field door envelope', async () => {
1955+
const { beforeInsert, fileReads } = installWithFiles({ openable: ['f-own'] });
1956+
await expect(beforeInsert(attach('f-other'))).rejects.toMatchObject(refusal);
1957+
// Looked up as the acting organization reaches it.
1958+
expect(fileReads[0].context).toMatchObject({ isSystem: true, tenantId: 'org-1' });
1959+
});
1960+
1961+
it('insert answers an id that names no file exactly as a file the caller cannot open', async () => {
1962+
const { beforeInsert } = installWithFiles({ openable: ['f-own', 'f-missing'] });
1963+
await expect(beforeInsert(attach('f-missing'))).rejects.toMatchObject(refusal);
1964+
});
1965+
1966+
it('insert admits a file the caller may open (control)', async () => {
1967+
const { beforeInsert, authorizeFileRead } = installWithFiles({ openable: ['f-own'] });
1968+
await expect(beforeInsert(attach('f-own'))).resolves.toBeUndefined();
1969+
expect(authorizeFileRead).toHaveBeenCalledTimes(1);
1970+
});
1971+
1972+
it('a system insert is not judged (control)', async () => {
1973+
const { beforeInsert, authorizeFileRead } = installWithFiles({ openable: [] });
1974+
await expect(beforeInsert(attach('f-other', { isSystem: true }))).resolves.toBeUndefined();
1975+
expect(authorizeFileRead).not.toHaveBeenCalled();
1976+
});
1977+
1978+
it('a caller the parent rule refuses keeps the parent refusal, and no file is judged', async () => {
1979+
const { beforeInsert, authorizeFileRead } = installWithFiles({ openable: [], canEditParent: false });
1980+
await expect(beforeInsert(attach('f-other'))).rejects.toMatchObject({ code: 'ATTACHMENT_PARENT_ACCESS', status: 403 });
1981+
expect(authorizeFileRead).not.toHaveBeenCalled();
1982+
});
1983+
1984+
it('without the file gate installed, the file is not judged', async () => {
1985+
const { beforeInsert, fileReads } = installWithFiles({ openable: [], bound: false });
1986+
await expect(beforeInsert(attach('f-other'))).resolves.toBeUndefined();
1987+
expect(fileReads).toHaveLength(0);
1988+
});
1989+
1990+
it('update refuses changing file_id to a file the caller cannot open', async () => {
1991+
const { beforeUpdate } = installWithFiles({ openable: ['f-own'] });
1992+
await expect(beforeUpdate(repoint({ file_id: 'f-other' }))).rejects.toMatchObject(refusal);
1993+
});
1994+
1995+
it('update admits changing file_id to a file the caller may open (control)', async () => {
1996+
const { beforeUpdate } = installWithFiles({ openable: ['f-own', 'f-other'] });
1997+
await expect(beforeUpdate(repoint({ file_id: 'f-other' }))).resolves.toBeUndefined();
1998+
});
1999+
2000+
it('update that leaves file_id as it is, or does not name it, judges no file (control)', async () => {
2001+
const { beforeUpdate, authorizeFileRead } = installWithFiles({ openable: [] });
2002+
await expect(beforeUpdate(repoint({ file_id: 'f-own', file_name: 'y.png' }))).resolves.toBeUndefined();
2003+
await expect(beforeUpdate(repoint({ file_name: 'z.png' }))).resolves.toBeUndefined();
2004+
expect(authorizeFileRead).not.toHaveBeenCalled();
2005+
});
2006+
});

0 commit comments

Comments
 (0)