|
9 | 9 | type FileReferenceEngine, |
10 | 10 | } from './file-reference-lifecycle.js'; |
11 | 11 | import { assertEngineFindOnePredicate } from '@objectstack/objectql'; |
| 12 | +import { resolveThrownHttpError } from '@objectstack/types'; |
12 | 13 |
|
13 | 14 | const silentLogger = () => ({ info: vi.fn(), warn: vi.fn(), debug: vi.fn() }); |
14 | 15 |
|
@@ -1357,3 +1358,191 @@ describe('a write references only a file its caller may read', () => { |
1357 | 1358 | }); |
1358 | 1359 | }); |
1359 | 1360 | }); |
| 1361 | + |
| 1362 | +describe('an anonymous write carries no file (#25694, ruling C at every anonymous write door)', () => { |
| 1363 | + /** The guest principal an `authRequired: false` endpoint's write executes as (ADR-0138 D9). */ |
| 1364 | + const GUEST = { principalKind: 'guest', positions: ['guest'], isSystem: false, tenantId: 'org_1' }; |
| 1365 | + const guestSession = { organizationId: 'org_1', positions: ['guest'] }; |
| 1366 | + const SIGNED_IN = { userId: 'u_writer', tenantId: 'org_1' }; |
| 1367 | + |
| 1368 | + const LABELLED: Record<string, any> = { |
| 1369 | + sys_file: REGISTRY.sys_file, |
| 1370 | + product: { |
| 1371 | + name: 'product', |
| 1372 | + fields: { |
| 1373 | + id: { type: 'text' }, |
| 1374 | + name: { type: 'text' }, |
| 1375 | + image: { type: 'image', label: 'Photo' }, |
| 1376 | + gallery: { type: 'image', multiple: true }, |
| 1377 | + manual: { type: 'file' }, |
| 1378 | + }, |
| 1379 | + }, |
| 1380 | + }; |
| 1381 | + |
| 1382 | + function installAdmitting(engine: Engine, storage: any = fakeStorage()) { |
| 1383 | + // A verdict that admits every file: the refusal must not lean on it. |
| 1384 | + const authorizeRead = vi.fn(async () => true); |
| 1385 | + installFileReferenceHooks(engine, () => storage, silentLogger(), authorizeRead); |
| 1386 | + return { storage, authorizeRead }; |
| 1387 | + } |
| 1388 | + |
| 1389 | + async function write( |
| 1390 | + engine: Engine, |
| 1391 | + phase: 'insert' | 'update', |
| 1392 | + data: Record<string, unknown>, |
| 1393 | + id: string, |
| 1394 | + caller: { session?: any; context?: any }, |
| 1395 | + ) { |
| 1396 | + const ctx: any = { |
| 1397 | + object: 'product', |
| 1398 | + event: phase === 'insert' ? 'beforeInsert' : 'beforeUpdate', |
| 1399 | + input: { ...(phase === 'update' ? { id } : {}), data, options: caller.context ? { context: caller.context } : {} }, |
| 1400 | + session: caller.session, |
| 1401 | + dispatch: recordDispatch(), |
| 1402 | + }; |
| 1403 | + await engine.trigger(ctx.event, ctx); |
| 1404 | + let row: Record<string, unknown> & { id: string }; |
| 1405 | + if (phase === 'insert') { |
| 1406 | + row = { ...(ctx.input.data as Record<string, unknown>), id }; |
| 1407 | + (engine.tables.product ??= []).push(row); |
| 1408 | + } else { |
| 1409 | + row = (engine.tables.product ?? []).find((r) => String(r.id) === id) as any; |
| 1410 | + Object.assign(row, ctx.input.data); |
| 1411 | + } |
| 1412 | + ctx.event = phase === 'insert' ? 'afterInsert' : 'afterUpdate'; |
| 1413 | + ctx.result = row; |
| 1414 | + await engine.trigger(ctx.event, ctx); |
| 1415 | + return row; |
| 1416 | + } |
| 1417 | + |
| 1418 | + const asGuest = { session: guestSession, context: GUEST }; |
| 1419 | + const refusedBy = (p: Promise<unknown>) => p.then(() => null, (e) => e); |
| 1420 | + const sysFileWrites = (engine: Engine) => |
| 1421 | + engine.calls.filter((c) => (c.op === 'update' || c.op === 'insert') && c.object === 'sys_file'); |
| 1422 | + |
| 1423 | + describe('a value in a file field is refused before anything is written', () => { |
| 1424 | + const VALUES: Array<[string, unknown]> = [ |
| 1425 | + ['an unclaimed file id', 'file_a'], |
| 1426 | + ['a file id another record owns', 'file_b'], |
| 1427 | + ['an external URL', 'https://example.test/x.png'], |
| 1428 | + ['an inline file value', { url: 'https://example.test/y.png', name: 'y.png' }], |
| 1429 | + ]; |
| 1430 | + for (const [what, value] of VALUES) { |
| 1431 | + it(`on insert: ${what}`, async () => { |
| 1432 | + const engine = fakeEngine({ |
| 1433 | + registry: LABELLED, |
| 1434 | + files: [file(), file({ id: 'file_b', key: 'user/file_b.png', ref_object: 'product', ref_id: 'p0', ref_field: 'image' })], |
| 1435 | + records: { product: [{ id: 'p0', image: 'file_b' }] }, |
| 1436 | + }); |
| 1437 | + const { storage, authorizeRead } = installAdmitting(engine); |
| 1438 | + |
| 1439 | + const err = await refusedBy(write(engine, 'insert', { name: 'n', image: value }, 'p1', asGuest)); |
| 1440 | + |
| 1441 | + const answered = resolveThrownHttpError(err); |
| 1442 | + expect({ status: answered.status, code: answered.code }).toEqual({ status: 400, code: 'VALIDATION_FAILED' }); |
| 1443 | + expect(err?.fields).toEqual([expect.objectContaining({ field: 'image', code: 'invalid_value', label: 'Photo' })]); |
| 1444 | + expect(err?.fields[0].message).toContain('Photo'); |
| 1445 | + // Nothing was written: no row, no claim, no copy, and no file was looked up for it. |
| 1446 | + expect((engine.tables.product ?? []).map((r) => r.id)).toEqual(['p0']); |
| 1447 | + expect(sysFileWrites(engine)).toEqual([]); |
| 1448 | + expect(storage.download).not.toHaveBeenCalled(); |
| 1449 | + expect(authorizeRead).not.toHaveBeenCalled(); |
| 1450 | + }); |
| 1451 | + } |
| 1452 | + |
| 1453 | + it('on update', async () => { |
| 1454 | + const engine = fakeEngine({ registry: LABELLED, files: [file()], records: { product: [{ id: 'p1', image: null }] } }); |
| 1455 | + installAdmitting(engine); |
| 1456 | + |
| 1457 | + const err = await refusedBy(write(engine, 'update', { image: 'file_a' }, 'p1', asGuest)); |
| 1458 | + |
| 1459 | + expect(err?.code).toBe('VALIDATION_FAILED'); |
| 1460 | + expect(err?.fields).toEqual([expect.objectContaining({ field: 'image', code: 'invalid_value' })]); |
| 1461 | + expect(engine.tables.product[0].image).toBeNull(); |
| 1462 | + expect(engine.tables.sys_file[0].ref_id ?? null).toBeNull(); |
| 1463 | + }); |
| 1464 | + |
| 1465 | + it('every file field carrying a value is named in one envelope, by its label or else its name', async () => { |
| 1466 | + const engine = fakeEngine({ registry: LABELLED, files: [file()] }); |
| 1467 | + installAdmitting(engine); |
| 1468 | + |
| 1469 | + const err = await refusedBy( |
| 1470 | + write(engine, 'insert', { image: 'file_a', gallery: ['file_a'], manual: 'https://example.test/m.pdf' }, 'p1', asGuest), |
| 1471 | + ); |
| 1472 | + |
| 1473 | + expect(err?.code).toBe('VALIDATION_FAILED'); |
| 1474 | + expect(err?.fields.map((f: any) => [f.field, f.code, f.label])).toEqual([ |
| 1475 | + ['image', 'invalid_value', 'Photo'], |
| 1476 | + ['gallery', 'invalid_value', 'gallery'], |
| 1477 | + ['manual', 'invalid_value', 'manual'], |
| 1478 | + ]); |
| 1479 | + }); |
| 1480 | + }); |
| 1481 | + |
| 1482 | + describe('CONTROL — what the rule leaves as it was', () => { |
| 1483 | + it('an anonymous write with every file field empty or absent is written', async () => { |
| 1484 | + const engine = fakeEngine({ registry: LABELLED, files: [file()] }); |
| 1485 | + installAdmitting(engine); |
| 1486 | + |
| 1487 | + for (const [id, data] of [ |
| 1488 | + ['p1', { name: 'absent' }], |
| 1489 | + ['p2', { name: 'null', image: null }], |
| 1490 | + ['p3', { name: 'blank', image: '' }], |
| 1491 | + ['p4', { name: 'empty set', gallery: [] }], |
| 1492 | + ] as const) { |
| 1493 | + await write(engine, 'insert', { ...data }, id, asGuest); |
| 1494 | + } |
| 1495 | + |
| 1496 | + expect(engine.tables.product.map((r) => r.id)).toEqual(['p1', 'p2', 'p3', 'p4']); |
| 1497 | + expect(engine.tables.sys_file[0].ref_id ?? null).toBeNull(); |
| 1498 | + }); |
| 1499 | + |
| 1500 | + it('an anonymous update that clears a file field, or leaves it out, is written', async () => { |
| 1501 | + const engine = fakeEngine({ registry: LABELLED, records: { product: [{ id: 'p1', name: 'a', image: null }] } }); |
| 1502 | + installAdmitting(engine); |
| 1503 | + |
| 1504 | + await write(engine, 'update', { name: 'b' }, 'p1', asGuest); |
| 1505 | + await write(engine, 'update', { image: null }, 'p1', asGuest); |
| 1506 | + |
| 1507 | + expect(engine.tables.product[0]).toMatchObject({ name: 'b', image: null }); |
| 1508 | + }); |
| 1509 | + |
| 1510 | + it('a signed-in caller\'s write still claims the file', async () => { |
| 1511 | + const engine = fakeEngine({ registry: LABELLED, files: [file()] }); |
| 1512 | + installAdmitting(engine); |
| 1513 | + |
| 1514 | + await write(engine, 'insert', { image: 'file_a' }, 'p1', { |
| 1515 | + session: { userId: SIGNED_IN.userId, organizationId: 'org_1' }, |
| 1516 | + context: SIGNED_IN, |
| 1517 | + }); |
| 1518 | + |
| 1519 | + expect(engine.tables.sys_file[0]).toMatchObject({ ref_object: 'product', ref_id: 'p1', ref_field: 'image' }); |
| 1520 | + }); |
| 1521 | + |
| 1522 | + it('a system write and a context-less write still claim the file', async () => { |
| 1523 | + const engine = fakeEngine({ registry: LABELLED, files: [file(), file({ id: 'file_c', key: 'user/file_c.png' })] }); |
| 1524 | + installAdmitting(engine); |
| 1525 | + |
| 1526 | + await write(engine, 'insert', { image: 'file_a' }, 'p1', { session: { isSystem: true }, context: { isSystem: true } }); |
| 1527 | + await write(engine, 'insert', { image: 'file_c' }, 'p2', {}); |
| 1528 | + |
| 1529 | + expect(engine.tables.sys_file.map((f) => [f.id, f.ref_id ?? null])).toEqual([ |
| 1530 | + ['file_a', 'p1'], |
| 1531 | + ['file_c', 'p2'], |
| 1532 | + ]); |
| 1533 | + }); |
| 1534 | + |
| 1535 | + it('an object with no file field is not judged', async () => { |
| 1536 | + const engine = fakeEngine({ files: [file()] }); |
| 1537 | + installAdmitting(engine); |
| 1538 | + const ctx: any = { |
| 1539 | + object: 'tag', event: 'beforeInsert', input: { data: { label: 'file_a' }, options: { context: GUEST } }, |
| 1540 | + session: guestSession, dispatch: recordDispatch(), |
| 1541 | + }; |
| 1542 | + |
| 1543 | + await engine.trigger('beforeInsert', ctx); |
| 1544 | + |
| 1545 | + expect(engine.calls).toEqual([]); |
| 1546 | + }); |
| 1547 | + }); |
| 1548 | +}); |
0 commit comments