@volontariapp/domain-storage 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +18 -2
  2. package/dist/entities/file.entity.d.ts +1 -1
  3. package/dist/entities/file.entity.d.ts.map +1 -1
  4. package/dist/entities/file.entity.js +6 -3
  5. package/dist/entities/file.entity.js.map +1 -1
  6. package/dist/enums/file-attachment-outcome.enum.d.ts +8 -0
  7. package/dist/enums/file-attachment-outcome.enum.d.ts.map +1 -0
  8. package/dist/enums/file-attachment-outcome.enum.js +9 -0
  9. package/dist/enums/file-attachment-outcome.enum.js.map +1 -0
  10. package/dist/enums/index.d.ts +1 -0
  11. package/dist/enums/index.d.ts.map +1 -1
  12. package/dist/enums/index.js +1 -0
  13. package/dist/enums/index.js.map +1 -1
  14. package/dist/models/index.js.map +1 -1
  15. package/dist/policies/attachment-validation.rule.d.ts +13 -0
  16. package/dist/policies/attachment-validation.rule.d.ts.map +1 -1
  17. package/dist/policies/attachment-validation.rule.js +35 -0
  18. package/dist/policies/attachment-validation.rule.js.map +1 -1
  19. package/dist/repositories/file-outbox.builders.d.ts +11 -1
  20. package/dist/repositories/file-outbox.builders.d.ts.map +1 -1
  21. package/dist/repositories/file-outbox.builders.js +25 -1
  22. package/dist/repositories/file-outbox.builders.js.map +1 -1
  23. package/dist/repositories/file-repository.types.d.ts +36 -0
  24. package/dist/repositories/file-repository.types.d.ts.map +1 -1
  25. package/dist/repositories/interfaces/file.repository.d.ts +5 -1
  26. package/dist/repositories/interfaces/file.repository.d.ts.map +1 -1
  27. package/dist/repositories/postgres-file.repository.d.ts +10 -2
  28. package/dist/repositories/postgres-file.repository.d.ts.map +1 -1
  29. package/dist/repositories/postgres-file.repository.js +217 -3
  30. package/dist/repositories/postgres-file.repository.js.map +1 -1
  31. package/dist/test/confirmation-validation.rule.unit.spec.d.ts +2 -0
  32. package/dist/test/confirmation-validation.rule.unit.spec.d.ts.map +1 -0
  33. package/dist/test/confirmation-validation.rule.unit.spec.js +85 -0
  34. package/dist/test/confirmation-validation.rule.unit.spec.js.map +1 -0
  35. package/dist/test/file-outbox.builders.unit.spec.js +52 -3
  36. package/dist/test/file-outbox.builders.unit.spec.js.map +1 -1
  37. package/dist/test/file.entity.unit.spec.js +23 -5
  38. package/dist/test/file.entity.unit.spec.js.map +1 -1
  39. package/dist/test/helpers/file-db.helper.d.ts +2 -2
  40. package/dist/test/helpers/file-db.helper.d.ts.map +1 -1
  41. package/dist/test/helpers/file-db.helper.js +2 -8
  42. package/dist/test/helpers/file-db.helper.js.map +1 -1
  43. package/dist/test/integration/postgres-file.repository.attach-release.int.spec.d.ts +2 -0
  44. package/dist/test/integration/postgres-file.repository.attach-release.int.spec.d.ts.map +1 -0
  45. package/dist/test/integration/postgres-file.repository.attach-release.int.spec.js +623 -0
  46. package/dist/test/integration/postgres-file.repository.attach-release.int.spec.js.map +1 -0
  47. package/dist/test/integration/postgres-file.repository.int.spec.js +5 -56
  48. package/dist/test/integration/postgres-file.repository.int.spec.js.map +1 -1
  49. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"file-db.helper.js","sourceRoot":"","sources":["../../../src/test/helpers/file-db.helper.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAC1E,OAAO,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAC;AAE7D,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAG7D,MAAM,CAAC,MAAM,UAAU,GAAG,KAAK,EAAE,YAA+B,EAAE,EAAsB,EAAE;IACxF,MAAM,UAAU,GAAG,cAAc,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC;IAC3D,OAAO,UAAU,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;AACtE,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,QAAQ,GAAG,KAAK,EAAE,EAAU,EAAsB,EAAE,CAC/D,cAAc,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,eAAe,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;AAElE,MAAM,CAAC,MAAM,UAAU,GAAG,KAAK,IAAgC,EAAE,CAC/D,cAAc,CAAC,aAAa,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;AAEtF,MAAM,CAAC,MAAM,QAAQ,GAAG,KAAK,IAAgC,EAAE,CAC7D,cAAc,CAAC,aAAa,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;AAOtF,MAAM,CAAC,MAAM,cAAc,GAAG,KAAK,EACjC,MAAc,EACd,QAAgB,EACY,EAAE;IAC9B,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,cAAc,CAAC,KAAK,CACvC;;;2BAGuB,EACvB,CAAC,UAAU,CAAC,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,CAAC,OAAO,EAAE,UAAU,CAAC,QAAQ,CAAC,CACjF,CAAC;IACF,OAAO,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,WAAW,IAAI,IAAI,CAAC;AACzC,CAAC,CAAC"}
