Skip to content

Commit 59e6463

Browse files
fix(service-storage)!: a stored file's uploader is the user who created it, as an attachment's and a comment's author are (#30170)
Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: updated here — `content/docs/permissions/attachments-access.mdx` now states that a `sys_file` created through the data API records the signed-in user as `owner_id`, and that such a create with no signed-in user is refused. No published skill teaches writing `sys_file.owner_id` (`git grep` over `skills/` finds none). ## 维护者速读 - **改了什么**:非系统上下文创建 `sys_file` 时,`owner_id` 一律记为当前会话用户(覆盖请求里带的值),与附件 `uploaded_by`、评论 `author_id` 同一规则;会话没有登录用户的创建被拒绝(403 `RECORD_NOT_ACCESSIBLE`)。 - **为什么改**:一个文件的上传者就是创建它的人;出处只在创建时记录一次,之后只读(更新侧已是只读)。 - **风险与回滚**:收窄(BREAKING)。经数据接口创建 `sys_file` 时请求里的 `owner_id` 不再生效;上传接口、系统上下文写入、无会话的裸内核调用都不变;控制台不经数据接口创建 `sys_file`,无需改动。回滚即 revert 本 PR。 - **先看**:`packages/services/service-storage/src/sys-file-storage-owned-columns.ts`(`recordUploader` 与钩子里 `if (ctx?.session)` 那一行)、`packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts`(数据接口上的新钉)、`.changeset/sys-file-uploader-is-its-creator.md`。 - **两分钟验证**:`pnpm --filter @objectstack/service-storage exec vitest run src/sys-file-storage-owned-columns.test.ts src/sys-file-storage-owned-columns.integration.test.ts`,预期 `Test Files 2 passed (2)`、`Tests 38 passed (38)`。 - **需要你做的**:无,席位落地。 ## What changes A stored file's uploader is the user who created it, as an attachment's and a comment's author are. - **Create.** A non-system create of `sys_file` records the session's user as `owner_id`, over whatever the request carries: the stamp the attachment create hook (`sys_attachment.uploaded_by`) and the comment create hook (`sys_comment.author_id`) already apply. It lives in the existing `sys_file` create guard (`installSysFileStorageOwnedColumnsGuard`), beside the storage-owned column strip, so `sys_file` keeps one `beforeInsert` registration. - **Refused.** A non-system create whose session names no signed-in user is refused with 403 `RECORD_NOT_ACCESSIBLE`, the code and status the attachment and comment hooks answer their own no-user creates with. - **Unchanged.** The system context keeps the `owner_id` it supplies: the upload doors (presigned, chunked, complete) record the session's user themselves under it, as do imports and migrations. A call with no session at all (a bare kernel) keeps its value too, which is the attachment hook's carve-out. Updates are unchanged: `owner_id` is already read-only after creation, so an update drops a non-system caller's value and reports it in `droppedFields`. - **One difference from the attachment hook, by design.** The attachment hook passes a context-less call entirely. Here the carve-out covers the uploader only: the storage-owned and file-reference columns are still dropped from a context-less create, because that strip judges every write that is not `isSystem` (as the update strip does) and this rule does not loosen it. - **Silent, as the attachment and comment stamps are.** The engine reports `droppedFields` / `onFieldsDropped` only for strips it runs itself, it runs none on a `sys_` object's create, and the hook contract carries no dropped-field report: its one caller-facing member, `ctx.notice`, is a free-text save notice, and neither sibling stamp uses it. So the overwrite is not reported, exactly as `uploaded_by` and `author_id` are not. - **Docs.** `content/docs/permissions/attachments-access.mdx`, and the `owner_id` comment in `objects/system-file.object.ts`. ## Tests - **Unit, at the guard** (new `sys-file-storage-owned-columns.test.ts`, 8 cases): the session's user is recorded over another user's id and when none is named; three no-user sessions (a service principal, an organization only, an empty user id) are refused 403 `RECORD_NOT_ACCESSIBLE` with nothing logged; a system create keeps its value; a bare-kernel call keeps its value and still loses the storage-owned columns; the stamp sits beside the strip. - **Integration, real engine over sqlite** (`sys-file-storage-owned-columns.integration.test.ts`, 6 new cases): single and batch creates record the caller; a no-user create is refused and writes no row; the upload doors (presign, complete) still record the uploading user; a system create and a system update keep the uploader they write; an update leaves the uploader the create recorded. - **Dogfood, at the data door** (`attachments-permission-matrix.dogfood.test.ts`, uploader block): an administrator's `POST /data/sys_file` naming another user answers 201 and records the administrator; control, a system create keeps the uploader it supplies. The block's existing pins stay the update-side and upload-door controls: an administrator's PATCH of `owner_id` answers 200 with `owner_id` in `droppedFields`, and (e) the upload door stamps the uploading member. - **Ablation, one shot** (fix committed first; mutation through `scripts/ablation-replace.mjs`, which restored on exit): the stamp line replaced by a no-op, `service-storage` rebuilt, `ablation-dist-preflight` proved the line absent from `dist/`. Red as predicted: 5 of 38 in the two guard files (the three unit and two integration cases that read the stamp) and the new dogfood pin; every control stayed green. Restore leg: blob equal to HEAD, `git diff HEAD` empty, rebuilt, preflight proved the mutation absent and the line present in `dist/`, both runs green again. ## Acceptance notes - `sys_file.owner_id` has no update-time ownership-transfer path: since the rule that made a row's uploader read-only after creation, the update strip drops a non-system caller's `owner_id`, the holder of the ownership-transfer grant included (measured on `origin/main` 917f024: PATCH 200, `droppedFields` names `owner_id`, stored value unchanged). This PR keeps that; only the system context records a different uploader. - No UI or service creates `sys_file` through the data API (`git grep` over `ui/` and `packages/` finds the one writer, `StorageMetadataStore.createFile`, under the system context), so no caller changes. ## Verification Readings on `3aa77b9a7f` (`origin/main` 232d3bb merged in; it touches no package) unless stated. The head `89c379ba6e` adds one comment-only commit (the guard's docblock on `ctx.notice`). At that head, the guard tests read `Test Files 2 passed (2)`, `Tests 38 passed (38)`, and the 49 gate families derived for the changed file all exit 0 (`dispatch-gates --ran`: `49 run, 0 NOT-MEASURED`). <details><summary>service-storage: full suite and typecheck</summary> - `pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2` under `scripts/pm/os-verify-lock.sh`: `Test Files 72 passed (72)`, `Tests 1341 passed (1341)`, lock `VERDICT command-exit 0`. - `pnpm --filter @objectstack/service-storage typecheck`: exit 0, including `check:test-typecheck: OK`; `tsc --listFiles` on `tsconfig.test.json` lists the guard and both test files. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0; its program lists `attachments-permission-matrix.dogfood.test.ts`. </details> <details><summary>Derived gates (dispatch-gates --commands, 94 families)</summary> - 91 exit 0, each exit code captured before any pipe. - 3 NOT MEASURED, each answered PREREQUISITE NOT MET (exit 3) and measured nothing: `check:skill-examples` (no built `client-react` declarations in this worktree), `check:dual-build-cjs-loads` (seven packages outside the dogfood build closure have no `dist/`), `check:type-check-debt` (its closure build never got a turn on the shared verify lock). CI's `Lint & Repo Gates` runs all three on a full build. - `dispatch-gates --ran`: `94 derived famil(ies) accounted for — 91 run, 3 NOT-MEASURED`, exit 0. </details> <details><summary>Dogfood battery</summary> - Full `pnpm --filter @objectstack/dogfood test` on 3aa77b9 under os-verify-lock: VERDICT command-exit 0; Test Files 417 passed | 3 skipped (420); Tests 3464 passed | 28 skipped (3492) </details> <!-- signed: security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9a53fc9 commit 59e6463

