@contember/engine-s3-plugin 2.2.0-alpha.2 → 2.2.0-alpha.5

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 (68) hide show
  1. package/dist/development/src/Config.cjs.map +1 -1
  2. package/dist/development/src/Config.js.map +1 -1
  3. package/dist/development/src/S3ObjectAuthorizator.cjs +12 -1
  4. package/dist/development/src/S3ObjectAuthorizator.cjs.map +1 -1
  5. package/dist/development/src/S3ObjectAuthorizator.js +12 -1
  6. package/dist/development/src/S3ObjectAuthorizator.js.map +1 -1
  7. package/dist/development/src/S3SchemaContributor.cjs +27 -3
  8. package/dist/development/src/S3SchemaContributor.cjs.map +1 -1
  9. package/dist/development/src/S3SchemaContributor.js +28 -4
  10. package/dist/development/src/S3SchemaContributor.js.map +1 -1
  11. package/dist/development/src/S3SchemaTypes.cjs +8 -0
  12. package/dist/development/src/S3SchemaTypes.cjs.map +1 -1
  13. package/dist/development/src/S3SchemaTypes.js +8 -0
  14. package/dist/development/src/S3SchemaTypes.js.map +1 -1
  15. package/dist/development/src/S3Service.cjs +44 -14
  16. package/dist/development/src/S3Service.cjs.map +1 -1
  17. package/dist/development/src/S3Service.js +44 -14
  18. package/dist/development/src/S3Service.js.map +1 -1
  19. package/dist/development/src/S3Signer.cjs +6 -1
  20. package/dist/development/src/S3Signer.cjs.map +1 -1
  21. package/dist/development/src/S3Signer.js +6 -1
  22. package/dist/development/src/S3Signer.js.map +1 -1
  23. package/dist/production/src/Config.cjs.map +1 -1
  24. package/dist/production/src/Config.js.map +1 -1
  25. package/dist/production/src/S3ObjectAuthorizator.cjs +12 -1
  26. package/dist/production/src/S3ObjectAuthorizator.cjs.map +1 -1
  27. package/dist/production/src/S3ObjectAuthorizator.js +12 -1
  28. package/dist/production/src/S3ObjectAuthorizator.js.map +1 -1
  29. package/dist/production/src/S3SchemaContributor.cjs +27 -3
  30. package/dist/production/src/S3SchemaContributor.cjs.map +1 -1
  31. package/dist/production/src/S3SchemaContributor.js +28 -4
  32. package/dist/production/src/S3SchemaContributor.js.map +1 -1
  33. package/dist/production/src/S3SchemaTypes.cjs +8 -0
  34. package/dist/production/src/S3SchemaTypes.cjs.map +1 -1
  35. package/dist/production/src/S3SchemaTypes.js +8 -0
  36. package/dist/production/src/S3SchemaTypes.js.map +1 -1
  37. package/dist/production/src/S3Service.cjs +44 -14
  38. package/dist/production/src/S3Service.cjs.map +1 -1
  39. package/dist/production/src/S3Service.js +44 -14
  40. package/dist/production/src/S3Service.js.map +1 -1
  41. package/dist/production/src/S3Signer.cjs +6 -1
  42. package/dist/production/src/S3Signer.cjs.map +1 -1
  43. package/dist/production/src/S3Signer.js +6 -1
  44. package/dist/production/src/S3Signer.js.map +1 -1
  45. package/dist/tests/tsconfig.tsbuildinfo +1 -1
  46. package/dist/types/Config.d.ts.map +1 -1
  47. package/dist/types/S3ConfigProcessor.d.ts.map +1 -1
  48. package/dist/types/S3ObjectAuthorizator.d.ts +9 -1
  49. package/dist/types/S3ObjectAuthorizator.d.ts.map +1 -1
  50. package/dist/types/S3SchemaContributor.d.ts +2 -0
  51. package/dist/types/S3SchemaContributor.d.ts.map +1 -1
  52. package/dist/types/S3SchemaTypes.d.ts +1 -0
  53. package/dist/types/S3SchemaTypes.d.ts.map +1 -1
  54. package/dist/types/S3Service.d.ts +22 -1
  55. package/dist/types/S3Service.d.ts.map +1 -1
  56. package/dist/types/S3Signer.d.ts +1 -1
  57. package/dist/types/S3Signer.d.ts.map +1 -1
  58. package/dist/types/index.d.ts.map +1 -1
  59. package/dist/types/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +4 -4
  61. package/src/Config.ts +1 -0
  62. package/src/S3ObjectAuthorizator.ts +20 -0
  63. package/src/S3SchemaContributor.ts +29 -3
  64. package/src/S3SchemaTypes.ts +8 -0
  65. package/src/S3Service.ts +64 -14
  66. package/src/S3Signer.ts +8 -2
  67. package/tests/cases/authorizator.test.ts +11 -0
  68. package/tests/cases/signRequest.test.ts +81 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contember/engine-s3-plugin",
