@proteinjs/db-file-storage-driver-gcs 1.4.32 → 1.5.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.
@@ -1,5 +1,5 @@
1
1
  import { Storage } from '@google-cloud/storage';
2
- import { File, FileStorageDriver } from '@proteinjs/db-file';
2
+ import { File, FileStorageDriver, FileStorageError } from '@proteinjs/db-file';
3
3
  import {
4
4
  GoogleCloudStorageConfig,
5
5
  getDefaultGoogleCloudStorageConfigFactory,
@@ -9,73 +9,170 @@ import {
9
9
  * a video, short enough that a leaked URL goes stale quickly. */
10
10
  const DEFAULT_SIGNED_URL_TTL_MS = 15 * 60 * 1000;
11
11
 
12
+ /** Failures of the connection itself, as Node names them — the store was never reached. */
13
+ const NETWORK_ERROR_CODES = ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'EPIPE'];
14
+
15
+ /**
16
+ * ERRORS: every operation runs inside {@link GoogleCloudStorageDriver.guarded}, so what leaves this
17
+ * driver on a failure is a `FileStorageError` — a code, a plain message, the HTTP status — and
18
+ * never the storage client's own error. The client's error holds the request it made (`config`,
19
+ * `response.request`, `response.config`), and that request holds this driver's access token in its
20
+ * `Authorization` header: an application that logged the error it caught would write a live
21
+ * credential. A missing object is the `not-found` code on every read and overwrite.
22
+ */
12
23
  export class GoogleCloudStorageDriver implements FileStorageDriver {
13
24
  private storage: Storage;
14
25
  private bucketName: string;
26
+ private objectPrefix: string;
15
27
 
16
28
  constructor(config?: GoogleCloudStorageConfig) {
17
- const { projectId, bucketName, storageOptions } = config ? config : this.getDefaultConfig();
29
+ const { projectId, bucketName, objectPrefix, storageOptions } = config ? config : this.getDefaultConfig();
18
30
  this.storage = new Storage({ ...storageOptions, projectId });
19
31
  this.bucketName = bucketName;
32
+ this.objectPrefix = objectPrefix ?? '';
20
33
  }
21
34
 
22
35
  async createFile(file: File, fileData: string): Promise<void> {
23
- const bucket = this.storage.bucket(this.bucketName);
24
- const gcsFile = bucket.file(file.id);
36
+ const gcsFile = this.object(file.id);
25
37
 
26
38
  // The GCS object stores the file's TRUE bytes (the interface string is base64 transport).
27
39
  // Raw bytes at rest are what make signed-URL serving correct: the browser reads the object
28
40
  // directly, so base64 text at rest would corrupt every binary served that way.
29
- await gcsFile.save(Buffer.from(fileData, 'base64'), {
30
- metadata: {
31
- contentType: file.type,
41
+ await this.guarded('createFile', file.id, () =>
42
+ gcsFile.save(Buffer.from(fileData, 'base64'), {
32
43
  metadata: {
33
- fileId: file.id,
34
- fileName: file.name,
35
- fileSize: file.size.toString(),
44
+ contentType: file.type,
45
+ metadata: {
46
+ fileId: file.id,
47
+ fileName: file.name,
48
+ fileSize: file.size.toString(),
49
+ },
36
50
  },
37
- },
38
- });
51
+ })
52
+ );
39
53
  }
40
54
 
41
55
  async getFileData(fileId: string): Promise<string> {
42
- const file = this.storage.bucket(this.bucketName).file(fileId);
43
- const [fileContent] = await file.download();
56
+ const file = this.object(fileId);
57
+ const [fileContent] = await this.guarded('getFileData', fileId, () => file.download());
44
58
  return fileContent.toString('base64');
45
59
  }
46
60
 
47
61
  async updateFileData(fileId: string, data: string): Promise<void> {
48
- const file = this.storage.bucket(this.bucketName).file(fileId);
49
- await file.save(Buffer.from(data, 'base64'));
62
+ const gcsFile = this.object(fileId);
63
+ const bytes = Buffer.from(data, 'base64');
64
+
65
+ // Writing an object's bytes creates a new generation, and a new generation holds only the
66
+ // metadata sent with that write. The object is named by file id, so there is no extension to
67
+ // infer a content type from: an overwrite that sends none is served as
68
+ // `application/octet-stream`, and a browser following `GET /file/:id` downloads a picture
69
+ // instead of showing it. So the overwrite restates what the object already says about
70
+ // itself. `contentEncoding` is left out on purpose — it describes the bytes being replaced.
71
+ const [existing] = await this.guarded('updateFileData', fileId, () => gcsFile.getMetadata());
72
+ await this.guarded('updateFileData', fileId, () =>
73
+ gcsFile.save(bytes, {
74
+ metadata: {
75
+ contentType: existing.contentType,
76
+ cacheControl: existing.cacheControl,
77
+ contentDisposition: existing.contentDisposition,
78
+ contentLanguage: existing.contentLanguage,
79
+ metadata: { ...existing.metadata, fileSize: bytes.length.toString() },
80
+ },
81
+ // The metadata read and the byte write are two requests. Pinning the write to the
82
+ // generation that was read makes the pair one decision: an overwrite that raced another
83
+ // writer fails loudly instead of restating metadata that is no longer the object's.
84
+ preconditionOpts: { ifGenerationMatch: existing.generation },
85
+ })
86
+ );
50
87
  }
51
88
 
52
89
  async updateFile(file: File): Promise<void> {
53
- const gcsFile = this.storage.bucket(this.bucketName).file(file.id);
54
- await gcsFile.setMetadata({
55
- contentType: file.type,
56
- metadata: {
57
- fileId: file.id,
58
- fileName: file.name,
59
- fileSize: file.size.toString(),
60
- },
61
- });
90
+ const gcsFile = this.object(file.id);
91
+ await this.guarded('updateFile', file.id, () =>
92
+ gcsFile.setMetadata({
93
+ contentType: file.type,
94
+ metadata: {
95
+ fileId: file.id,
96
+ fileName: file.name,
97
+ fileSize: file.size.toString(),
98
+ },
99
+ })
100
+ );
62
101
  }
63
102
 
64
103
  async getSignedUrl(fileId: string, options?: { ttlMs?: number }): Promise<string> {
65
- const gcsFile = this.storage.bucket(this.bucketName).file(fileId);
66
- const [url] = await gcsFile.getSignedUrl({
67
- version: 'v4',
68
- action: 'read',
69
- expires: Date.now() + (options?.ttlMs ?? DEFAULT_SIGNED_URL_TTL_MS),
70
- });
104
+ const gcsFile = this.object(fileId);
105
+ const [url] = await this.guarded('getSignedUrl', fileId, () =>
106
+ gcsFile.getSignedUrl({
107
+ version: 'v4',
108
+ action: 'read',
109
+ expires: Date.now() + (options?.ttlMs ?? DEFAULT_SIGNED_URL_TTL_MS),
110
+ })
111
+ );
71
112
  return url;
72
113
  }
73
114
 
74
115
  async deleteFile(fileId: string): Promise<void> {
75
- const gcsFile = this.storage.bucket(this.bucketName).file(fileId);
116
+ const gcsFile = this.object(fileId);
76
117
  // ignoreNotFound implements the driver contract's idempotency: a retried row delete must not
77
118
  // wedge because a prior attempt already removed the blob. Every other failure throws loudly.
78
- await gcsFile.delete({ ignoreNotFound: true });
119
+ await this.guarded('deleteFile', fileId, () => gcsFile.delete({ ignoreNotFound: true }));
120
+ }
121
+
122
+ /**
123
+ * THE one place an object's name is decided (`<objectPrefix><file id>`): every operation
124
+ * addresses its object through here, so a configured prefix holds for all of them or none.
125
+ */
126
+ private object(fileId: string) {
127
+ return this.storage.bucket(this.bucketName).file(`${this.objectPrefix}${fileId}`);
128
+ }
129
+
130
+ /**
131
+ * THE one door a storage-client call leaves through. Whatever the client throws is replaced by
132
+ * this driver's own error; nothing of the client's error object rides on it.
133
+ */
134
+ private async guarded<T>(operation: string, fileId: string, call: () => Promise<T>): Promise<T> {
135
+ try {
136
+ return await call();
137
+ } catch (clientError: unknown) {
138
+ throw this.driverError(operation, fileId, clientError);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Reads exactly three plain facts off the client's error — an HTTP status, a connection error
144
+ * code, its message TEXT — and builds the driver's error from them. The client's error itself,
145
+ * its `config`, `request`, `response` and `errors` are never copied, wrapped or kept as a cause.
146
+ */
147
+ private driverError(operation: string, fileId: string, clientError: unknown): FileStorageError {
148
+ const status = this.httpStatus(clientError);
149
+ const connectionFailed = NETWORK_ERROR_CODES.includes((clientError as { code?: unknown } | null)?.code as string);
150
+ const message = (clientError as { message?: unknown } | null)?.message;
151
+ return new FileStorageError(
152
+ connectionFailed ? 'unavailable' : FileStorageError.codeForStatus(status),
153
+ `${operation} failed for file ${fileId}`,
154
+ { status, detail: typeof message === 'string' ? message : undefined }
155
+ );
156
+ }
157
+
158
+ /** The client reports the HTTP status as its error's `code`; an upload's error carries it as `status` or on its `response`. */
159
+ private httpStatus(clientError: unknown): number | undefined {
160
+ const candidate = clientError as
161
+ | { code?: unknown; status?: unknown; response?: { status?: unknown; statusCode?: unknown } }
162
+ | null
163
+ | undefined;
164
+ for (const value of [
165
+ candidate?.code,
166
+ candidate?.status,
167
+ candidate?.response?.status,
168
+ candidate?.response?.statusCode,
169
+ ]) {
170
+ const status = typeof value === 'string' && /^\d{3}$/.test(value) ? Number(value) : value;
171
+ if (typeof status === 'number' && status >= 100 && status <= 599) {
172
+ return status;
173
+ }
174
+ }
175
+ return undefined;
79
176
  }
80
177
 
81
178
  private getDefaultConfig(): GoogleCloudStorageConfig {
@@ -2,7 +2,8 @@ import { GoogleCloudStorageDriver } from '../src/GoogleCloudStorageDriver';
2
2
 
3
3
  const saveMock = jest.fn();
4
4
  const downloadMock = jest.fn();
5
- const fileMock = jest.fn(() => ({ save: saveMock, download: downloadMock }));
5
+ const getMetadataMock = jest.fn();
6
+ const fileMock = jest.fn(() => ({ save: saveMock, download: downloadMock, getMetadata: getMetadataMock }));
6
7
  const bucketMock = jest.fn(() => ({ file: fileMock }));
7
8
 
8
9
  jest.mock('@google-cloud/storage', () => ({
@@ -25,6 +26,7 @@ describe('GoogleCloudStorageDriver byte encoding', () => {
25
26
  beforeEach(() => {
26
27
  saveMock.mockReset();
27
28
  downloadMock.mockReset();
29
+ getMetadataMock.mockReset();
28
30
  fileMock.mockClear();
29
31
  bucketMock.mockClear();
30
32
  });
@@ -55,6 +57,7 @@ describe('GoogleCloudStorageDriver byte encoding', () => {
55
57
 
56
58
  it('updateFileData stores the decoded true bytes', async () => {
57
59
  saveMock.mockResolvedValue(undefined);
60
+ getMetadataMock.mockResolvedValue([{ contentType: 'video/mp4', generation: '1' }, {}]);
58
61
 
59
62
  await driver.updateFileData('file-1', base64);
60
63
 
@@ -0,0 +1,97 @@
1
+ import { SourceRepository } from '@proteinjs/reflection';
2
+ import { GoogleCloudStorageDriver } from '../src/GoogleCloudStorageDriver';
3
+ import type {
4
+ DefaultGoogleCloudStorageConfigFactory,
5
+ GoogleCloudStorageConfig,
6
+ } from '../src/DefaultGoogleCloudStorageConfigFactory';
7
+
8
+ /** An in-memory bucket keyed by OBJECT NAME: whatever name the driver decides is the key here. */
9
+ const objects = new Map<string, Buffer>();
10
+ const buckets: string[] = [];
11
+
12
+ const fileMock = jest.fn((name: string) => ({
13
+ save: async (bytes: Buffer) => {
14
+ objects.set(name, bytes);
15
+ },
16
+ download: async () => {
17
+ const existing = objects.get(name);
18
+ if (!existing) {
19
+ throw Object.assign(new Error(`No such object: ${name}`), { code: 404 });
20
+ }
21
+ return [existing];
22
+ },
23
+ getSignedUrl: async () => [`https://storage.example/${name}?signed`],
24
+ delete: async () => {
25
+ objects.delete(name);
26
+ },
27
+ }));
28
+
29
+ jest.mock('@google-cloud/storage', () => ({
30
+ Storage: jest.fn(() => ({
31
+ bucket: (name: string) => {
32
+ buckets.push(name);
33
+ return { file: fileMock };
34
+ },
35
+ })),
36
+ }));
37
+
38
+ const FACTORY_TYPE = '@proteinjs/db-file-storage-driver-gcs/DefaultGoogleCloudStorageConfigFactory';
39
+ type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
40
+
41
+ /**
42
+ * An application rarely hands the driver a config: it implements
43
+ * `DefaultGoogleCloudStorageConfigFactory` and the framework builds `new GoogleCloudStorageDriver()`
44
+ * with NO argument. That is the path most deployments run, so it is the path the prefix has to hold
45
+ * on: a prefix honoured only for an explicit config would leave those deployments writing bare ids
46
+ * while believing their files are grouped.
47
+ */
48
+ describe('GoogleCloudStorageDriver built from the default config factory (no constructor argument)', () => {
49
+ const bytes = Buffer.from('the bytes of a picture');
50
+ const picture = (id: string) => ({ id, name: 'holiday.jpg', type: 'image/jpeg', size: bytes.length }) as never;
51
+ const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
52
+ /** The implementation an application would ship, seeded where the framework looks it up. */
53
+ const implementFactory = (config: GoogleCloudStorageConfig) => {
54
+ const factory: DefaultGoogleCloudStorageConfigFactory = { getConfig: () => config } as never;
55
+ objectCache()[FACTORY_TYPE] = [factory];
56
+ };
57
+
58
+ beforeEach(() => {
59
+ objects.clear();
60
+ buckets.length = 0;
61
+ });
62
+
63
+ afterEach(() => {
64
+ delete objectCache()[FACTORY_TYPE];
65
+ });
66
+
67
+ it('honours the factory’s objectPrefix on every operation: write, read, sign, delete', async () => {
68
+ implementFactory({ projectId: 'test-project', bucketName: 'test-bucket', objectPrefix: 'deployment-a/' });
69
+ const driver = new GoogleCloudStorageDriver();
70
+
71
+ await driver.createFile(picture('file-1'), bytes.toString('base64'));
72
+ expect(Array.from(objects.keys())).toEqual(['deployment-a/file-1']);
73
+ expect(buckets).toEqual(['test-bucket']);
74
+
75
+ expect(await driver.getFileData('file-1')).toBe(bytes.toString('base64'));
76
+ expect(await driver.getSignedUrl('file-1')).toBe('https://storage.example/deployment-a/file-1?signed');
77
+
78
+ await driver.deleteFile('file-1');
79
+ expect(Array.from(objects.keys())).toEqual([]);
80
+ });
81
+
82
+ it('a default-config driver and an explicit-config driver with the same prefix reach the same object', async () => {
83
+ const config = { projectId: 'test-project', bucketName: 'test-bucket', objectPrefix: 'deployment-a/' };
84
+ implementFactory(config);
85
+ await new GoogleCloudStorageDriver().createFile(picture('file-1'), bytes.toString('base64'));
86
+
87
+ expect(await new GoogleCloudStorageDriver(config).getFileData('file-1')).toBe(bytes.toString('base64'));
88
+ });
89
+
90
+ it('a factory that names no prefix keeps objects at the file id alone', async () => {
91
+ implementFactory({ projectId: 'test-project', bucketName: 'test-bucket' });
92
+
93
+ await new GoogleCloudStorageDriver().createFile(picture('file-1'), bytes.toString('base64'));
94
+
95
+ expect(Array.from(objects.keys())).toEqual(['file-1']);
96
+ });
97
+ });
@@ -0,0 +1,244 @@
1
+ import http from 'http';
2
+ import util from 'util';
3
+ import { AddressInfo } from 'net';
4
+ import { OAuth2Client } from 'google-auth-library';
5
+ import { FileStorageError } from '@proteinjs/db-file';
6
+ import { GoogleCloudStorageDriver } from '../src/GoogleCloudStorageDriver';
7
+
8
+ /** An obviously fake access token — what the storage client sends as `Authorization: Bearer …`. */
9
+ const MARKER = 'FAKE-TOKEN-MARKER';
10
+
11
+ type DriverInternals = { storage: unknown };
12
+
13
+ /** Every way an application reads an error it caught: printed, serialised, inspected to any depth. */
14
+ function everyReadingOf(error: unknown): string {
15
+ return [
16
+ String(error),
17
+ (error as Error)?.stack ?? '',
18
+ JSON.stringify(error),
19
+ JSON.stringify({ error }),
20
+ util.inspect(error),
21
+ util.inspect(error, { depth: null, showHidden: true, getters: true }),
22
+ ].join('\n');
23
+ }
24
+
25
+ /** What the operation threw — anything at all; the assertions decide what it is allowed to be. */
26
+ async function failureOf(run: () => Promise<unknown>): Promise<FileStorageError> {
27
+ return await run().then(
28
+ () => {
29
+ throw new Error('expected the operation to fail');
30
+ },
31
+ (error) => error
32
+ );
33
+ }
34
+
35
+ function expectDriverOwned(error: unknown) {
36
+ expect(everyReadingOf(error)).not.toContain(MARKER);
37
+ expect(FileStorageError.is(error)).toBe(true);
38
+ expect(
39
+ Object.getOwnPropertyNames(error)
40
+ .filter((name) => name !== 'status')
41
+ .sort()
42
+ ).toEqual(['code', 'message', 'name', 'stack']);
43
+ }
44
+
45
+ /**
46
+ * THE REAL STORAGE CLIENT against a stub server on the loopback interface (nothing leaves the
47
+ * machine), authorised by a client whose token is the fake marker. The client's own error holds
48
+ * the request it made — headers included — so the marker is inside it; an application that logs
49
+ * the error it caught with a deep inspect (a development log writer does) writes the token out.
50
+ * What leaves the driver is the driver's own error.
51
+ */
52
+ describe('GoogleCloudStorageDriver errors, through the real storage client', () => {
53
+ let server: http.Server;
54
+ let driver: GoogleCloudStorageDriver;
55
+ let answerWith = 404;
56
+ let authorizedRequests = 0;
57
+
58
+ beforeAll(async () => {
59
+ server = http.createServer((request, response) => {
60
+ if (request.headers.authorization === `Bearer ${MARKER}`) {
61
+ authorizedRequests++;
62
+ }
63
+ request.resume();
64
+ request.on('end', () => {
65
+ response.writeHead(answerWith, { 'content-type': 'application/json' });
66
+ response.end(JSON.stringify({ error: { code: answerWith, message: `the store answered ${answerWith}` } }));
67
+ });
68
+ });
69
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
70
+
71
+ const tokenHolder = new OAuth2Client();
72
+ tokenHolder.setCredentials({ access_token: MARKER, expiry_date: Date.now() + 60 * 60 * 1000 });
73
+ driver = new GoogleCloudStorageDriver({
74
+ projectId: 'test-project',
75
+ bucketName: 'test-bucket',
76
+ storageOptions: {
77
+ apiEndpoint: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
78
+ useAuthWithCustomEndpoint: true,
79
+ authClient: tokenHolder,
80
+ retryOptions: { autoRetry: false },
81
+ },
82
+ });
83
+ });
84
+
85
+ afterAll(async () => {
86
+ await new Promise((resolve) => server.close(resolve));
87
+ });
88
+
89
+ beforeEach(() => {
90
+ answerWith = 404;
91
+ authorizedRequests = 0;
92
+ });
93
+
94
+ const picture = { id: 'file-1', name: 'holiday.jpg', type: 'image/jpeg', size: 5 } as never;
95
+ const operations: [string, () => Promise<unknown>][] = [
96
+ ['getFileData', () => driver.getFileData('file-1')],
97
+ ['updateFileData', () => driver.updateFileData('file-1', Buffer.from('bytes').toString('base64'))],
98
+ ['updateFile', () => driver.updateFile(picture)],
99
+ ];
100
+
101
+ it.each(operations)('%s on a missing object: not-found, and the token is nowhere in the error', async (name, run) => {
102
+ const failure = await failureOf(run);
103
+
104
+ expect(authorizedRequests).toBeGreaterThan(0); // the token WAS on the request that failed
105
+ expectDriverOwned(failure);
106
+ expect(failure.code).toEqual('not-found');
107
+ expect(failure.status).toEqual(404);
108
+ expect(failure.message).toContain(`${name} failed for file file-1: not-found (HTTP 404)`);
109
+ });
110
+
111
+ it('createFile refused by the store (the upload path, its own client error): the driver’s error', async () => {
112
+ answerWith = 403;
113
+
114
+ const failure = await failureOf(() => driver.createFile(picture, Buffer.from('bytes').toString('base64')));
115
+
116
+ expectDriverOwned(failure);
117
+ expect(failure.code).toEqual('forbidden');
118
+ expect(failure.status).toEqual(403);
119
+ expect(failure.message).toContain('createFile failed for file file-1: forbidden (HTTP 403)');
120
+ });
121
+
122
+ it('deleteFile refused by the store: forbidden, and the token is nowhere in the error', async () => {
123
+ answerWith = 403;
124
+
125
+ const failure = await failureOf(() => driver.deleteFile('file-1'));
126
+
127
+ expect(authorizedRequests).toBeGreaterThan(0);
128
+ expectDriverOwned(failure);
129
+ expect(failure.code).toEqual('forbidden');
130
+ });
131
+
132
+ it('deleteFile on a missing object is still success — deletes are idempotent', async () => {
133
+ await expect(driver.deleteFile('file-1')).resolves.toBeUndefined();
134
+ });
135
+
136
+ it.each([
137
+ [401, 'forbidden'],
138
+ [403, 'forbidden'],
139
+ [412, 'precondition-failed'],
140
+ [429, 'unavailable'],
141
+ [503, 'unavailable'],
142
+ [400, 'unknown'],
143
+ ])('the store answering %i reads as %s', async (status, code) => {
144
+ answerWith = status;
145
+
146
+ const failure = await failureOf(() => driver.getFileData('file-1'));
147
+
148
+ expectDriverOwned(failure);
149
+ expect(failure.code).toEqual(code);
150
+ expect(failure.status).toEqual(status);
151
+ });
152
+
153
+ it('a store that cannot be reached reads as unavailable', async () => {
154
+ const closed = http.createServer();
155
+ await new Promise<void>((resolve) => closed.listen(0, '127.0.0.1', resolve));
156
+ const port = (closed.address() as AddressInfo).port;
157
+ await new Promise((resolve) => closed.close(resolve));
158
+ const unreachable = new GoogleCloudStorageDriver({
159
+ projectId: 'test-project',
160
+ bucketName: 'test-bucket',
161
+ storageOptions: { apiEndpoint: `http://127.0.0.1:${port}`, retryOptions: { autoRetry: false } },
162
+ });
163
+
164
+ const failure = await failureOf(() => unreachable.getFileData('file-1'));
165
+
166
+ expectDriverOwned(failure);
167
+ expect(failure.code).toEqual('unavailable');
168
+ expect(failure.status).toBeUndefined();
169
+ });
170
+
171
+ it('a signing failure is the driver’s error too', async () => {
172
+ const failure = await failureOf(() => driver.getSignedUrl('file-1'));
173
+
174
+ expectDriverOwned(failure);
175
+ expect(failure.message).toContain('getSignedUrl failed for file file-1');
176
+ });
177
+ });
178
+
179
+ /**
180
+ * THE WORST CASE, whatever client version is installed: an error that carries the credential
181
+ * everywhere one can — its message (a signed URL), an enumerable request config, the request, the
182
+ * response's config and request, its error list, its cause. None of it leaves the driver.
183
+ */
184
+ describe('GoogleCloudStorageDriver errors, whatever the client’s error carries', () => {
185
+ const authorization = { Authorization: `Bearer ${MARKER}` };
186
+ const signedUrl = `https://storage.example/test-bucket/file-1?X-Goog-Signature=${MARKER}&X-Goog-Expires=900`;
187
+ const clientError = () =>
188
+ Object.assign(new Error(`Request to ${signedUrl} failed with Authorization: Bearer ${MARKER}`), {
189
+ code: 404,
190
+ config: { url: signedUrl, headers: authorization },
191
+ request: { headers: authorization },
192
+ response: { status: 404, config: { headers: authorization }, request: { headers: authorization } },
193
+ errors: [{ message: `denied for ${signedUrl}` }],
194
+ cause: { config: { headers: authorization } },
195
+ });
196
+
197
+ function driverWhoseClientThrows(): GoogleCloudStorageDriver {
198
+ const failing = async () => {
199
+ throw clientError();
200
+ };
201
+ const driver = new GoogleCloudStorageDriver({ projectId: 'test-project', bucketName: 'test-bucket' });
202
+ (driver as unknown as DriverInternals).storage = {
203
+ bucket: () => ({
204
+ file: () => ({
205
+ save: failing,
206
+ download: failing,
207
+ getMetadata: failing,
208
+ setMetadata: failing,
209
+ getSignedUrl: failing,
210
+ delete: failing,
211
+ }),
212
+ }),
213
+ };
214
+ return driver;
215
+ }
216
+
217
+ const picture = { id: 'file-1', name: 'holiday.jpg', type: 'image/jpeg', size: 5 } as never;
218
+ const operations: [string, (driver: GoogleCloudStorageDriver) => Promise<unknown>][] = [
219
+ ['createFile', (driver) => driver.createFile(picture, '')],
220
+ ['getFileData', (driver) => driver.getFileData('file-1')],
221
+ ['updateFileData', (driver) => driver.updateFileData('file-1', '')],
222
+ ['updateFile', (driver) => driver.updateFile(picture)],
223
+ ['getSignedUrl', (driver) => driver.getSignedUrl('file-1')],
224
+ ['deleteFile', (driver) => driver.deleteFile('file-1')],
225
+ ];
226
+
227
+ it('the stand-in error really does carry the marker everywhere', () => {
228
+ expect(String(clientError())).toContain(MARKER);
229
+ expect(JSON.stringify(clientError())).toContain(MARKER);
230
+ expect(util.inspect(clientError())).toContain(MARKER);
231
+ });
232
+
233
+ it.each(operations)(
234
+ '%s: a caller printing, serialising or inspecting the error never reads the marker',
235
+ async (name, run) => {
236
+ const failure = await failureOf(() => run(driverWhoseClientThrows()));
237
+
238
+ expectDriverOwned(failure);
239
+ expect(failure.code).toEqual('not-found');
240
+ expect(failure.message).toContain(`${name} failed for file file-1: not-found (HTTP 404)`);
241
+ expect(failure.message).toContain('https://storage.example/test-bucket/file-1 failed');
242
+ }
243
+ );
244
+ });