1
+ {"version":3,"file":"file-db.helper.js","sourceRoot":"","sources":["../../../src/test/helpers/file-db.helper.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAC1E,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AACvD,OAAO,EAAE,mBAAmB,EAAE,MAAM,uCAAuC,CAAC;AAC5E,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAG7D,MAAM,CAAC,MAAM,UAAU,GAAG,KAAK,EAAE,YAA+B,EAAE,EAAsB,EAAE;IACxF,MAAM,UAAU,GAAG,cAAc,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC;IAC3D,OAAO,UAAU,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;AACtE,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,QAAQ,GAAG,KAAK,EAAE,EAAU,EAAsB,EAAE,CAC/D,cAAc,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,eAAe,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;AAElE,MAAM,CAAC,MAAM,cAAc,GAAG,KAAK,IAAoC,EAAE,CACvE,cAAc,CAAC,aAAa,CAAC,mBAAmB,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;AAE3F,MAAM,CAAC,MAAM,UAAU,GAAG,KAAK,IAAgC,EAAE,CAC/D,cAAc,CAAC,aAAa,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;AAEtF,MAAM,CAAC,MAAM,QAAQ,GAAG,KAAK,IAAgC,EAAE,CAC7D,cAAc,CAAC,aAAa,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=postgres-file.repository.attach-release.int.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres-file.repository.attach-release.int.spec.d.ts","sourceRoot":"","sources":["../../../src/test/integration/postgres-file.repository.attach-release.int.spec.ts"],"names":[],"mappings":""}
@@ -0,0 +1,623 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest, } from '@jest/globals';
2
+ import { StorageAttachmentRejectionReason, StorageEventMessagingType, } from '@volontariapp/messaging';
3
+ import { EntityType, FileAttachmentOutcome, FileId, FileStatus, InvalidEntityTypeException, RejectionReason, ScanStatus, } from '../../index.js';
4
+ import { FileModel } from '../../models/index.js';
5
+ import { FILE_SCAN_RESULT_TARGET_SERVICES, OWNER_RELEASE_EXCLUDED_ENTITY_TYPES, PostgresFileRepository, } from '../../repositories/index.js';
6
+ import { closeTestDb, initializeTestDb, testDataSource, truncateAll } from '../data-source.js';
7
+ import { insertFile, readEvents, readFile, readTombstones } from '../helpers/file-db.helper.js';
8
+ import { mockFileUpdateFailure } from '../mocks/file-update-failure.mock.js';
9
+ import { mockOutboxInsertFailure } from '../mocks/outbox-write-failure.mock.js';
10
+ const newId = () => FileId.generate().getValue();
11
+ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
12
+ describe('PostgresFileRepository attachment and release (integration)', () => {
13
+ let repository;
14
+ let restore;
15
+ const ownerId = newId();
16
+ const entityId = newId();
17
+ const confirmation = (fileIds, overrides = {}) => ({ fileIds, entityType: EntityType.POST, entityId, ownerId, ...overrides });
18
+ const reservedFile = (overrides = {}) => insertFile({
19
+ ownerId,
20
+ status: FileStatus.RESERVED,
21
+ entityId,
22
+ reservedAt: new Date(),
23
+ scanStatus: ScanStatus.CLEAN,
24
+ ...overrides,
25
+ });
26
+ const pendingFile = (overrides = {}) => insertFile({ ownerId, scanStatus: ScanStatus.CLEAN, ...overrides });
27
+ const attachedFile = (overrides = {}) => reservedFile({ status: FileStatus.ATTACHED, attachedAt: new Date(), ...overrides });
28
+ beforeAll(async () => {
29
+ await initializeTestDb();
30
+ repository = new PostgresFileRepository(testDataSource.getRepository(FileModel));
31
+ });
32
+ afterAll(async () => {
33
+ await closeTestDb();
34
+ });
35
+ beforeEach(async () => {
36
+ await truncateAll();
37
+ });
38
+ afterEach(async () => {
39
+ jest.restoreAllMocks();
40
+ await restore?.();
41
+ restore = undefined;
42
+ });
43
+ describe('attachFromConfirmationEvent()', () => {
44
+ describe('synchronous path (RESERVED for the entity)', () => {
45
+ it('attaches a CLEAN reserved file and emits storage.file_scanned once', async () => {
46
+ const file = await reservedFile();
47
+ const [result] = await repository.attachFromConfirmationEvent(confirmation([file.id]));
48
+ expect(result).toEqual({
49
+ fileId: file.id,
50
+ outcome: FileAttachmentOutcome.ATTACHED,
51
+ scanEventEmitted: true,
52
+ });
53
+ const row = await readFile(file.id);
54
+ expect(row.status).toBe(FileStatus.ATTACHED);
55
+ expect(row.entityId).toBe(entityId);
56
+ expect(row.attachedAt).toBeInstanceOf(Date);
57
+ const events = await readEvents();
58
+ expect(events).toHaveLength(1);
59
+ expect(events[0]?.type).toBe(StorageEventMessagingType.FILE_SCANNED);
60
+ expect(events[0]?.payload.after).toEqual({
61
+ fileId: file.id,
62
+ ownerId,
63
+ entityType: EntityType.POST,
64
+ entityId,
65
+ });
66
+ expect(events[0]?.targetServices).toEqual(FILE_SCAN_RESULT_TARGET_SERVICES);
67
+ });
68
+ it('emits storage.file_rejected with the reason of a scan that ended REJECTED', async () => {
69
+ const file = await reservedFile({
70
+ scanStatus: ScanStatus.REJECTED,
71
+ rejectionReason: RejectionReason.MALWARE,
72
+ });
73
+ const [result] = await repository.attachFromConfirmationEvent(confirmation([file.id]));
74
+ expect(result.outcome).toBe(FileAttachmentOutcome.ATTACHED);
75
+ expect(result.scanEventEmitted).toBe(true);
76
+ const events = await readEvents();
77
+ expect(events).toHaveLength(1);
78
+ expect(events[0]?.type).toBe(StorageEventMessagingType.FILE_REJECTED);
79
+ expect(events[0]?.payload.after).toEqual({
80
+ fileId: file.id,
81
+ ownerId,
82
+ entityType: EntityType.POST,
83
+ entityId,
84
+ reason: RejectionReason.MALWARE,
85
+ });
86
+ });
87
+ it('attaches a reserved file while it is still SCANNING, and the scan emits afterwards', async () => {
88
+ const file = await reservedFile({ scanStatus: ScanStatus.SCANNING });
89
+ const [result] = await repository.attachFromConfirmationEvent(confirmation([file.id]));
90
+ expect(result.scanEventEmitted).toBe(false);
91
+ expect(await readEvents()).toHaveLength(0);
92
+ const scan = await repository.completeScan({ fileId: file.id, publicKey: 'public/x' });
93
+ expect(scan?.eventEmitted).toBe(true);
94
+ const events = await readEvents();
95
+ expect(events).toHaveLength(1);
96
+ expect(events[0]?.type).toBe(StorageEventMessagingType.FILE_SCANNED);
97
+ });
98
+ it('scan then attachment: the scan stays silent, the attachment emits (CLEAN and REJECTED)', async () => {
99
+ const cleaned = await reservedFile({ scanStatus: ScanStatus.SCANNING });
100
+ const refused = await reservedFile({ scanStatus: ScanStatus.SCANNING });
101
+ const clean = await repository.completeScan({ fileId: cleaned.id, publicKey: 'p/x' });
102
+ const reject = await repository.rejectScan({
103
+ fileId: refused.id,
104
+ reason: RejectionReason.UNDECODABLE,
105
+ });
106
+ expect(clean?.eventEmitted).toBe(false);
107
+ expect(reject?.eventEmitted).toBe(false);
108
+ expect(await readEvents()).toHaveLength(0);
109
+ const results = await repository.attachFromConfirmationEvent(confirmation([cleaned.id, refused.id]));
110
+ expect(results.map((result) => result.scanEventEmitted)).toEqual([true, true]);
111
+ const types = (await readEvents()).map((event) => event.type).sort();
112
+ expect(types).toEqual([
113
+ StorageEventMessagingType.FILE_REJECTED,
114
+ StorageEventMessagingType.FILE_SCANNED,
115
+ ]);
116
+ });
117
+ it('attachment then rejection: the scan transition emits storage.file_rejected', async () => {
118
+ const file = await reservedFile({ scanStatus: ScanStatus.SCANNING });
119
+ await repository.attachFromConfirmationEvent(confirmation([file.id]));
120
+ const scan = await repository.rejectScan({
121
+ fileId: file.id,
122
+ reason: RejectionReason.MIME_MISMATCH,
123
+ });
124
+ expect(scan?.eventEmitted).toBe(true);
125
+ const events = await readEvents();
126
+ expect(events).toHaveLength(1);
127
+ expect(events[0]?.type).toBe(StorageEventMessagingType.FILE_REJECTED);
128
+ });
129
+ it('attaches a reservation that expired long ago: no condition on a date', async () => {
130
+ const file = await reservedFile({ reservedAt: new Date(Date.now() - THIRTY_DAYS_MS) });
131
+ const [result] = await repository.attachFromConfirmationEvent(confirmation([file.id]));
132
+ expect(result.outcome).toBe(FileAttachmentOutcome.ATTACHED);
133
+ });
134
+ });
135
+ describe('asynchronous path (PENDING, never reserved)', () => {
136
+ it('validates and attaches a CLEAN pending file, and emits the scan result', async () => {
137
+ const file = await pendingFile();
138
+ const [result] = await repository.attachFromConfirmationEvent(confirmation([file.id]));
139
+ expect(result.outcome).toBe(FileAttachmentOutcome.ATTACHED);
140
+ expect(result.scanEventEmitted).toBe(true);
141
+ const row = await readFile(file.id);
142
+ expect(row.status).toBe(FileStatus.ATTACHED);
143
+ expect(row.entityId).toBe(entityId);
144
+ expect(row.reservedAt).toBeNull();
145
+ expect((await readEvents()).map((event) => event.type)).toEqual([
146
+ StorageEventMessagingType.FILE_SCANNED,
147
+ ]);
148
+ });
149
+ it('attaches a SCANNING pending file for an entity that allows ASYNC, without event', async () => {
150
+ const file = await pendingFile({ scanStatus: ScanStatus.SCANNING });
151
+ const [result] = await repository.attachFromConfirmationEvent(confirmation([file.id]));
152
+ expect(result.outcome).toBe(FileAttachmentOutcome.ATTACHED);
153
+ expect(result.scanEventEmitted).toBe(false);
154
+ expect(await readEvents()).toHaveLength(0);
155
+ });
156
+ it('applies the same rule as reserve: SCANNING is refused for an avatar', async () => {
157
+ const file = await pendingFile({
158
+ entityType: EntityType.USER_AVATAR,
159
+ scanStatus: ScanStatus.SCANNING,
160
+ });
161
+ const [result] = await repository.attachFromConfirmationEvent(confirmation([file.id], { entityType: EntityType.USER_AVATAR }));
162
+ expect(result.outcome).toBe(FileAttachmentOutcome.REJECTED);
163
+ expect(result.rejectionReason).toBe(StorageAttachmentRejectionReason.NOT_CONFIRMED);
164
+ expect((await readFile(file.id)).status).toBe(FileStatus.PENDING);
165
+ });
166
+ it('scan then attachment and attachment then scan both emit exactly once', async () => {
167
+ const scanFirst = await pendingFile({ scanStatus: ScanStatus.SCANNING });
168
+ const attachFirst = await pendingFile({ scanStatus: ScanStatus.SCANNING });
169
+ await repository.completeScan({ fileId: scanFirst.id, publicKey: 'p/a' });
170
+ await repository.attachFromConfirmationEvent(confirmation([scanFirst.id]));
171
+ await repository.attachFromConfirmationEvent(confirmation([attachFirst.id]));
172
+ await repository.completeScan({ fileId: attachFirst.id, publicKey: 'p/b' });
173
+ const events = await readEvents();
174
+ expect(events).toHaveLength(2);
175
+ expect(events.map((event) => event.payload.after)).toEqual(expect.arrayContaining([
176
+ expect.objectContaining({ fileId: scanFirst.id }),
177
+ expect.objectContaining({ fileId: attachFirst.id }),
178
+ ]));
179
+ });
180
+ });
181
+ describe('refusals', () => {
182
+ const rejectionOf = async (file, overrides = {}) => {
183
+ const [result] = await repository.attachFromConfirmationEvent(confirmation([file.id], overrides));
184
+ return result;
185
+ };
186
+ const expectRejectedEvent = async (fileId, reason) => {
187
+ const events = await readEvents();
188
+ expect(events).toHaveLength(1);
189
+ expect(events[0]?.type).toBe(StorageEventMessagingType.ATTACHMENT_REJECTED);
190
+ expect(events[0]?.payload.after).toEqual({
191
+ fileId,
192
+ entityType: EntityType.POST,
193
+ entityId,
194
+ reason,
195
+ });
196
+ expect(events[0]?.emitterId).toBe(ownerId);
197
+ expect(events[0]?.targetServices).toEqual(FILE_SCAN_RESULT_TARGET_SERVICES);
198
+ };
199
+ it('writes NOT_FOUND for an unknown file and for a file of another owner', async () => {
200
+ const foreign = await pendingFile({ ownerId: newId() });
201
+ const unknownId = newId();
202
+ const results = await repository.attachFromConfirmationEvent(confirmation([foreign.id, unknownId]));
203
+ expect(results.map((result) => result.rejectionReason)).toEqual([
204
+ StorageAttachmentRejectionReason.NOT_FOUND,
205
+ StorageAttachmentRejectionReason.NOT_FOUND,
206
+ ]);
207
+ expect(await readEvents()).toHaveLength(2);
208
+ expect((await readFile(foreign.id)).status).toBe(FileStatus.PENDING);
209
+ });
210
+ it('writes WRONG_ENTITY_TYPE for a file declared for another entity type', async () => {
211
+ const file = await pendingFile({ entityType: EntityType.EVENT_COVER });
212
+ const result = await rejectionOf(file);
213
+ expect(result.rejectionReason).toBe(StorageAttachmentRejectionReason.WRONG_ENTITY_TYPE);
214
+ await expectRejectedEvent(file.id, StorageAttachmentRejectionReason.WRONG_ENTITY_TYPE);
215
+ });
216
+ it('writes NOT_CONFIRMED for an upload that was not confirmed', async () => {
217
+ const file = await pendingFile({ scanStatus: ScanStatus.AWAITING_UPLOAD });
218
+ const result = await rejectionOf(file);
219
+ expect(result.rejectionReason).toBe(StorageAttachmentRejectionReason.NOT_CONFIRMED);
220
+ await expectRejectedEvent(file.id, StorageAttachmentRejectionReason.NOT_CONFIRMED);
221
+ expect((await readFile(file.id)).status).toBe(FileStatus.PENDING);
222
+ });
223
+ it('writes CONTENT_REJECTED for a file whose scan refused the content', async () => {
224
+ const file = await pendingFile({
225
+ scanStatus: ScanStatus.REJECTED,
226
+ rejectionReason: RejectionReason.MALWARE,
227
+ });
228
+ const result = await rejectionOf(file);
229
+ expect(result.rejectionReason).toBe(StorageAttachmentRejectionReason.CONTENT_REJECTED);
230
+ await expectRejectedEvent(file.id, StorageAttachmentRejectionReason.CONTENT_REJECTED);
231
+ });
232
+ it.each([FileStatus.RESERVED, FileStatus.ATTACHED])('writes ALREADY_ATTACHED for a file %s for another entity, and leaves it alone', async (status) => {
233
+ const otherEntityId = newId();
234
+ const file = await reservedFile({ status, entityId: otherEntityId });
235
+ const result = await rejectionOf(file);
236
+ expect(result.rejectionReason).toBe(StorageAttachmentRejectionReason.ALREADY_ATTACHED);
237
+ await expectRejectedEvent(file.id, StorageAttachmentRejectionReason.ALREADY_ATTACHED);
238
+ const row = await readFile(file.id);
239
+ expect(row.status).toBe(status);
240
+ expect(row.entityId).toBe(otherEntityId);
241
+ });
242
+ it.each([FileStatus.ORPHANED, FileStatus.DELETED])('writes NOT_FOUND for a %s file that was never held by this entity', async (status) => {
243
+ const file = await pendingFile({ status });
244
+ const result = await rejectionOf(file);
245
+ expect(result.rejectionReason).toBe(StorageAttachmentRejectionReason.NOT_FOUND);
246
+ expect((await readFile(file.id)).status).toBe(status);
247
+ });
248
+ it('judges each file on its own: a valid one is attached next to an invalid one', async () => {
249
+ const valid = await pendingFile();
250
+ const invalid = await pendingFile({ scanStatus: ScanStatus.REJECTED });
251
+ const results = await repository.attachFromConfirmationEvent(confirmation([invalid.id, valid.id]));
252
+ expect(results.map((result) => result.outcome)).toEqual([
253
+ FileAttachmentOutcome.REJECTED,
254
+ FileAttachmentOutcome.ATTACHED,
255
+ ]);
256
+ expect((await readFile(valid.id)).status).toBe(FileStatus.ATTACHED);
257
+ expect((await readFile(invalid.id)).status).toBe(FileStatus.PENDING);
258
+ });
259
+ it('refuses an unknown entity type before any write', async () => {
260
+ const file = await pendingFile();
261
+ await expect(repository.attachFromConfirmationEvent(confirmation([file.id], { entityType: 'TOSTRING' }))).rejects.toThrow(InvalidEntityTypeException);
262
+ expect(await readEvents()).toHaveLength(0);
263
+ });
264
+ it('does nothing for an event that names no file', async () => {
265
+ expect(await repository.attachFromConfirmationEvent(confirmation([]))).toEqual([]);
266
+ expect(await readEvents()).toHaveLength(0);
267
+ });
268
+ });
269
+ describe('idempotence', () => {
270
+ it('a replayed event attaches nothing more and emits nothing more', async () => {
271
+ const reserved = await reservedFile();
272
+ const pending = await pendingFile();
273
+ const input = confirmation([reserved.id, pending.id]);
274
+ const first = await repository.attachFromConfirmationEvent(input);
275
+ const attachedAt = (await readFile(pending.id)).attachedAt;
276
+ const eventsAfterFirst = await readEvents();
277
+ const second = await repository.attachFromConfirmationEvent(input);
278
+ expect(first.map((result) => result.outcome)).toEqual([
279
+ FileAttachmentOutcome.ATTACHED,
280
+ FileAttachmentOutcome.ATTACHED,
281
+ ]);
282
+ expect(second).toEqual([
283
+ {
284
+ fileId: reserved.id,
285
+ outcome: FileAttachmentOutcome.ALREADY_ATTACHED,
286
+ scanEventEmitted: false,
287
+ },
288
+ {
289
+ fileId: pending.id,
290
+ outcome: FileAttachmentOutcome.ALREADY_ATTACHED,
291
+ scanEventEmitted: false,
292
+ },
293
+ ]);
294
+ expect(await readEvents()).toHaveLength(eventsAfterFirst.length);
295
+ expect((await readFile(pending.id)).attachedAt).toEqual(attachedAt);
296
+ });
297
+ it('a replay after a REJECTED scan is still a replay, whatever the scan became', async () => {
298
+ const file = await reservedFile({ scanStatus: ScanStatus.SCANNING });
299
+ await repository.attachFromConfirmationEvent(confirmation([file.id]));
300
+ await repository.rejectScan({ fileId: file.id, reason: RejectionReason.MALWARE });
301
+ const events = await readEvents();
302
+ const [result] = await repository.attachFromConfirmationEvent(confirmation([file.id]));
303
+ expect(result.outcome).toBe(FileAttachmentOutcome.ALREADY_ATTACHED);
304
+ expect(await readEvents()).toHaveLength(events.length);
305
+ });
306
+ it('a replayed refusal changes no file', async () => {
307
+ const file = await pendingFile({ scanStatus: ScanStatus.REJECTED });
308
+ const input = confirmation([file.id]);
309
+ await repository.attachFromConfirmationEvent(input);
310
+ const before = await readFile(file.id);
311
+ const [again] = await repository.attachFromConfirmationEvent(input);
312
+ expect(again.outcome).toBe(FileAttachmentOutcome.REJECTED);
313
+ expect(await readFile(file.id)).toEqual(before);
314
+ });
315
+ it('the release of a file that came first is recognised: no effect, no event', async () => {
316
+ const file = await attachedFile();
317
+ await repository.releaseFile({ fileId: file.id, entityType: EntityType.POST, entityId });
318
+ const [result] = await repository.attachFromConfirmationEvent(confirmation([file.id]));
319
+ expect(result.outcome).toBe(FileAttachmentOutcome.ALREADY_RELEASED);
320
+ expect((await readFile(file.id)).status).toBe(FileStatus.ORPHANED);
321
+ expect(await readEvents()).toHaveLength(0);
322
+ });
323
+ it('ORPHANED is absorbing: an orphaned reserved file is never attached again', async () => {
324
+ const file = await reservedFile();
325
+ await repository.releaseForEntity({ entityType: EntityType.POST, entityId });
326
+ const [result] = await repository.attachFromConfirmationEvent(confirmation([file.id]));
327
+ expect(result.outcome).toBe(FileAttachmentOutcome.ENTITY_RELEASED);
328
+ expect((await readFile(file.id)).status).toBe(FileStatus.ORPHANED);
329
+ });
330
+ });
331
+ describe('released entity (tombstone)', () => {
332
+ it('releases the named pending files instead of attaching them, and writes no event', async () => {
333
+ const pending = await pendingFile();
334
+ const reserved = await reservedFile();
335
+ await repository.releaseForEntity({ entityType: EntityType.POST, entityId });
336
+ await repository.releaseForEntity({ entityType: EntityType.POST, entityId });
337
+ const lateEventFiles = [pending.id, reserved.id];
338
+ const results = await repository.attachFromConfirmationEvent(confirmation(lateEventFiles));
339
+ expect(results.map((result) => result.outcome)).toEqual([
340
+ FileAttachmentOutcome.ENTITY_RELEASED,
341
+ FileAttachmentOutcome.ENTITY_RELEASED,
342
+ ]);
343
+ const row = await readFile(pending.id);
344
+ expect(row.status).toBe(FileStatus.ORPHANED);
345
+ expect(row.entityId).toBe(entityId);
346
+ expect(await readEvents()).toHaveLength(0);
347
+ });
348
+ it('releases only the files of the owner, of the entity type, and not held elsewhere', async () => {
349
+ const foreign = await pendingFile({ ownerId: newId() });
350
+ const otherType = await pendingFile({ entityType: EntityType.EVENT_COVER });
351
+ const heldElsewhere = await reservedFile({ entityId: newId() });
352
+ await repository.releaseForEntity({ entityType: EntityType.POST, entityId });
353
+ await repository.attachFromConfirmationEvent(confirmation([foreign.id, otherType.id, heldElsewhere.id]));
354
+ expect((await readFile(foreign.id)).status).toBe(FileStatus.PENDING);
355
+ expect((await readFile(otherType.id)).status).toBe(FileStatus.PENDING);
356
+ expect((await readFile(heldElsewhere.id)).status).toBe(FileStatus.RESERVED);
357
+ });
358
+ it('a late event after the release of a deleted post: the file is never attached', async () => {
359
+ const file = await pendingFile();
360
+ await repository.releaseForEntity({ entityType: EntityType.POST, entityId });
361
+ await repository.attachFromConfirmationEvent(confirmation([file.id]));
362
+ expect((await readFile(file.id)).status).toBe(FileStatus.ORPHANED);
363
+ expect(await readTombstones()).toHaveLength(1);
364
+ });
365
+ });
366
+ describe('atomicity', () => {
367
+ it('rolls the attachment back when the outbox write fails', async () => {
368
+ const file = await reservedFile();
369
+ const other = await pendingFile();
370
+ restore = await mockOutboxInsertFailure('event_queue');
371
+ await expect(repository.attachFromConfirmationEvent(confirmation([file.id, other.id]))).rejects.toThrow('simulated outbox failure');
372
+ const row = await readFile(file.id);
373
+ expect(row.status).toBe(FileStatus.RESERVED);
374
+ expect(row.attachedAt).toBeNull();
375
+ expect((await readFile(other.id)).status).toBe(FileStatus.PENDING);
376
+ expect(await readEvents()).toHaveLength(0);
377
+ });
378
+ it('rolls the rejection event back with the attachments of the same event', async () => {
379
+ const valid = await pendingFile({ scanStatus: ScanStatus.SCANNING });
380
+ const invalid = await pendingFile({ scanStatus: ScanStatus.REJECTED });
381
+ restore = await mockOutboxInsertFailure('event_queue');
382
+ await expect(repository.attachFromConfirmationEvent(confirmation([valid.id, invalid.id]))).rejects.toThrow('simulated outbox failure');
383
+ expect((await readFile(valid.id)).status).toBe(FileStatus.PENDING);
384
+ });
385
+ it('rolls everything back when the UPDATE of one file fails', async () => {
386
+ const first = await pendingFile();
387
+ const failing = await pendingFile();
388
+ restore = await mockFileUpdateFailure(failing.id);
389
+ await expect(repository.attachFromConfirmationEvent(confirmation([first.id, failing.id]))).rejects.toThrow('simulated file update failure');
390
+ expect((await readFile(first.id)).status).toBe(FileStatus.PENDING);
391
+ expect(await readEvents()).toHaveLength(0);
392
+ });
393
+ });
394
+ describe('concurrency', () => {
395
+ it('two instances handling the same event attach once and emit once', async () => {
396
+ for (let run = 0; run < 15; run += 1) {
397
+ await truncateAll();
398
+ const file = await pendingFile();
399
+ const input = confirmation([file.id]);
400
+ const results = await Promise.all([
401
+ repository.attachFromConfirmationEvent(input),
402
+ repository.attachFromConfirmationEvent(input),
403
+ ]);
404
+ expect(results
405
+ .flat()
406
+ .map((result) => result.outcome)
407
+ .sort()).toEqual([FileAttachmentOutcome.ALREADY_ATTACHED, FileAttachmentOutcome.ATTACHED]);
408
+ expect(await readEvents()).toHaveLength(1);
409
+ }
410
+ });
411
+ it('a concurrent scan and attachment emit exactly once, in either order', async () => {
412
+ for (let run = 0; run < 25; run += 1) {
413
+ await truncateAll();
414
+ const file = await reservedFile({ scanStatus: ScanStatus.SCANNING });
415
+ const [attached, scan] = await Promise.all([
416
+ repository.attachFromConfirmationEvent(confirmation([file.id])),
417
+ repository.completeScan({ fileId: file.id, publicKey: 'public/x' }),
418
+ ]);
419
+ const emitted = (attached[0]?.scanEventEmitted ? 1 : 0) +
420
+ (scan?.eventEmitted === true ? 1 : 0);
421
+ expect(emitted).toBe(1);
422
+ expect(await readEvents()).toHaveLength(1);
423
+ }
424
+ });
425
+ it('a release and a late attachment of the same entity never leave a file attached', async () => {
426
+ for (let run = 0; run < 25; run += 1) {
427
+ await truncateAll();
428
+ const file = await pendingFile();
429
+ await Promise.all([
430
+ repository.releaseForEntity({ entityType: EntityType.POST, entityId }),
431
+ repository.attachFromConfirmationEvent(confirmation([file.id])),
432
+ ]);
433
+ expect((await readFile(file.id)).status).toBe(FileStatus.ORPHANED);
434
+ expect(await readTombstones()).toHaveLength(1);
435
+ }
436
+ });
437
+ });
438
+ });
439
+ describe('releaseForEntity()', () => {
440
+ it('orphans the RESERVED and ATTACHED files of the entity and writes the tombstone', async () => {
441
+ const reserved = await reservedFile();
442
+ const attached = await attachedFile();
443
+ const pending = await pendingFile();
444
+ const otherEntity = await reservedFile({ entityId: newId() });
445
+ const otherType = await reservedFile({ entityType: EntityType.EVENT_COVER });
446
+ const result = await repository.releaseForEntity({ entityType: EntityType.POST, entityId });
447
+ expect(result.tombstoneCreated).toBe(true);
448
+ expect(result.releasedFileIds.sort()).toEqual([reserved.id, attached.id].sort());
449
+ expect((await readFile(reserved.id)).status).toBe(FileStatus.ORPHANED);
450
+ expect((await readFile(attached.id)).status).toBe(FileStatus.ORPHANED);
451
+ expect((await readFile(pending.id)).status).toBe(FileStatus.PENDING);
452
+ expect((await readFile(otherEntity.id)).status).toBe(FileStatus.RESERVED);
453
+ expect((await readFile(otherType.id)).status).toBe(FileStatus.RESERVED);
454
+ const tombstones = await readTombstones();
455
+ expect(tombstones).toHaveLength(1);
456
+ expect(tombstones[0]?.entityType).toBe(EntityType.POST);
457
+ expect(tombstones[0]?.entityId).toBe(entityId);
458
+ expect(await readEvents()).toHaveLength(0);
459
+ });
460
+ it('writes the tombstone even when the entity has no file yet', async () => {
461
+ const result = await repository.releaseForEntity({
462
+ entityType: EntityType.EVENT_COVER,
463
+ entityId,
464
+ });
465
+ expect(result).toEqual({ releasedFileIds: [], tombstoneCreated: true });
466
+ expect(await readTombstones()).toHaveLength(1);
467
+ });
468
+ it('is idempotent: a replay releases nothing and keeps the first release date', async () => {
469
+ await reservedFile();
470
+ await repository.releaseForEntity({ entityType: EntityType.POST, entityId });
471
+ const [first] = await readTombstones();
472
+ const replay = await repository.releaseForEntity({ entityType: EntityType.POST, entityId });
473
+ expect(replay).toEqual({ releasedFileIds: [], tombstoneCreated: false });
474
+ const tombstones = await readTombstones();
475
+ expect(tombstones).toHaveLength(1);
476
+ expect(tombstones[0]?.releasedAt).toEqual(first.releasedAt);
477
+ });
478
+ it('never touches ORPHANED or DELETED files', async () => {
479
+ const orphaned = await reservedFile({ status: FileStatus.ORPHANED });
480
+ const deleted = await reservedFile({ status: FileStatus.DELETED });
481
+ const before = [await readFile(orphaned.id), await readFile(deleted.id)];
482
+ await repository.releaseForEntity({ entityType: EntityType.POST, entityId });
483
+ expect([await readFile(orphaned.id), await readFile(deleted.id)]).toEqual(before);
484
+ });
485
+ it('refuses an unknown entity type and a blank entity id before any write', async () => {
486
+ await expect(repository.releaseForEntity({ entityType: 'TOSTRING', entityId })).rejects.toThrow(InvalidEntityTypeException);
487
+ await expect(repository.releaseForEntity({ entityType: EntityType.POST, entityId: ' ' })).rejects.toThrow('Released entity id must not be empty');
488
+ expect(await readTombstones()).toHaveLength(0);
489
+ });
490
+ it('rolls the tombstone back when a file update fails', async () => {
491
+ const failing = await reservedFile();
492
+ restore = await mockFileUpdateFailure(failing.id);
493
+ await expect(repository.releaseForEntity({ entityType: EntityType.POST, entityId })).rejects.toThrow('simulated file update failure');
494
+ expect(await readTombstones()).toHaveLength(0);
495
+ expect((await readFile(failing.id)).status).toBe(FileStatus.RESERVED);
496
+ });
497
+ });
498
+ describe('releaseFile()', () => {
499
+ const release = (fileId, overrides = {}) => repository.releaseFile({
500
+ fileId,
501
+ entityType: EntityType.POST,
502
+ entityId,
503
+ ownerId,
504
+ ...overrides,
505
+ });
506
+ it.each([FileStatus.RESERVED, FileStatus.ATTACHED])('orphans a %s file of the entity', async (status) => {
507
+ const file = await reservedFile({ status });
508
+ expect(await release(file.id)).toBe(true);
509
+ expect((await readFile(file.id)).status).toBe(FileStatus.ORPHANED);
510
+ expect(await readEvents()).toHaveLength(0);
511
+ });
512
+ it('orphans a PENDING file of the owner and records the entity, so a late event is recognised', async () => {
513
+ const file = await pendingFile();
514
+ expect(await release(file.id)).toBe(true);
515
+ const row = await readFile(file.id);
516
+ expect(row.status).toBe(FileStatus.ORPHANED);
517
+ expect(row.entityId).toBe(entityId);
518
+ const [late] = await repository.attachFromConfirmationEvent(confirmation([file.id]));
519
+ expect(late.outcome).toBe(FileAttachmentOutcome.ALREADY_RELEASED);
520
+ expect(await readEvents()).toHaveLength(0);
521
+ });
522
+ it('refuses a PENDING file of another owner, and a PENDING file without owner', async () => {
523
+ const file = await pendingFile();
524
+ expect(await release(file.id, { ownerId: newId() })).toBe(false);
525
+ expect(await release(file.id, { ownerId: undefined })).toBe(false);
526
+ expect((await readFile(file.id)).status).toBe(FileStatus.PENDING);
527
+ });
528
+ it('refuses a file held by another entity or declared for another type', async () => {
529
+ const elsewhere = await attachedFile({ entityId: newId() });
530
+ const otherType = await attachedFile({ entityType: EntityType.EVENT_COVER });
531
+ expect(await release(elsewhere.id)).toBe(false);
532
+ expect(await release(otherType.id)).toBe(false);
533
+ expect((await readFile(elsewhere.id)).status).toBe(FileStatus.ATTACHED);
534
+ expect((await readFile(otherType.id)).status).toBe(FileStatus.ATTACHED);
535
+ });
536
+ it('releases a held file without owner (event with no owner, badge icon) unless the owner differs', async () => {
537
+ const file = await attachedFile();
538
+ const another = await attachedFile();
539
+ expect(await release(another.id, { ownerId: newId() })).toBe(false);
540
+ expect(await release(file.id, { ownerId: undefined })).toBe(true);
541
+ expect((await readFile(file.id)).status).toBe(FileStatus.ORPHANED);
542
+ expect((await readFile(another.id)).status).toBe(FileStatus.ATTACHED);
543
+ });
544
+ it('is absorbing and idempotent: ORPHANED and DELETED match nothing', async () => {
545
+ const file = await attachedFile();
546
+ const deleted = await reservedFile({ status: FileStatus.DELETED });
547
+ expect(await release(file.id)).toBe(true);
548
+ const before = await readFile(file.id);
549
+ expect(await release(file.id)).toBe(false);
550
+ expect(await release(deleted.id)).toBe(false);
551
+ expect(await readFile(file.id)).toEqual(before);
552
+ expect((await readFile(deleted.id)).status).toBe(FileStatus.DELETED);
553
+ });
554
+ it('does nothing when the file is its own replacement', async () => {
555
+ const file = await attachedFile();
556
+ expect(await release(file.id, { newFileId: file.id.toUpperCase() })).toBe(false);
557
+ expect((await readFile(file.id)).status).toBe(FileStatus.ATTACHED);
558
+ });
559
+ it('returns false for an unknown file', async () => {
560
+ expect(await release(newId())).toBe(false);
561
+ });
562
+ it('rolls back nothing visible when the UPDATE fails', async () => {
563
+ const file = await attachedFile();
564
+ restore = await mockFileUpdateFailure(file.id);
565
+ await expect(release(file.id)).rejects.toThrow('simulated file update failure');
566
+ expect((await readFile(file.id)).status).toBe(FileStatus.ATTACHED);
567
+ });
568
+ it('an out-of-order replacement: the old file is released before the entity event', async () => {
569
+ const oldFile = await pendingFile();
570
+ const newFile = await pendingFile();
571
+ const input = confirmation([newFile.id], { entityType: EntityType.POST });
572
+ await repository.attachFromConfirmationEvent(input);
573
+ await release(oldFile.id, { newFileId: newFile.id });
574
+ const [late] = await repository.attachFromConfirmationEvent(confirmation([oldFile.id]));
575
+ expect(late.outcome).toBe(FileAttachmentOutcome.ALREADY_RELEASED);
576
+ expect((await readFile(oldFile.id)).status).toBe(FileStatus.ORPHANED);
577
+ expect((await readFile(newFile.id)).status).toBe(FileStatus.ATTACHED);
578
+ });
579
+ });
580
+ describe('releaseForOwner()', () => {
581
+ it('orphans every unfinished file of the owner but the platform resources', async () => {
582
+ const mine = await Promise.all([
583
+ pendingFile(),
584
+ reservedFile(),
585
+ attachedFile(),
586
+ pendingFile({ entityType: EntityType.USER_AVATAR }),
587
+ pendingFile({ entityType: EntityType.EVENT_COVER }),
588
+ ]);
589
+ const badge = await pendingFile({ entityType: EntityType.BADGE_ICON });
590
+ const foreign = await pendingFile({ ownerId: newId() });
591
+ const orphaned = await pendingFile({ status: FileStatus.ORPHANED });
592
+ const deleted = await pendingFile({ status: FileStatus.DELETED });
593
+ const frozen = [await readFile(orphaned.id), await readFile(deleted.id)];
594
+ const released = await repository.releaseForOwner({ ownerId });
595
+ expect(released.sort()).toEqual(mine.map((file) => file.id).sort());
596
+ for (const file of mine) {
597
+ expect((await readFile(file.id)).status).toBe(FileStatus.ORPHANED);
598
+ }
599
+ expect((await readFile(badge.id)).status).toBe(FileStatus.PENDING);
600
+ expect((await readFile(foreign.id)).status).toBe(FileStatus.PENDING);
601
+ expect([await readFile(orphaned.id), await readFile(deleted.id)]).toEqual(frozen);
602
+ expect(await readEvents()).toHaveLength(0);
603
+ expect(await readTombstones()).toHaveLength(0);
604
+ });
605
+ it('excludes exactly the badge icons', () => {
606
+ expect(OWNER_RELEASE_EXCLUDED_ENTITY_TYPES).toEqual([EntityType.BADGE_ICON]);
607
+ });
608
+ it('is idempotent: a replay releases nothing', async () => {
609
+ await pendingFile();
610
+ expect(await repository.releaseForOwner({ ownerId })).toHaveLength(1);
611
+ expect(await repository.releaseForOwner({ ownerId })).toEqual([]);
612
+ });
613
+ it('a late confirmation event for a released file is refused, not attached', async () => {
614
+ const file = await pendingFile();
615
+ await repository.releaseForOwner({ ownerId });
616
+ const [late] = await repository.attachFromConfirmationEvent(confirmation([file.id]));
617
+ expect(late.outcome).toBe(FileAttachmentOutcome.REJECTED);
618
+ expect(late.rejectionReason).toBe(StorageAttachmentRejectionReason.NOT_FOUND);
619
+ expect((await readFile(file.id)).status).toBe(FileStatus.ORPHANED);
620
+ });
621
+ });
622
+ });
623
+ //# sourceMappingURL=postgres-file.repository.attach-release.int.spec.js.map