7 files changed

Lines changed: 281 additions & 10 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
fix(service-storage)!: a stored file's uploader is the user who created it, as an attachment's and a comment's author are
6+
7+
Clause-②: no (narrowing) — a non-system create of `sys_file` records the session's user as `owner_id`, whatever the request carries, and one whose session names no signed-in user is refused. Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, and no stored row is read, rewritten, converted or dropped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime accept set on one platform-written column of one platform object, `sys_file.owner_id`, whose value is recorded once at creation; the remedy for a writer that needs another value recorded is a system-context write, which no ledger entry can derive. The other categories are closed on facts: the bumped package publishes (not unpublished); no ADR-0087 id covers this column and this diff adds none (not registered, not already-registered); no export is added, removed or retyped, so no interface or type narrows (not runtime-export-retired, runtime-interface-only or type-surface-only). -->
11+
12+
**BREAKING** accept-set narrowing on `sys_file` creates (`@objectstack/service-storage`), served by every data door (REST, MCP, the console).
13+
14+
- **Now.** A stored file's uploader is the user who created it, as an attachment's (`sys_attachment.uploaded_by`) and a comment's (`sys_comment.author_id`) author are. A non-system create of `sys_file` records the session's user as `owner_id`, over whatever the request carries. The column was already read-only on update, so the stamp is the row's uploader for life.
15+
- **Refused.** A non-system create of `sys_file` whose session names no signed-in user is refused with 403 `RECORD_NOT_ACCESSIBLE`, the answer an attachment or comment create with no signed-in user already gets: there is no user to record as the uploader.
16+
- **Unchanged.** The upload doors (presigned, chunked, complete) already record the signed-in user under the system context, and keep doing so. System-context writes (seeds, imports, migrations) keep the `owner_id` they supply, as does a programmatic create with no session at all on a bare kernel. An update of `owner_id` is dropped and reported in `droppedFields`, as before. Stored rows keep what they were written with. The console needs no change: it never creates `sys_file` rows through the data API.
17+
18+
**The remedy.** None for the console or the upload doors. A client that creates a `sys_file` row through the data API gets the row recorded under its own user, whatever `owner_id` it sends; to record a different uploader, write from a system context (a migration or an import). A server-side caller that creates `sys_file` rows with no signed-in user runs as the system principal or names the user it acts for.

