@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.
- package/CHANGELOG.md +25 -0
- package/README.md +14 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/src/DefaultGoogleCloudStorageConfigFactory.d.ts +7 -0
- package/dist/src/DefaultGoogleCloudStorageConfigFactory.d.ts.map +1 -1
- package/dist/src/GoogleCloudStorageDriver.d.ts +27 -0
- package/dist/src/GoogleCloudStorageDriver.d.ts.map +1 -1
- package/dist/src/GoogleCloudStorageDriver.js +127 -35
- package/dist/src/GoogleCloudStorageDriver.js.map +1 -1
- package/generated/index.ts +1 -1
- package/package.json +5 -4
- package/src/DefaultGoogleCloudStorageConfigFactory.ts +7 -0
- package/src/GoogleCloudStorageDriver.ts +130 -33
- package/test/GoogleCloudStorageDriverByteEncoding.test.ts +4 -1
- package/test/GoogleCloudStorageDriverDefaultConfig.test.ts +97 -0
- package/test/GoogleCloudStorageDriverErrors.test.ts +244 -0
- package/test/GoogleCloudStorageDriverObjectPrefix.test.ts +128 -0
- package/test/GoogleCloudStorageDriverOverwriteMetadata.test.ts +146 -0
|
@@ -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
|
|
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
|
|
30
|
-
|
|
31
|
-
contentType: file.type,
|
|
41
|
+
await this.guarded('createFile', file.id, () =>
|
|
42
|
+
gcsFile.save(Buffer.from(fileData, 'base64'), {
|
|
32
43
|
metadata: {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
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
|
|
49
|
-
|
|
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.
|
|
54
|
-
await
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
66
|
-
const [url] = await
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
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
|
|
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
|
+
});
|