3
- "version": "2.2.0-alpha.2",
3
+ "version": "2.2.0-alpha.5",
4
4
  "license": "Apache-2.0",
5
5
  "main": "./dist/production/index.js",
6
6
  "typings": "./dist/types/index.d.ts",
@@ -23,9 +23,9 @@
23
23
  }
24
24
  },
25
25
  "dependencies": {
26
- "@contember/engine-plugins": "2.1.0-beta.2",
27
- "@contember/graphql-utils": "2.1.0-beta.2",
28
- "@contember/typesafe": "2.1.0-beta.2",
26
+ "@contember/engine-plugins": "2.2.0-alpha.5",
27
+ "@contember/graphql-utils": "2.2.0-alpha.5",
28
+ "@contember/typesafe": "2.2.0-alpha.5",
29
29
  "mime-types": "^3.0.2",
30
30
  "picomatch": "^4.0.3",
31
31
  "qs": "^6.15.0",
package/src/Config.ts CHANGED
@@ -2,6 +2,7 @@ import * as Typesafe from '@contember/typesafe'
2
2
 
3
3
  export enum S3Providers {
4
4
  aws = 'aws',
5
+ // Path-style addressing with no object ACL — the right value for any MinIO-compatible server, SeaweedFS included.
5
6
  minio = 'minio',
6
7
  ceph = 'ceph',
7
8
  localstack = 'localstack',
@@ -13,16 +13,24 @@ export type S3ObjectReadRule = {
13
13
  matcher?: (key: string) => boolean
14
14
  }
15
15
 
16
+ export type S3ObjectDeleteRule = {
17
+ pattern: string
18
+ matcher?: (key: string) => boolean
19
+ }
20
+
16
21
  export class S3ObjectAuthorizator {
17
22
  private readonly uploadRules: S3ObjectUploadRule[]
18
23
  private readonly readRules: S3ObjectReadRule[]
24
+ private readonly deleteRules: S3ObjectDeleteRule[]
19
25
 
20
26
  constructor(
21
27
  uploadRules: Omit<S3ObjectUploadRule, 'matcher'>[],
22
28
  readRules: Omit<S3ObjectReadRule, 'matcher'>[],
29
+ deleteRules: Omit<S3ObjectDeleteRule, 'matcher'>[] = [],
23
30
  ) {
24
31
  this.uploadRules = uploadRules
25
32
  this.readRules = readRules
33
+ this.deleteRules = deleteRules
26
34
  }
27
35
 
28
36
  public verifyReadAccess({ key }: { key: string }): void {
@@ -37,6 +45,18 @@ export class S3ObjectAuthorizator {
37
45
  throw new ForbiddenError(`Read access forbidden for object key ${key}`)
38
46
  }
39
47
 
48
+ public verifyDeleteAccess({ key }: { key: string }): void {
49
+ for (const rule of this.deleteRules) {
50
+ rule.matcher ??= pm(rule.pattern)
51
+
52
+ if (rule.matcher(key)) {
53
+ return
54
+ }
55
+ }
56
+
57
+ throw new ForbiddenError(`Delete access forbidden for object key ${key}`)
58
+ }
59
+
40
60
  public verifyUploadAccess({ key, size }: { key: string; size: number | null }): void {
41
61
  const matchedPatterns: S3ObjectUploadRule[] = []
42
62
 
@@ -12,7 +12,7 @@ import { S3Service, S3ServiceFactory } from './S3Service.js'
12
12
  import { Project3Config, resolveS3Config, S3Config } from './Config.js'
13
13
  import { GraphQLSchemaContributor, GraphQLSchemaContributorContext, Providers } from '@contember/engine-plugins'
14
14
  import * as types from './S3SchemaTypes.js'
15
- import { S3Acl, S3GenerateSignedUploadInput, S3SignedRead, S3SignedUpload } from './S3SchemaTypes.js'
15
+ import { S3Acl, S3DeletedObject, S3GenerateSignedUploadInput, S3SignedRead, S3SignedUpload } from './S3SchemaTypes.js'
16
16
  import { S3ObjectAuthorizator } from './S3ObjectAuthorizator.js'
17
17
 
18
18
  interface Identity {
@@ -30,6 +30,7 @@ export type S3SchemaAcl = Record<
30
30
  {
31
31
  read?: boolean
32
32
  upload?: S3SchemaUploadAcl
33
+ delete?: boolean
33
34
  }
34
35
  >
35
36
 
@@ -56,17 +57,23 @@ export class S3SchemaContributor implements GraphQLSchemaContributor {
56
57
  const readRules = rules.filter(([, it]) => it.read).map(([it]) => ({
57
58
  pattern: it,
58
59
  }))
60
+ const deleteRules = rules.filter(([, it]) => it.delete).map(([it]) => ({
61
+ pattern: it,
62
+ }))
59
63
 
60
- if (uploadRules.length === 0 && readRules.length === 0) {
64
+ if (uploadRules.length === 0 && readRules.length === 0 && deleteRules.length === 0) {
61
65
  return undefined
62
66
  }
63
67
 
64
- const authorizator = new S3ObjectAuthorizator(uploadRules, readRules)
68
+ const authorizator = new S3ObjectAuthorizator(uploadRules, readRules, deleteRules)
65
69
  const uploadMutation = this.createUploadMutation(authorizator)
66
70
  const readMutation = this.createReadMutation(authorizator)
71
+ const deleteMutation = this.createDeleteMutation(authorizator)
67
72
  const mutation = {
68
73
  generateUploadUrl: uploadMutation as GraphQLFieldConfig<any, any, any>,
69
74
  generateReadUrl: readMutation,
75
+ // not "deleteObject" — a content mutation for an entity named "Object" would shadow it
76
+ deleteS3Object: deleteMutation,
70
77
  }
71
78
  return {
72
79
  mutation: new GraphQLObjectType({
@@ -106,6 +113,25 @@ export class S3SchemaContributor implements GraphQLSchemaContributor {
106
113
  } as GraphQLFieldConfig<any, any, any>
107
114
  }
108
115
 
116
+ private createDeleteMutation(authorizator: S3ObjectAuthorizator): GraphQLFieldConfig<any, any, any> {
117
+ return {
118
+ type: new GraphQLNonNull(S3DeletedObject),
119
+ args: {
120
+ objectKey: {
121
+ type: new GraphQLNonNull(GraphQLString),
122
+ },
123
+ },
124
+ resolve: async (parent: any, args: { objectKey: string }, ctx: { project: Project3Config }) => {
125
+ if (!ctx.project.s3) {
126
+ throw new GraphQLError('S3 is not configured for this project')
127
+ }
128
+ const s3Config = resolveS3Config(ctx.project.s3)
129
+ const s3 = this.s3Factory.create(s3Config, this.providers, authorizator)
130
+ return await s3.deleteObject({ objectKey: args.objectKey })
131
+ },
132
+ }
133
+ }
134
+
109
135
  private createUploadMutation(authorizator: S3ObjectAuthorizator): GraphQLFieldConfig<any, any, any> {
110
136
  return {
111
137
  type: new GraphQLNonNull(S3SignedUpload),
@@ -44,6 +44,14 @@ export const S3SignedUpload = new GraphQLObjectType({
44
44
  },
45
45
  })
46
46
 
47
+ export const S3DeletedObject = new GraphQLObjectType({
48
+ name: 'S3DeletedObject',
49
+ fields: {
50
+ objectKey: { type: new GraphQLNonNull(GraphQLString) },
51
+ bucket: { type: new GraphQLNonNull(GraphQLString) },
52
+ },
53
+ })
54
+
47
55
  export const S3Acl = new GraphQLEnumType({
48
56
  name: 'S3Acl',
49
57
  values: {
package/src/S3Service.ts CHANGED
@@ -23,6 +23,22 @@ type SignedUploadUrl = {
23
23
  method: string
24
24
  }
25
25
 
26
+ type DeletedObject = {
27
+ bucket: string
28
+ objectKey: string
29
+ }
30
+
31
+ export type S3FetchResponse = {
32
+ readonly ok: boolean
33
+ readonly status: number
34
+ text(): Promise<string>
35
+ }
36
+
37
+ export type S3Fetch = (url: string, init: { method: string; signal: AbortSignal }) => Promise<S3FetchResponse>
38
+
39
+ const deleteUrlExpiration = 60
40
+ const deleteTimeoutMs = 10_000
41
+
26
42
  export class S3Service {
27
43
  private readonly publicBaseUrl: string
28
44
 
@@ -32,6 +48,7 @@ export class S3Service {
32
48
  public readonly config: S3Config,
33
49
  private readonly providers: Pick<Providers, 'uuid' | 'now'>,
34
50
  private readonly authorizator: S3ObjectAuthorizator,
51
+ private readonly fetch: S3Fetch = (url, init) => globalThis.fetch(url, init),
35
52
  ) {
36
53
  this.publicBaseUrl = resolveS3PublicBaseUrl(config)
37
54
  this.signer = new S3Signer(config, providers)
@@ -97,38 +114,71 @@ export class S3Service {
97
114
 
98
115
  public getSignedReadUrl({ objectKey, expiration }: { objectKey: string; expiration: number | null }): SignedReadUrl {
99
116
  const bucket = this.config.bucket
100
-
101
- const publicPrefix = this.formatPublicUrl('')
102
- if (objectKey.startsWith(publicPrefix)) {
103
- objectKey = objectKey.substring(publicPrefix.length)
104
- }
105
- if (this.config.prefix && !objectKey.startsWith(this.config.prefix)) {
106
- throw new ForbiddenError(
107
- `Given object key "${objectKey}" does not start with a project prefix "${this.config.prefix}"`,
108
- )
109
- }
110
- const localObjectKey = this.config.prefix ? objectKey.substring(this.config.prefix.length + 1) : objectKey
111
- this.authorizator.verifyReadAccess({ key: localObjectKey })
117
+ const resolved = this.resolveObjectKey(objectKey)
118
+ this.authorizator.verifyReadAccess({ key: resolved.localObjectKey })
112
119
 
113
120
  const url = this.signer.sign({
114
121
  action: 'read',
115
122
  expiration: expiration ?? 3600,
116
- key: objectKey,
123
+ key: resolved.objectKey,
117
124
  headers: {},
118
125
  })
119
126
 
120
127
  return {
121
128
  bucket,
122
- objectKey,
129
+ objectKey: resolved.objectKey,
123
130
  url,
124
131
  headers: [],
125
132
  method: 'GET',
126
133
  }
127
134
  }
128
135
 
136
+ /**
137
+ * Unlike an upload or a read, a delete carries no payload, so the signed URL never leaves the server.
138
+ */
139
+ public async deleteObject({ objectKey }: { objectKey: string }): Promise<DeletedObject> {
140
+ const bucket = this.config.bucket
141
+ const resolved = this.resolveObjectKey(objectKey)
142
+ this.authorizator.verifyDeleteAccess({ key: resolved.localObjectKey })
143
+
144
+ const url = this.signer.sign({
145
+ action: 'delete',
146
+ expiration: deleteUrlExpiration,
147
+ key: resolved.objectKey,
148
+ headers: {},
149
+ })
150
+
151
+ const response = await this.fetch(url, { method: 'DELETE', signal: AbortSignal.timeout(deleteTimeoutMs) })
152
+ if (!response.ok) {
153
+ throw new Error(`Failed to delete an object "${resolved.objectKey}": S3 responded with ${response.status}: ${await response.text()}`)
154
+ }
155
+
156
+ return {
157
+ bucket,
158
+ objectKey: resolved.objectKey,
159
+ }
160
+ }
161
+
129
162
  public formatPublicUrl(key: string): string {
130
163
  return `${this.publicBaseUrl}/${key}`
131
164
  }
165
+
166
+ private resolveObjectKey(objectKey: string): { objectKey: string; localObjectKey: string } {
167
+ const publicPrefix = this.formatPublicUrl('')
168
+ if (objectKey.startsWith(publicPrefix)) {
169
+ objectKey = objectKey.substring(publicPrefix.length)
170
+ }
171
+ // the separator is a part of the prefix: without it, a project prefixed "blog" reaches into "blog-staging" in a shared bucket
172
+ const prefix = this.config.prefix ? this.config.prefix + '/' : ''
173
+ if (prefix && !objectKey.startsWith(prefix)) {
174
+ throw new ForbiddenError(
175
+ `Given object key "${objectKey}" does not start with a project prefix "${this.config.prefix}"`,
176
+ )
177
+ }
178
+ const localObjectKey = objectKey.substring(prefix.length)
179
+
180
+ return { objectKey, localObjectKey }
181
+ }
132
182
  }
133
183
 
134
184
  export class S3ServiceFactory {
package/src/S3Signer.ts CHANGED
@@ -4,12 +4,18 @@ import qs from 'qs'
4
4
  import { BinaryLike, createHash, createHmac } from 'node:crypto'
5
5
 
6
6
  interface S3Request {
7
- action: 'read' | 'upload'
7
+ action: 'read' | 'upload' | 'delete'
8
8
  key: string
9
9
  expiration: number
10
10
  headers: Record<string, string>
11
11
  }
12
12
 
13
+ const methods: Record<S3Request['action'], string> = {
14
+ read: 'GET',
15
+ upload: 'PUT',
16
+ delete: 'DELETE',
17
+ }
18
+
13
19
  const hmac = (secret: BinaryLike, data: BinaryLike) => createHmac('sha256', secret).update(data).digest()
14
20
 
15
21
  const hash = (data: BinaryLike) => createHash('sha256').update(data).digest('hex')
@@ -36,7 +42,7 @@ export class S3Signer {
36
42
  const queryString = this.getQueryParams(request, nowFormatted)
37
43
  const path = uris.basePath + '/' + request.key
38
44
  const lines = [
39
- request.action === 'read' ? 'GET' : 'PUT',
45
+ methods[request.action],
40
46
  path,
41
47
  queryString,
42
48
  this.getSignedHeaders(request),
@@ -13,6 +13,17 @@ test('required upload pattern', () => {
13
13
  expect(() => authorizator.verifyUploadAccess({ key: 'lorem.jpg', size: null })).toThrow('Upload access forbidden for object key lorem.jpg')
14
14
  })
15
15
 
16
+ test('required delete pattern', () => {
17
+ const authorizator = new S3ObjectAuthorizator([], [], [{ pattern: 'foo/**' }])
18
+ expect(() => authorizator.verifyDeleteAccess({ key: 'foo/lorem.jpg' })).not.toThrow()
19
+ expect(() => authorizator.verifyDeleteAccess({ key: 'lorem.jpg' })).toThrow('Delete access forbidden for object key lorem.jpg')
20
+ })
21
+
22
+ test('delete is not implied by upload nor read', () => {
23
+ const authorizator = new S3ObjectAuthorizator([{ pattern: '**' }], [{ pattern: '**' }])
24
+ expect(() => authorizator.verifyDeleteAccess({ key: 'foo/lorem.jpg' })).toThrow('Delete access forbidden for object key foo/lorem.jpg')
25
+ })
26
+
16
27
  test('max upload size', () => {
17
28
  const authorizator = new S3ObjectAuthorizator([{ pattern: '**', maxSize: 1024 }], [])
18
29
  expect(() => authorizator.verifyUploadAccess({ key: 'lorem.jpg', size: 1000 })).not.toThrow()
@@ -1,9 +1,9 @@
1
1
  import { expect, test } from 'bun:test'
2
- import { S3ObjectAuthorizator, S3Service } from '../../src/index.js'
2
+ import { S3Fetch, S3ObjectAuthorizator, S3Service } from '../../src/index.js'
3
3
 
4
4
  const mocked = new Date('2021-07-02T17:22Z')
5
5
  const constantUuid = '9fce3907-ff2b-45bb-b4ce-eff5527dd315'
6
- const createS3Service = (bucket: string, prefix = '') =>
6
+ const createS3Service = (bucket: string, prefix = '', fetch?: S3Fetch) =>
7
7
  new S3Service(
8
8
  {
9
9
  bucket,
@@ -18,9 +18,20 @@ const createS3Service = (bucket: string, prefix = '') =>
18
18
  uuid: () => constantUuid,
19
19
  now: () => mocked,
20
20
  },
21
- new S3ObjectAuthorizator([{ pattern: '**' }], [{ pattern: '**' }]),
21
+ new S3ObjectAuthorizator([{ pattern: '**' }], [{ pattern: '**' }], [{ pattern: '**' }]),
22
+ fetch,
22
23
  )
23
24
 
25
+ const createFetchMock = (response: { ok: boolean; status: number; body?: string } = { ok: true, status: 204 }) => {
26
+ const calls: { url: string; method: string }[] = []
27
+ const fetch: S3Fetch = async (url, init) => {
28
+ calls.push({ url, method: init.method })
29
+ return { ...response, text: async () => response.body ?? '' }
30
+ }
31
+
32
+ return { calls, fetch }
33
+ }
34
+
24
35
  test('sign s3 request', () => {
25
36
  const service = createS3Service('test')
26
37
  const signed = service.getSignedReadUrl({ objectKey: 'foo.jpg', expiration: null })
@@ -37,6 +48,13 @@ test('sign s3 request - prefix', () => {
37
48
  )
38
49
  })
39
50
 
51
+ test('sign s3 request - a sibling prefix is not a prefix', () => {
52
+ const service = createS3Service('test', 'lorem')
53
+ expect(() => service.getSignedReadUrl({ objectKey: 'loremipsum/foo.jpg', expiration: null })).toThrow(
54
+ 'Given object key "loremipsum/foo.jpg" does not start with a project prefix "lorem"',
55
+ )
56
+ })
57
+
40
58
  test('sign upload', () => {
41
59
  const service = createS3Service('test')
42
60
  const signed = service.getSignedUploadUrl({
@@ -73,6 +91,66 @@ test('sign upload #2', () => {
73
91
  )
74
92
  })
75
93
 
94
+ test('delete object', async () => {
95
+ const { calls, fetch } = createFetchMock()
96
+ const service = createS3Service('test', '', fetch)
97
+ const deleted = await service.deleteObject({ objectKey: 'foo.jpg' })
98
+ expect(deleted).toEqual({ bucket: 'test', objectKey: 'foo.jpg' })
99
+ expect(calls).toEqual([{
100
+ method: 'DELETE',
101
+ url:
102
+ 'https://test.s3.eu-central-1.amazonaws.com/foo.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test%2F20210702%2Feu-central-1%2Fs3%2Faws4_request&X-Amz-Date=20210702T172200Z&X-Amz-Expires=60&X-Amz-Signature=441f2003478d8dc8761b6c4c48c6c0805a36d1f088138a5b89d6bea365922663&X-Amz-SignedHeaders=host',
103
+ }])
104
+ })
105
+
106
+ test('delete object - public url is accepted', async () => {
107
+ const { calls, fetch } = createFetchMock()
108
+ const service = createS3Service('test', 'lorem', fetch)
109
+ const deleted = await service.deleteObject({
110
+ objectKey: 'https://test.s3.eu-central-1.amazonaws.com/lorem/foo.jpg',
111
+ })
112
+ expect(deleted.objectKey).toEqual('lorem/foo.jpg')
113
+ expect(calls.length).toEqual(1)
114
+ })
115
+
116
+ test('delete object - project prefix is enforced', async () => {
117
+ const { calls, fetch } = createFetchMock()
118
+ const service = createS3Service('test', 'lorem', fetch)
119
+ await expect(service.deleteObject({ objectKey: 'ipsum/foo.jpg' })).rejects.toThrow(
120
+ 'Given object key "ipsum/foo.jpg" does not start with a project prefix "lorem"',
121
+ )
122
+ expect(calls.length).toEqual(0)
123
+ })
124
+
125
+ test('delete object - a sibling prefix is not a prefix', async () => {
126
+ const { calls, fetch } = createFetchMock()
127
+ const service = createS3Service('test', 'lorem', fetch)
128
+ await expect(service.deleteObject({ objectKey: 'loremipsum/foo.jpg' })).rejects.toThrow(
129
+ 'Given object key "loremipsum/foo.jpg" does not start with a project prefix "lorem"',
130
+ )
131
+ expect(calls.length).toEqual(0)
132
+ })
133
+
134
+ test('delete object - acl is verified', async () => {
135
+ const { calls, fetch } = createFetchMock()
136
+ const service = new S3Service(
137
+ { bucket: 'test', region: 'eu-central-1', credentials: { key: 'test', secret: 'abcd' }, prefix: '' },
138
+ { uuid: () => constantUuid, now: () => mocked },
139
+ new S3ObjectAuthorizator([{ pattern: '**' }], [{ pattern: '**' }], [{ pattern: 'foo/**' }]),
140
+ fetch,
141
+ )
142
+ await expect(service.deleteObject({ objectKey: 'bar/foo.jpg' })).rejects.toThrow('Delete access forbidden for object key bar/foo.jpg')
143
+ expect(calls.length).toEqual(0)
144
+ })
145
+
146
+ test('delete object - s3 failure', async () => {
147
+ const { fetch } = createFetchMock({ ok: false, status: 403, body: '<Error>AccessDenied</Error>' })
148
+ const service = createS3Service('test', '', fetch)
149
+ await expect(service.deleteObject({ objectKey: 'foo.jpg' })).rejects.toThrow(
150
+ 'Failed to delete an object "foo.jpg": S3 responded with 403: <Error>AccessDenied</Error>',
151
+ )
152
+ })
153
+
76
154
  test('bucket with dot', () => {
77
155
  const service = createS3Service('test.foo')
78
156
  const signed = service.getSignedReadUrl({ objectKey: 'foo.jpg', expiration: null })