‎content/docs/permissions/attachments-access.mdx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,14 @@ rendering these files. Only a file marked `acl: 'public_read'` is served to a
179179
caller with no session: mark a file that way when it must render before sign-in.
180180

181181
The upload entry points (presigned / chunked) likewise require a session when
182-
an auth service is wired, and stamp `owner_id` on the new `sys_file`. The
183-
file's uploader is read-only after creation, as an attachment's is: an update
184-
drops a non-system caller's `owner_id` (a holder of the ownership-transfer
185-
grant included) and reports it in `droppedFields`.
182+
an auth service is wired, and stamp `owner_id` on the new `sys_file`. A stored
183+
file's uploader is the user who created it, as an attachment's is: a
184+
`sys_file` created through the data API records the signed-in user as
185+
`owner_id`, whatever the request carries, and a create whose session names no
186+
signed-in user is refused (`RECORD_NOT_ACCESSIBLE`, 403). The file's uploader
187+
is read-only after creation: an update drops a non-system caller's `owner_id`
188+
(a holder of the ownership-transfer grant included) and reports it in
189+
`droppedFields`. Only a system-context write records a different uploader.
186190

187191
## Storage-byte lifecycle
188192

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -716,16 +716,19 @@ describe('attachments permission matrix (#2755)', () => {
716716
// join row's `uploaded_by` and the file's `owner_id` both declare `readonly`,
717717
// so the data door strips a non-system caller's value on update and reports it
718718
// in `droppedFields`, while the rest of the write lands. Their create stamps
719-
// are pinned in the matrix above, (e) and (f); a system write keeps the value
720-
// it supplies. Org-bound like the matrix: memberA holds the attachment-manager
719+
// are pinned in the matrix above, (e) and (f), and the file's at the data door
720+
// below: a stored file's uploader is the user who created it, as an
721+
// attachment's and a comment's author are. A system write keeps the value it
722+
// supplies. Org-bound like the matrix: memberA holds the attachment-manager
721723
// set and PATCHes the join row it created, which the floor admits; only an
722-
// administrator reaches a `sys_file` PATCH (the baseline names no grant on it).
724+
// administrator reaches a `sys_file` create or PATCH (the baseline names no
725+
// grant on it).
723726
describe('attachments: a row\'s uploader is read-only after creation (sys_attachment.uploaded_by, sys_file.owner_id)', () => {
724727
let stack: VerifyStack;
725728
let rootDir: string;
726729
let ql: any;
727730
let adminTok: string, memberATok: string;
728-
let memberAId: string, memberBId: string;
731+
let adminId: string, memberAId: string, memberBId: string;
729732
let caseAId: string;
730733

731734
const uid = async (email: string) =>
@@ -756,11 +759,14 @@ describe('attachments: a row\'s uploader is read-only after creation (sys_attach
756759
memberATok = await stack.signUp('att-ro-member-a@verify.test');
757760
await stack.signUp('att-ro-member-b@verify.test');
758761
ql = await stack.kernel.getServiceAsync('objectql');
762+
adminId = await uid('admin@objectos.ai');
759763
memberAId = await uid('att-ro-member-a@verify.test');
760764
memberBId = await uid('att-ro-member-b@verify.test');
765+
expect(adminId, 'the administrator resolved').toBeTruthy();
761766
expect(memberAId, 'memberA resolved').toBeTruthy();
762767
expect(memberBId, 'memberB resolved, a different user').toBeTruthy();
763768
expect(memberBId).not.toBe(memberAId);
769+
expect(adminId).not.toBe(memberBId);
764770
const managerSet = await catalogPermissionSet(ql, 'att_attachment_manager');
765771
expect(managerSet, 'fixture permission set served by the security catalog').toBeTruthy();
766772
await ql.insert('sys_user_permission_set', { user_id: memberAId, permission_set: managerSet.name }, { context: { ...SYS } });
@@ -815,6 +821,19 @@ describe('attachments: a row\'s uploader is read-only after creation (sys_attach
815821
expect((await ql.findOne('sys_attachment', { where: { id: attachmentId }, context: SYS }))?.uploaded_by).toBe(memberBId);
816822
expect((await ql.findOne('sys_file', { where: { id: fileId }, context: SYS }))?.owner_id).toBe(memberBId);
817823
});
824+
825+
it('a stored file\'s uploader is the user who created it: an administrator\'s data-door create naming another user answers 201 and records the administrator', async () => {
826+
const created = await stack.apiAs(adminTok, 'POST', '/data/sys_file', { name: 'created.txt', owner_id: memberBId });
827+
expect(created.status, 'the create is admitted').toBe(201);
828+
const file = await ql.findOne('sys_file', { where: { id: await createdId(created) }, context: SYS });
829+
expect(file?.owner_id, 'the creator, not the user the request named').toBe(adminId);
830+
expect(file?.name, 'the rest of the row lands').toBe('created.txt');
831+
});
832+
833+
it('control: a system create keeps the uploader it supplies', async () => {
834+
const created = await ql.insert('sys_file', { name: 'imported.txt', owner_id: memberBId }, { context: { ...SYS } });
835+
expect((await ql.findOne('sys_file', { where: { id: created.id }, context: SYS }))?.owner_id).toBe(memberBId);
836+
});
818837
});
819838

820839
// ── (a′) without service-storage, the floor is the last word (#21729) ────

‎packages/services/service-storage/src/objects/system-file.object.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,11 @@ export const SystemFile = ObjectSchema.create({
182182

183183
// The uploader: a row's uploader is read-only after creation, like a
184184
// comment's author. The upload doors record it from the session, under
185-
// the system context; `readonly` makes the engine's update-side strip drop
185+
// the system context, and any other create records the session's user,
186+
// over whatever it carried, or is refused when the session names none
187+
// (`sys-file-storage-owned-columns.ts`, "The uploader"), so a stored
188+
// file's uploader is the user who created it; `readonly` makes the
189+
// engine's update-side strip drop
186190
// a non-system caller's value and report it (`droppedFields`), a holder of
187191
// the ownership-transfer grant included, since this column is authorship
188192
// and not a transferable record owner. The reason is provenance

‎packages/services/service-storage/src/sys-file-storage-owned-columns.integration.test.ts‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@
2424
* create-side guard drops them from a non-system create, while the lifecycle's
2525
* claim, release and copy, all under the system context, still write them.
2626
*
27+
* The same guard records the row's uploader: a stored file's uploader
28+
* (`owner_id`) is the user who created it, as an attachment's and a comment's
29+
* author are. A non-system create records the caller over whatever it named,
30+
* and one whose context names no user is refused; the upload doors and every
31+
* system write keep the uploader they record.
32+
*
2733
* Driven through the doors as they ship, as the #26479 rig is: one real
2834
* `ObjectQL` engine over sqlite `:memory:`, `StorageServicePlugin` booted on
2935
* the local adapter, the upload doors called as routes, and the data writes
@@ -435,4 +441,67 @@ describe('sys_file storage-owned columns are not writable outside the storage se
435441
expect(typeof copyRow.key === 'string' && copyRow.key.length > 0, JSON.stringify(copyRow)).toBe(true);
436442
expect(copyRow.key).not.toBe(committedRow.key);
437443
});
444+
445+
// ── The uploader (`owner_id`) ──────────────────────────────────────
446+
447+
const SOMEONE_ELSE = 'u_someone_else';
448+
const uploaderOf = async (id: string) => (await fileRow(id))?.owner_id ?? null;
449+
450+
it('a stored file\'s uploader is the user who created it: a create records the caller over an owner_id naming another user', async () => {
451+
const fileId = `f_uploader_${++seq}`;
452+
453+
await engine.insert('sys_file', { id: fileId, name: 'mine.png', owner_id: SOMEONE_ELSE } as any, { context: WRITER_CTX });
454+
455+
expect(await uploaderOf(fileId)).toBe(WRITER);
456+
expect((await fileRow(fileId))?.name).toBe('mine.png');
457+
});
458+
459+
it('a batch create records the caller as the uploader on every row', async () => {
460+
const a = `f_uploader_${++seq}`;
461+
const b = `f_uploader_${++seq}`;
462+
463+
await engine.insert('sys_file', [
464+
{ id: a, name: 'a.png', owner_id: SOMEONE_ELSE },
465+
{ id: b, name: 'b.png' },
466+
] as any, { context: WRITER_CTX });
467+
468+
expect(await uploaderOf(a)).toBe(WRITER);
469+
expect(await uploaderOf(b)).toBe(WRITER);
470+
});
471+
472+
it('a create whose context names no signed-in user is refused 403 RECORD_NOT_ACCESSIBLE, and no row is written', async () => {
473+
const fileId = `f_uploader_${++seq}`;
474+
475+
expect(await failureOf(
476+
engine.insert('sys_file', { id: fileId, name: 'nobody.png', owner_id: SOMEONE_ELSE } as any, { context: { actor: 'svc:flow:importer' } as any }),
477+
)).toMatchObject({ status: 403, code: 'RECORD_NOT_ACCESSIBLE' });
478+
expect(await fileRow(fileId)).toBeUndefined();
479+
});
480+
481+
it('the upload doors still record the uploading user, under the system context', async () => {
482+
const { fileId: presigned } = await presign();
483+
expect(await uploaderOf(presigned)).toBe(WRITER);
484+
485+
const committed = await committedUpload();
486+
expect(await uploaderOf(committed)).toBe(WRITER);
487+
});
488+
489+
it('a system create and a system update keep the uploader they write', async () => {
490+
const fileId = `f_uploader_${++seq}`;
491+
492+
await engine.insert('sys_file', { id: fileId, name: 'import.png', owner_id: SOMEONE_ELSE } as any, { context: SYS });
493+
expect(await uploaderOf(fileId)).toBe(SOMEONE_ELSE);
494+
495+
await engine.update('sys_file', { id: fileId, owner_id: WRITER } as any, { context: SYS });
496+
expect(await uploaderOf(fileId)).toBe(WRITER);
497+
});
498+
499+
it('an update leaves the uploader the create recorded, whoever sends it', async () => {
500+
const fileId = await committedUpload();
501+
502+
await engine.update('sys_file', { id: fileId, name: 'renamed.png', owner_id: SOMEONE_ELSE } as any, { context: WRITER_CTX });
503+
504+
expect(await uploaderOf(fileId)).toBe(WRITER);
505+
expect((await fileRow(fileId))?.name).toBe('renamed.png');
506+
});
438507
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The create-side guard of `sys_file`, judged at the guard: the handler
5+
* {@link installSysFileStorageOwnedColumnsGuard} registers, called with the
6+
* hook contexts the engine builds. The real engine around it is
7+
* `sys-file-storage-owned-columns.integration.test.ts`.
8+
*
9+
* The rule pinned here is the uploader's: a stored file's uploader is the user
10+
* who created it, as an attachment's and a comment's author are. A non-system
11+
* create records the session's user as `owner_id` over whatever it carried, and
12+
* one whose session names no signed-in user is refused, as the attachment and
13+
* comment create hooks refuse theirs. System context keeps what it supplies,
14+
* and so does a call with no session at all (a bare kernel), which the
15+
* attachment hook passes the same way.
16+
*/
17+
18+
import { describe, it, expect, vi } from 'vitest';
19+
import { installSysFileStorageOwnedColumnsGuard } from './sys-file-storage-owned-columns.js';
20+
21+
type Handler = (ctx: any) => void | Promise<void>;
22+
23+
/** Install the guard on a stand-in engine and hand back its one handler. */
24+
function installGuard() {
25+
const registered: Array<{ event: string; handler: Handler; options: { object: string } }> = [];
26+
const logger = { warn: vi.fn() };
27+
installSysFileStorageOwnedColumnsGuard(
28+
{ registerHook: (event, handler, options) => { registered.push({ event, handler, options }); } },
29+
logger,
30+
);
31+
expect(registered.map((r) => [r.event, r.options.object])).toEqual([['beforeInsert', 'sys_file']]);
32+
const run = async (session: unknown, data: Record<string, unknown>) => {
33+
await registered[0]!.handler({ object: 'sys_file', event: 'beforeInsert', session, input: { data } });
34+
return data;
35+
};
36+
return { run, logger };
37+
}
38+
39+
/** What a thrown refusal answers, reduced to the two members ADR-0112 makes assertable. */
40+
async function refusalOf(write: Promise<unknown>): Promise<{ code: unknown; status: unknown } | null> {
41+
return write.then(
42+
() => null,
43+
(err: any) => ({ code: err?.code, status: err?.status }),
44+
);
45+
}
46+
47+
const CREATOR = 'u_creator';
48+
const SOMEONE_ELSE = 'u_someone_else';
49+
50+
describe('a stored file\'s uploader is the user who created it (sys_file.owner_id on create)', () => {
51+
it('a non-system create records the session\'s user as the uploader, over an owner_id naming another user', async () => {
52+
const { run } = installGuard();
53+
const row = await run({ userId: CREATOR }, { name: 'a.png', owner_id: SOMEONE_ELSE });
54+
expect(row.owner_id).toBe(CREATOR);
55+
expect(row.name).toBe('a.png');
56+
});
57+
58+
it('a non-system create that names no uploader records the session\'s user', async () => {
59+
const { run } = installGuard();
60+
expect((await run({ userId: CREATOR }, { name: 'a.png' })).owner_id).toBe(CREATOR);
61+
});
62+
63+
it.each([
64+
['a session that names no user (a service principal)', { actor: 'svc:flow:importer' }],
65+
['a session that names only an organization', { organizationId: 'org_1' }],
66+
['a session whose user id is empty', { userId: '' }],
67+
])('a non-system create is refused 403 RECORD_NOT_ACCESSIBLE when there is no signed-in user to record: %s', async (_label, session) => {
68+
const { run, logger } = installGuard();
69+
expect(await refusalOf(run(session, { name: 'a.png', owner_id: SOMEONE_ELSE, status: 'committed' }))).toEqual({
70+
code: 'RECORD_NOT_ACCESSIBLE',
71+
status: 403,
72+
});
73+
expect(logger.warn).not.toHaveBeenCalled();
74+
});
75+
76+
it('a system create keeps the uploader it supplies', async () => {
77+
const { run } = installGuard();
78+
const row = await run({ userId: CREATOR, isSystem: true }, { name: 'a.png', owner_id: SOMEONE_ELSE });
79+
expect(row.owner_id).toBe(SOMEONE_ELSE);
80+
});
81+
82+
it('a create with no session at all (a bare kernel) keeps the uploader it supplies, and still loses the storage-owned columns', async () => {
83+
const { run } = installGuard();
84+
const row = await run(undefined, { name: 'a.png', owner_id: SOMEONE_ELSE, status: 'committed', key: 'k' });
85+
expect(row.owner_id).toBe(SOMEONE_ELSE);
86+
expect(row.status).toBe('pending');
87+
expect(row).not.toHaveProperty('key');
88+
});
89+
90+
it('the uploader is recorded beside the storage-owned strip, which still drops the caller\'s columns and starts the row pending', async () => {
91+
const { run, logger } = installGuard();
92+
const row = await run({ userId: CREATOR }, { name: 'a.png', owner_id: SOMEONE_ELSE, size: 9, status: 'committed' });
93+
expect(row).toEqual({ name: 'a.png', owner_id: CREATOR, status: 'pending' });
94+
expect(logger.warn).toHaveBeenCalledTimes(1);
95+
expect(logger.warn.mock.calls[0]![1]).toMatchObject({ fields: ['size', 'status'] });
96+
});
97+
});

0 commit comments

Comments
 (0)