Skip to content

Commit 64beb52

Browse files
committed
test(service-storage): attachUploadAs attaches a member's own pending upload, and refuses every other file
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
1 parent 27c3561 commit 64beb52

1 file changed

Lines changed: 184 additions & 0 deletions

File tree

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

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,3 +638,187 @@ describe('attachFileAs for a member who cannot read sys_file: the verb vouches f
638638
expect(await rows(ql, 'sys_attachment')).toHaveLength(1);
639639
});
640640
});
641+
642+
describe('attachUploadAs: a member attaches their own pending browser upload, and the commit is the attach', () => {
643+
// The same member posture as above: the create grant on `sys_attachment`,
644+
// nothing on `sys_file` (a non-system read of it is refused outright), and
645+
// Bob cannot read the parent object. The upload is seeded as the presigned
646+
// door leaves one: a `pending` row owned by the uploader in the
647+
// `attachments` scope, and its bytes at its key.
648+
const ALICE = { userId: 'usr_alice', tenantId: 'org_a', positions: ['org_member'] };
649+
const BOB = { userId: 'usr_bob', tenantId: 'org_a', positions: ['org_member'] };
650+
const asCaller = (a: IFileAttachmentService) => a as IFileAttachmentService & ICallerFileAttachmentService;
651+
const READS = new Set(['find', 'findOne', 'count', 'aggregate']);
652+
const readRefusal = (object: string) =>
653+
Object.assign(new Error(`[Security] Access denied: read on ${object}`), {
654+
name: 'PermissionDeniedError', code: 'PERMISSION_DENIED', status: 403, statusCode: 403,
655+
});
656+
657+
async function bootMember(opts: { sharing?: unknown; security?: unknown } = {}) {
658+
const booted = await boot(opts);
659+
booted.ql.registerMiddleware(async (ctx: any, next: () => Promise<void>) => {
660+
if (READS.has(ctx.operation) && ctx.context?.isSystem !== true) {
661+
if (ctx.object === 'sys_file') throw readRefusal('sys_file');
662+
if (ctx.object === 'att_case' && ctx.context?.userId === BOB.userId) throw readRefusal('att_case');
663+
}
664+
await next();
665+
});
666+
return booted;
667+
}
668+
669+
const BYTES = Buffer.from('hello');
670+
/** A pending upload, as the presigned door and the raw PUT leave one. */
671+
async function seedUpload(
672+
ql: ObjectQL,
673+
storage: IStorageService,
674+
over: { id?: string; owner?: string; scope?: string; status?: string; bytes?: boolean } = {},
675+
) {
676+
const id = over.id ?? 'upl_alice';
677+
const scope = over.scope ?? 'attachments';
678+
const key = `${scope}/${id}.txt`;
679+
await ql.insert('sys_file', {
680+
id, key, name: 'notes.txt', mime_type: 'text/plain', size: BYTES.byteLength, scope, acl: 'private',
681+
status: over.status ?? 'pending', owner_id: over.owner ?? ALICE.userId, organization_id: 'org_a',
682+
}, SYS);
683+
if (over.bytes !== false) await storage.upload(key, BYTES, { contentType: 'text/plain' });
684+
return id;
685+
}
686+
const target = (fileId: string, over: Record<string, unknown> = {}) => ({ fileId, parentObject: 'att_case', parentId: 'case_a', ...over });
687+
const answer = (err: { code?: string; status?: number; message?: string }) => ({ code: err.code, status: err.status });
688+
const fileRow = async (ql: ObjectQL, id: string) => (await rows(ql, 'sys_file')).find((f) => f.id === id);
689+
690+
/** A refused attach leaves the upload as it was: pending, bytes stored, nothing attached. */
691+
async function expectUploadUntouched(ql: ObjectQL, storage: IStorageService, id: string) {
692+
const file = await fileRow(ql, id);
693+
expect(file?.status).toBe('pending');
694+
expect(await storedKeys(storage)).toContain(file!.key);
695+
expect(await rows(ql, 'sys_attachment')).toEqual([]);
696+
}
697+
698+
it('attaches as the member: the upload is committed and attached in one call, uploaded_by them, and sys_file stays unreadable to them', async () => {
699+
const { ql, attachments, storage } = await bootMember();
700+
const fileId = await seedUpload(ql, storage);
701+
const out = await asCaller(attachments).attachUploadAs(target(fileId, { eTag: 'etag-1' }), ALICE as never);
702+
expect(out).toEqual({ fileId, attachmentId: expect.any(String), size: BYTES.byteLength });
703+
expect(await fileRow(ql, fileId)).toMatchObject({ status: 'committed', etag: 'etag-1', owner_id: ALICE.userId });
704+
const links = await rows(ql, 'sys_attachment');
705+
expect(links).toHaveLength(1);
706+
expect(links[0]).toMatchObject({
707+
id: out.attachmentId, parent_object: 'att_case', parent_id: 'case_a', file_id: fileId,
708+
file_name: 'notes.txt', uploaded_by: ALICE.userId, organization_id: 'org_a',
709+
});
710+
const err = await refusalOf(ql.findOne('sys_file', { where: { id: fileId }, context: ALICE }));
711+
expect(answer(err)).toEqual({ code: 'PERMISSION_DENIED', status: 403 });
712+
});
713+
714+
it('control: another user’s pending upload is refused with the upload ownership rule’s one answer, and is left as it was', async () => {
715+
const { ql, attachments, storage } = await bootMember();
716+
const fileId = await seedUpload(ql, storage, { id: 'upl_bob', owner: BOB.userId });
717+
const err = await refusalOf(asCaller(attachments).attachUploadAs(target(fileId), ALICE as never));
718+
expect(answer(err)).toEqual({ code: 'PERMISSION_DENIED', status: 403 });
719+
await expectUploadUntouched(ql, storage, fileId);
720+
});
721+
722+
it('control: an id that names no file gets exactly the answer another user’s upload gets', async () => {
723+
const { ql, attachments, storage } = await bootMember();
724+
const others = await seedUpload(ql, storage, { id: 'upl_bob', owner: BOB.userId });
725+
const a = await refusalOf(asCaller(attachments).attachUploadAs(target(others), ALICE as never));
726+
const b = await refusalOf(asCaller(attachments).attachUploadAs(target('upl_nowhere'), ALICE as never));
727+
expect([b.code, b.status, b.message]).toEqual([a.code, a.status, a.message]);
728+
expect(await rows(ql, 'sys_attachment')).toEqual([]);
729+
});
730+
731+
it('control: a committed upload is refused RESOURCE_CONFLICT / 409 — only the upload this call commits is vouched for', async () => {
732+
const { ql, attachments, storage } = await bootMember();
733+
const fileId = await seedUpload(ql, storage, { status: 'committed' });
734+
const err = await refusalOf(asCaller(attachments).attachUploadAs(target(fileId), ALICE as never));
735+
expect(answer(err)).toEqual({ code: 'RESOURCE_CONFLICT', status: 409 });
736+
expect(await rows(ql, 'sys_attachment')).toEqual([]);
737+
});
738+
739+
it('control: a pending upload something already holds is refused RESOURCE_CONFLICT / 409, and no second attachment is written', async () => {
740+
const { ql, attachments, storage } = await bootMember();
741+
const fileId = await seedUpload(ql, storage);
742+
await ql.insert('sys_attachment', { parent_object: 'att_case', parent_id: 'case_global', file_id: fileId, file_name: 'n.txt' }, SYS);
743+
const err = await refusalOf(asCaller(attachments).attachUploadAs(target(fileId), ALICE as never));
744+
expect(answer(err)).toEqual({ code: 'RESOURCE_CONFLICT', status: 409 });
745+
expect(await rows(ql, 'sys_attachment')).toHaveLength(1);
746+
expect((await fileRow(ql, fileId))?.status).toBe('pending');
747+
});
748+
749+
it('control: an upload outside the attachments scope is refused INVALID_REQUEST / 400', async () => {
750+
const { ql, attachments, storage } = await bootMember();
751+
const fileId = await seedUpload(ql, storage, { scope: 'user' });
752+
const err = await refusalOf(asCaller(attachments).attachUploadAs(target(fileId), ALICE as never));
753+
expect(answer(err)).toEqual({ code: 'INVALID_REQUEST', status: 400 });
754+
expect(await rows(ql, 'sys_attachment')).toEqual([]);
755+
});
756+
757+
it('control: an upload whose bytes the backend does not hold is refused RESOURCE_CONFLICT / 409 and stays pending', async () => {
758+
const { ql, attachments, storage } = await bootMember();
759+
const fileId = await seedUpload(ql, storage, { bytes: false });
760+
const err = await refusalOf(asCaller(attachments).attachUploadAs(target(fileId), ALICE as never));
761+
expect(answer(err)).toEqual({ code: 'RESOURCE_CONFLICT', status: 409 });
762+
expect((await fileRow(ql, fileId))?.status).toBe('pending');
763+
expect(await rows(ql, 'sys_attachment')).toEqual([]);
764+
});
765+
766+
it('control: a member the attach rule refuses is refused ATTACHMENT_PARENT_ACCESS / 403, and the upload is left as it was', async () => {
767+
const sharing = { checkEdit: vi.fn(async () => 'deny') };
768+
const { ql, attachments, storage } = await bootMember({ sharing });
769+
const fileId = await seedUpload(ql, storage);
770+
const err = await refusalOf(asCaller(attachments).attachUploadAs(target(fileId), ALICE as never));
771+
expect(answer(err)).toEqual({ code: 'ATTACHMENT_PARENT_ACCESS', status: 403 });
772+
await expectUploadUntouched(ql, storage, fileId);
773+
});
774+
775+
it('control: a member without the permission to create an attachment is refused PERMISSION_DENIED / 403, and the upload is left as it was', async () => {
776+
const security = { explain: vi.fn(async () => ({ allowed: false })) };
777+
const { ql, attachments, storage } = await bootMember({ security });
778+
const fileId = await seedUpload(ql, storage);
779+
const err = await refusalOf(asCaller(attachments).attachUploadAs(target(fileId), ALICE as never));
780+
expect(answer(err)).toEqual({ code: 'PERMISSION_DENIED', status: 403 });
781+
expect(security.explain).toHaveBeenCalledWith({ object: 'sys_attachment', operation: 'create' }, ALICE);
782+
await expectUploadUntouched(ql, storage, fileId);
783+
});
784+
785+
it('control: a parent object without enable.files is refused FILES_DISABLED / 403 by the insert’s own gate, and the upload is left as it was', async () => {
786+
const { ql, attachments, storage } = await bootMember();
787+
const fileId = await seedUpload(ql, storage);
788+
const err = await refusalOf(asCaller(attachments).attachUploadAs(target(fileId, { parentObject: 'att_memo', parentId: 'memo_a' }), ALICE as never));
789+
expect(answer(err)).toEqual({ code: 'FILES_DISABLED', status: 403 });
790+
await expectUploadUntouched(ql, storage, fileId);
791+
});
792+
793+
it('control: a parent the member cannot read is refused, and the upload is left as it was', async () => {
794+
const { ql, attachments, storage } = await bootMember();
795+
const fileId = await seedUpload(ql, storage, { id: 'upl_bob', owner: BOB.userId });
796+
const err = await refusalOf(asCaller(attachments).attachUploadAs(target(fileId), BOB as never));
797+
expect(err.status).toBe(err.code === 'RECORD_NOT_FOUND' ? 404 : 403);
798+
expect(['RECORD_NOT_FOUND', 'ATTACHMENT_PARENT_ACCESS']).toContain(err.code);
799+
await expectUploadUntouched(ql, storage, fileId);
800+
});
801+
802+
it('the vouch is the verb’s call alone: after attachUploadAs, the member’s own insert naming the same file is refused reference_not_found', async () => {
803+
const { ql, attachments, storage } = await bootMember();
804+
const fileId = await seedUpload(ql, storage);
805+
await asCaller(attachments).attachUploadAs(target(fileId), ALICE as never);
806+
const err = await refusalOf(
807+
ql.insert('sys_attachment', { parent_object: 'att_case', parent_id: 'case_a', file_id: fileId, file_name: 'direct.txt' }, { context: ALICE }),
808+
);
809+
const envelope = resolveThrownHttpError(err);
810+
expect([envelope.code, envelope.status]).toEqual(['VALIDATION_FAILED', 400]);
811+
expect(((err as any).fields ?? []).map((f: any) => [f.field, f.code])).toEqual([['file_id', 'reference_not_found']]);
812+
expect(await rows(ql, 'sys_attachment')).toHaveLength(1);
813+
});
814+
815+
it('control: the system context and a context without a user are refused PERMISSION_DENIED / 403', async () => {
816+
const { ql, attachments, storage } = await bootMember();
817+
const fileId = await seedUpload(ql, storage);
818+
for (const context of [{ isSystem: true }, { tenantId: 'org_a' }]) {
819+
const err = await refusalOf(asCaller(attachments).attachUploadAs(target(fileId), context as never));
820+
expect(answer(err)).toEqual({ code: 'PERMISSION_DENIED', status: 403 });
821+
}
822+
await expectUploadUntouched(ql, storage, fileId);
823+
});
824+
});

0 commit comments

Comments
 (0)