@proteinjs/db-file 1.4.15 → 1.5.1
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 +20 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +3 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/src/DbFileStorageDriver.d.ts +2 -1
- package/dist/src/DbFileStorageDriver.d.ts.map +1 -1
- package/dist/src/DbFileStorageDriver.js +23 -7
- package/dist/src/DbFileStorageDriver.js.map +1 -1
- package/dist/src/FileStorage.d.ts +24 -6
- package/dist/src/FileStorage.d.ts.map +1 -1
- package/dist/src/FileStorage.js +57 -22
- package/dist/src/FileStorage.js.map +1 -1
- package/dist/src/FileStorageDriver.d.ts +26 -1
- package/dist/src/FileStorageDriver.d.ts.map +1 -1
- package/dist/src/FileStorageTableWatcher.d.ts +18 -0
- package/dist/src/FileStorageTableWatcher.d.ts.map +1 -0
- package/dist/src/FileStorageTableWatcher.js +96 -0
- package/dist/src/FileStorageTableWatcher.js.map +1 -0
- package/dist/src/routes/getFile.d.ts +11 -0
- package/dist/src/routes/getFile.d.ts.map +1 -1
- package/dist/src/routes/getFile.js +31 -15
- package/dist/src/routes/getFile.js.map +1 -1
- package/dist/src/tables/FileTable.d.ts +12 -4
- package/dist/src/tables/FileTable.d.ts.map +1 -1
- package/dist/src/tables/FileTable.js +5 -7
- package/dist/src/tables/FileTable.js.map +1 -1
- package/dist/test/FileMediaMetadata.test.d.ts +2 -0
- package/dist/test/FileMediaMetadata.test.d.ts.map +1 -0
- package/dist/test/FileMediaMetadata.test.js +154 -0
- package/dist/test/FileMediaMetadata.test.js.map +1 -0
- package/dist/test/FileStorageDeleteFile.test.d.ts +2 -0
- package/dist/test/FileStorageDeleteFile.test.d.ts.map +1 -0
- package/dist/test/FileStorageDeleteFile.test.js +259 -0
- package/dist/test/FileStorageDeleteFile.test.js.map +1 -0
- package/dist/test/FileTestEnvironment.d.ts +33 -0
- package/dist/test/FileTestEnvironment.d.ts.map +1 -0
- package/dist/test/FileTestEnvironment.js +229 -0
- package/dist/test/FileTestEnvironment.js.map +1 -0
- package/dist/test/GetFileRoute.test.d.ts +2 -0
- package/dist/test/GetFileRoute.test.d.ts.map +1 -0
- package/dist/test/GetFileRoute.test.js +432 -0
- package/dist/test/GetFileRoute.test.js.map +1 -0
- package/generated/index.ts +3 -1
- package/index.ts +1 -0
- package/jest.config.js +4 -0
- package/package.json +5 -3
- package/src/DbFileStorageDriver.ts +18 -8
- package/src/FileStorage.ts +48 -23
- package/src/FileStorageDriver.ts +24 -1
- package/src/FileStorageTableWatcher.ts +43 -0
- package/src/routes/getFile.ts +26 -8
- package/src/tables/FileTable.ts +14 -7
- package/test/FileMediaMetadata.test.ts +74 -0
- package/test/FileStorageDeleteFile.test.ts +128 -0
- package/test/FileTestEnvironment.ts +118 -0
- package/test/GetFileRoute.test.ts +258 -0
- package/test/emulatorLock.globalSetup.js +11 -0
- package/test/emulatorLock.globalTeardown.js +8 -0
- package/test/emulatorLock.js +124 -0
- package/test/setup.js +2 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Db, Table, getDbAsSystem } from '@proteinjs/db';
|
|
2
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
3
|
+
import { SpannerEmulatorProvisioner, getDropTestTable } from '@proteinjs/db-driver-spanner/test';
|
|
4
|
+
import { Session, type SessionData, type SessionDataStorage } from '@proteinjs/server-api';
|
|
5
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
6
|
+
import { tables as userTables, User, UserRepo } from '@proteinjs/user';
|
|
7
|
+
import { tables as fileTables } from '../src/tables/tables';
|
|
8
|
+
import { FileStorage } from '../src/FileStorage';
|
|
9
|
+
import { FileStorageDriver } from '../src/FileStorageDriver';
|
|
10
|
+
import { FileStorageTableWatcher } from '../src/FileStorageTableWatcher';
|
|
11
|
+
|
|
12
|
+
/** In-memory SessionDataStorage seeded into the SourceRepository cache (tests don't load the
|
|
13
|
+
* generated source graph, so `Session` would otherwise find no implementation). */
|
|
14
|
+
class TestSessionDataStorage implements SessionDataStorage {
|
|
15
|
+
environment = 'node' as const;
|
|
16
|
+
private static data: SessionData;
|
|
17
|
+
|
|
18
|
+
setData(data: SessionData) {
|
|
19
|
+
TestSessionDataStorage.data = data;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getData(): SessionData {
|
|
23
|
+
return TestSessionDataStorage.data;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
|
|
28
|
+
type FileStorageInternals = { driver?: FileStorageDriver };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Emulator-backed test environment for db-file integration tests (Spanner emulator on
|
|
32
|
+
* SPANNER_EMULATOR_HOST, provisioned on demand). Same shape as user-server's
|
|
33
|
+
* UserServerTestEnvironment: seed deterministic session storage + db driver + file storage driver
|
|
34
|
+
* into the SourceRepository cache, load the tables the suites touch, drop them on the way out.
|
|
35
|
+
*/
|
|
36
|
+
export class FileTestEnvironment {
|
|
37
|
+
readonly spannerDriver = new SpannerDriver({
|
|
38
|
+
projectId: 'proteinjs-test',
|
|
39
|
+
instanceName: 'proteinjs-test',
|
|
40
|
+
databaseName: 'test',
|
|
41
|
+
});
|
|
42
|
+
private dropTestTable = getDropTestTable(this.spannerDriver);
|
|
43
|
+
private userRepo = new UserRepo();
|
|
44
|
+
|
|
45
|
+
async beforeAll() {
|
|
46
|
+
// The emulator wipes its in-memory instances on any container restart (and CI service
|
|
47
|
+
// containers start empty) — self-provision so suites never depend on out-of-band setup.
|
|
48
|
+
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
49
|
+
projectId: 'proteinjs-test',
|
|
50
|
+
instanceName: 'proteinjs-test',
|
|
51
|
+
databaseName: 'test',
|
|
52
|
+
});
|
|
53
|
+
const objectCache = this.objectCache();
|
|
54
|
+
objectCache['@proteinjs/server-api/SessionDataStorage'] = [new TestSessionDataStorage()];
|
|
55
|
+
objectCache['@proteinjs/db/DefaultDbDriverFactory'] = [{ getDbDriver: () => this.spannerDriver }];
|
|
56
|
+
// Tests import src directly (no generated source graph), so the table registry that
|
|
57
|
+
// cascade machinery resolves through (`tableByName`) must be seeded explicitly.
|
|
58
|
+
objectCache['@proteinjs/db/Table'] = [...Object.values(userTables), ...Object.values(fileTables)] as unknown[];
|
|
59
|
+
// The byte-deletion seam under test: prod loads it from the generated source graph.
|
|
60
|
+
objectCache['@proteinjs/db/TableWatcher'] = [new FileStorageTableWatcher()];
|
|
61
|
+
Session.setData({
|
|
62
|
+
sessionId: 'test-session',
|
|
63
|
+
user: 'guest',
|
|
64
|
+
data: {},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await this.loadTables(userTables);
|
|
68
|
+
await this.loadTables(fileTables);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async afterAll() {
|
|
72
|
+
await this.dropTables(fileTables);
|
|
73
|
+
await this.dropTables(userTables);
|
|
74
|
+
SpannerEmulatorProvisioner.release();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Make `driver` the resolved FileStorageDriver: seeds the DefaultFileStorageDriverFactory the
|
|
79
|
+
* driver resolution reads and clears the cached instance so the next resolution re-reads it.
|
|
80
|
+
*/
|
|
81
|
+
setDriver(driver: FileStorageDriver) {
|
|
82
|
+
this.objectCache()['@proteinjs/db-file/DefaultFileStorageDriverFactory'] = [{ getDriver: () => driver }];
|
|
83
|
+
(FileStorage as unknown as FileStorageInternals).driver = undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Insert a user row (as system) and return it. */
|
|
87
|
+
async createUser(args: { name: string; email: string; roles?: string[] }): Promise<User> {
|
|
88
|
+
return await getDbAsSystem().insert(userTables.User, {
|
|
89
|
+
name: args.name,
|
|
90
|
+
email: args.email,
|
|
91
|
+
password: 'test',
|
|
92
|
+
emailVerified: true,
|
|
93
|
+
roles: args.roles ?? [],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Seed the session cache with `user` — the identity service calls and scoped db ops run under. */
|
|
98
|
+
actAs(user: User) {
|
|
99
|
+
this.userRepo.setUser(user);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private objectCache(): Record<string, unknown[]> {
|
|
103
|
+
return (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async loadTables(tables: { [key: string]: Table<any> }) {
|
|
107
|
+
const dbDriver = Db.getDefaultDbDriver();
|
|
108
|
+
for (const table of Object.values(tables)) {
|
|
109
|
+
await dbDriver.getTableManager().loadTable(table);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async dropTables(tables: { [key: string]: Table<any> }) {
|
|
114
|
+
for (const table of Object.values(tables)) {
|
|
115
|
+
await this.dropTestTable(table);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
import { AddressInfo } from 'net';
|
|
3
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
4
|
+
import { UserAuth, UserRepo, guestUser, User } from '@proteinjs/user';
|
|
5
|
+
import { File } from '../src/tables/FileTable';
|
|
6
|
+
import { FileStorage } from '../src/FileStorage';
|
|
7
|
+
import { FileStorageDriver } from '../src/FileStorageDriver';
|
|
8
|
+
import { getFile } from '../src/routes/getFile';
|
|
9
|
+
import { FileTestEnvironment } from './FileTestEnvironment';
|
|
10
|
+
|
|
11
|
+
/** Every byte value once — a payload that corrupts under any text/base64 mix-up. */
|
|
12
|
+
const rawBytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* In-memory byte store shaped like the GoogleCloudStorageDriver WITHOUT signed-URL support:
|
|
16
|
+
* the proxy-serving case (DbFileStorageDriver shape). Stores true bytes at rest per the driver
|
|
17
|
+
* contract; the interface strings are base64.
|
|
18
|
+
*/
|
|
19
|
+
class TestProxyFileStorageDriver implements FileStorageDriver {
|
|
20
|
+
readonly store = new Map<string, Buffer>();
|
|
21
|
+
|
|
22
|
+
async createFile(file: File, fileData: string): Promise<void> {
|
|
23
|
+
this.store.set(file.id, Buffer.from(fileData, 'base64'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async getFileData(fileId: string): Promise<string> {
|
|
27
|
+
const data = this.store.get(fileId);
|
|
28
|
+
if (data === undefined) {
|
|
29
|
+
throw new Error(`No such object: ${fileId}`);
|
|
30
|
+
}
|
|
31
|
+
return data.toString('base64');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async updateFileData(fileId: string, data: string): Promise<void> {
|
|
35
|
+
this.store.set(fileId, Buffer.from(data, 'base64'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async deleteFile(fileId: string): Promise<void> {
|
|
39
|
+
this.store.delete(fileId);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Local GCS shim: the signed-URL serving case. Blobs live as TRUE bytes behind a real local HTTP
|
|
45
|
+
* server (as they do in GCS); `getSignedUrl` mints a URL into that server's URL space, and
|
|
46
|
+
* clients read the bytes directly from it — exactly the redirect flow the route implements.
|
|
47
|
+
*/
|
|
48
|
+
class TestSignedUrlFileStorageDriver implements FileStorageDriver {
|
|
49
|
+
readonly store = new Map<string, { bytes: Buffer; contentType: string }>();
|
|
50
|
+
private server?: http.Server;
|
|
51
|
+
private port?: number;
|
|
52
|
+
|
|
53
|
+
async start(): Promise<void> {
|
|
54
|
+
this.server = http.createServer((req, res) => {
|
|
55
|
+
const fileId = (req.url ?? '').replace(/^\/blob\//, '').split('?')[0];
|
|
56
|
+
const blob = this.store.get(fileId);
|
|
57
|
+
if (!blob) {
|
|
58
|
+
res.writeHead(404).end();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
res.writeHead(200, { 'Content-Type': blob.contentType, 'Content-Length': blob.bytes.length });
|
|
62
|
+
res.end(blob.bytes);
|
|
63
|
+
});
|
|
64
|
+
await new Promise<void>((resolve) => this.server!.listen(0, '127.0.0.1', resolve));
|
|
65
|
+
this.port = (this.server!.address() as AddressInfo).port;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async stop(): Promise<void> {
|
|
69
|
+
if (this.server) {
|
|
70
|
+
await new Promise<void>((resolve, reject) => this.server!.close((err) => (err ? reject(err) : resolve())));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async createFile(file: File, fileData: string): Promise<void> {
|
|
75
|
+
this.store.set(file.id, { bytes: Buffer.from(fileData, 'base64'), contentType: file.type });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async getFileData(fileId: string): Promise<string> {
|
|
79
|
+
const blob = this.store.get(fileId);
|
|
80
|
+
if (blob === undefined) {
|
|
81
|
+
throw new Error(`No such object: ${fileId}`);
|
|
82
|
+
}
|
|
83
|
+
return blob.bytes.toString('base64');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async updateFileData(fileId: string, data: string): Promise<void> {
|
|
87
|
+
const existing = this.store.get(fileId);
|
|
88
|
+
this.store.set(fileId, {
|
|
89
|
+
bytes: Buffer.from(data, 'base64'),
|
|
90
|
+
contentType: existing?.contentType ?? 'application/octet-stream',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async getSignedUrl(fileId: string, options?: { ttlMs?: number }): Promise<string> {
|
|
95
|
+
const expires = Date.now() + (options?.ttlMs ?? 15 * 60 * 1000);
|
|
96
|
+
return `http://127.0.0.1:${this.port}/blob/${fileId}?sig=test&expires=${expires}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async deleteFile(fileId: string): Promise<void> {
|
|
100
|
+
this.store.delete(fileId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Minimal express Response recorder — the route only touches these members. */
|
|
105
|
+
class ResponseRecorder {
|
|
106
|
+
statusCode?: number;
|
|
107
|
+
body?: unknown;
|
|
108
|
+
redirectUrl?: string;
|
|
109
|
+
headers: Record<string, string> = {};
|
|
110
|
+
|
|
111
|
+
status(code: number): ResponseRecorder {
|
|
112
|
+
this.statusCode = code;
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
send(body: unknown): ResponseRecorder {
|
|
117
|
+
this.body = body;
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setHeader(name: string, value: string): void {
|
|
122
|
+
this.headers[name.toLowerCase()] = value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
redirect(status: number, url: string): void {
|
|
126
|
+
this.statusCode = status;
|
|
127
|
+
this.redirectUrl = url;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type RouteRequest = Parameters<typeof getFile.onRequest>[0];
|
|
132
|
+
type RouteResponse = Parameters<typeof getFile.onRequest>[1];
|
|
133
|
+
|
|
134
|
+
const invokeRoute = async (fileId: string): Promise<ResponseRecorder> => {
|
|
135
|
+
const response = new ResponseRecorder();
|
|
136
|
+
await getFile.onRequest({ params: { id: fileId } } as unknown as RouteRequest, response as unknown as RouteResponse);
|
|
137
|
+
return response;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const testEnv = new FileTestEnvironment();
|
|
141
|
+
type UserAuthInternals = { userRepo?: unknown };
|
|
142
|
+
let user: User;
|
|
143
|
+
|
|
144
|
+
beforeAll(async () => {
|
|
145
|
+
await testEnv.beforeAll();
|
|
146
|
+
// The route gates on UserAuth.isLoggedIn(), which resolves its user repo from the source
|
|
147
|
+
// repository — seed it (tests don't load the generated source graph) and reset the static cache.
|
|
148
|
+
const objectCache = (SourceRepository.get() as unknown as { objectCache: Record<string, unknown[]> }).objectCache;
|
|
149
|
+
objectCache['@proteinjs/user-auth/AuthenticatedUserRepo'] = [new UserRepo()];
|
|
150
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
|
151
|
+
user = await testEnv.createUser({ name: 'File owner', email: 'file-owner@test.local' });
|
|
152
|
+
testEnv.actAs(user);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
afterAll(async () => {
|
|
156
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
|
157
|
+
await testEnv.afterAll();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('GET /file/:id — proxy serving (driver without signed URLs)', () => {
|
|
161
|
+
const driver = new TestProxyFileStorageDriver();
|
|
162
|
+
|
|
163
|
+
beforeAll(() => {
|
|
164
|
+
testEnv.setDriver(driver);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('serves a non-image binary byte-identical (video/mp4 round-trip)', async () => {
|
|
168
|
+
const file = await new FileStorage().createFile(
|
|
169
|
+
{ name: 'clip.mp4', type: 'video/mp4', size: rawBytes.length } as File,
|
|
170
|
+
rawBytes.toString('base64')
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const response = await invokeRoute(file.id);
|
|
174
|
+
|
|
175
|
+
expect(response.redirectUrl).toBeUndefined();
|
|
176
|
+
expect(response.headers['content-type']).toEqual('video/mp4');
|
|
177
|
+
expect(Buffer.isBuffer(response.body)).toBe(true);
|
|
178
|
+
expect(Buffer.compare(response.body as Buffer, rawBytes)).toBe(0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('still serves images byte-identical (no regression on the decoded-image path)', async () => {
|
|
182
|
+
const file = await new FileStorage().createFile(
|
|
183
|
+
{ name: 'shot.png', type: 'image/png', size: rawBytes.length } as File,
|
|
184
|
+
rawBytes.toString('base64')
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const response = await invokeRoute(file.id);
|
|
188
|
+
|
|
189
|
+
expect(Buffer.isBuffer(response.body)).toBe(true);
|
|
190
|
+
expect(Buffer.compare(response.body as Buffer, rawBytes)).toBe(0);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('GET /file/:id — signed-URL serving (302 redirect)', () => {
|
|
195
|
+
const driver = new TestSignedUrlFileStorageDriver();
|
|
196
|
+
|
|
197
|
+
beforeAll(async () => {
|
|
198
|
+
await driver.start();
|
|
199
|
+
testEnv.setDriver(driver);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
afterAll(async () => {
|
|
203
|
+
await driver.stop();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('302-redirects to the signed URL; following it yields the true bytes, byte-identical', async () => {
|
|
207
|
+
const file = await new FileStorage().createFile(
|
|
208
|
+
{ name: 'clip.mp4', type: 'video/mp4', size: rawBytes.length } as File,
|
|
209
|
+
rawBytes.toString('base64')
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const response = await invokeRoute(file.id);
|
|
213
|
+
|
|
214
|
+
expect(response.statusCode).toEqual(302);
|
|
215
|
+
expect(response.redirectUrl).toBeDefined();
|
|
216
|
+
|
|
217
|
+
const blobResponse = await fetch(response.redirectUrl!);
|
|
218
|
+
expect(blobResponse.status).toEqual(200);
|
|
219
|
+
expect(blobResponse.headers.get('content-type')).toEqual('video/mp4');
|
|
220
|
+
const served = Buffer.from(await blobResponse.arrayBuffer());
|
|
221
|
+
expect(Buffer.compare(served, rawBytes)).toBe(0);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('caches the redirect briefly so repeated loads reuse one signed URL within its TTL', async () => {
|
|
225
|
+
const file = await new FileStorage().createFile(
|
|
226
|
+
{ name: 'shot.png', type: 'image/png', size: rawBytes.length } as File,
|
|
227
|
+
rawBytes.toString('base64')
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const response = await invokeRoute(file.id);
|
|
231
|
+
|
|
232
|
+
expect(response.statusCode).toEqual(302);
|
|
233
|
+
expect(response.headers['cache-control']).toEqual('private, max-age=300');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('404s on a missing file id without minting a signed URL', async () => {
|
|
237
|
+
const response = await invokeRoute('00000000-0000-0000-0000-000000000000');
|
|
238
|
+
|
|
239
|
+
expect(response.statusCode).toEqual(404);
|
|
240
|
+
expect(response.redirectUrl).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('401s when not logged in, without redirecting or leaking a signed URL', async () => {
|
|
244
|
+
const file = await new FileStorage().createFile(
|
|
245
|
+
{ name: 'clip.mp4', type: 'video/mp4', size: rawBytes.length } as File,
|
|
246
|
+
rawBytes.toString('base64')
|
|
247
|
+
);
|
|
248
|
+
testEnv.actAs(guestUser as unknown as User);
|
|
249
|
+
try {
|
|
250
|
+
const response = await invokeRoute(file.id);
|
|
251
|
+
|
|
252
|
+
expect(response.statusCode).toEqual(401);
|
|
253
|
+
expect(response.redirectUrl).toBeUndefined();
|
|
254
|
+
} finally {
|
|
255
|
+
testEnv.actAs(user);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// jest runs this in the main jest process before any test workers (and before setupFiles), so one
|
|
3
|
+
// whole `jest --runInBand` run holds the emulator exclusively — concurrent suites from other
|
|
4
|
+
// packages wait instead of corrupting each other. globalSetup runs before setupFiles, so require
|
|
5
|
+
// ./setup here to compute the same SPANNER_EMULATOR_HOST the tests will use.
|
|
6
|
+
require('./setup');
|
|
7
|
+
const { acquire } = require('./emulatorLock');
|
|
8
|
+
|
|
9
|
+
module.exports = async () => {
|
|
10
|
+
await acquire(process.env.SPANNER_EMULATOR_HOST, require('../package.json').name);
|
|
11
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Releases the emulator lock acquired by ./emulatorLock.globalSetup.js (same jest parent process).
|
|
3
|
+
require('./setup');
|
|
4
|
+
const { release } = require('./emulatorLock');
|
|
5
|
+
|
|
6
|
+
module.exports = async () => {
|
|
7
|
+
release(process.env.SPANNER_EMULATOR_HOST, require('../package.json').name);
|
|
8
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-process mutual exclusion around a Spanner emulator.
|
|
4
|
+
*
|
|
5
|
+
* `--runInBand` serializes tests within one package; nothing serializes across packages, and two
|
|
6
|
+
* jest runs hitting the same emulator corrupt each other (orphaned transactions, DDL
|
|
7
|
+
* FAILED_PRECONDITION). This lock closes that gap for this package: a lockfile in os.tmpdir()
|
|
8
|
+
* keyed by the emulator host string, held for the whole jest run via globalSetup/globalTeardown.
|
|
9
|
+
*
|
|
10
|
+
* The lockfile PATH FORMULA must stay byte-identical to the fleet's canonical implementation
|
|
11
|
+
* (`EmulatorLock.lockFilePathFor` in @n3xah/thought-common/test) so suites from every repo
|
|
12
|
+
* pointed at the same emulator actually contend on the same file. The logic is duplicated here
|
|
13
|
+
* because this repo sits BELOW @n3xah in the dependency layering (thought-common itself depends
|
|
14
|
+
* on @proteinjs/user) — future extraction target: @proteinjs/db-driver-spanner/test, beside
|
|
15
|
+
* SpannerEmulatorProvisioner.
|
|
16
|
+
*/
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const POLL_MS = 1000;
|
|
23
|
+
const WAIT_LOG_EVERY_MS = 30_000;
|
|
24
|
+
const TIMEOUT_MS = 60 * 60_000; // full DB suites elsewhere legitimately run 30+ minutes
|
|
25
|
+
|
|
26
|
+
/** Deterministic lockfile path for an emulator host — sanitized slug plus short hash. */
|
|
27
|
+
function lockFilePathFor(host) {
|
|
28
|
+
const slug = host
|
|
29
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
30
|
+
.replace(/^-+|-+$/g, '')
|
|
31
|
+
.slice(0, 64);
|
|
32
|
+
const hash = crypto.createHash('sha1').update(host).digest('hex').slice(0, 8);
|
|
33
|
+
return path.join(os.tmpdir(), `n3xa-spanner-emulator-${slug}-${hash}.lock`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isPidAlive(pid) {
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, 0);
|
|
39
|
+
return true;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return error.code === 'EPERM'; // alive but not ours; ESRCH = gone
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tryCreate(lockFilePath, packageName) {
|
|
46
|
+
const info = {
|
|
47
|
+
pid: process.pid,
|
|
48
|
+
packageName,
|
|
49
|
+
timestamp: new Date().toISOString(),
|
|
50
|
+
nonce: crypto.randomBytes(8).toString('hex'),
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
fs.writeFileSync(lockFilePath, JSON.stringify(info), { flag: 'wx' });
|
|
54
|
+
return true;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error.code === 'EEXIST') {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readLockInfo(lockFilePath) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(fs.readFileSync(lockFilePath, 'utf8'));
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined; // gone, or unparseable (treated as stale)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function acquire(host, packageName) {
|
|
72
|
+
const lockFilePath = lockFilePathFor(host);
|
|
73
|
+
const start = Date.now();
|
|
74
|
+
let lastWaitLog = 0;
|
|
75
|
+
for (;;) {
|
|
76
|
+
if (tryCreate(lockFilePath, packageName)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const info = readLockInfo(lockFilePath);
|
|
80
|
+
if (!info || !isPidAlive(info.pid)) {
|
|
81
|
+
// Stale (crashed run) — remove and retry; the O_EXCL create above decides the winner.
|
|
82
|
+
try {
|
|
83
|
+
fs.unlinkSync(lockFilePath);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error.code !== 'ENOENT') {
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
if (now - start >= TIMEOUT_MS) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`timed out waiting for emulator lock (${host}) held by pid ${info.pid} (${info.packageName}); ` +
|
|
94
|
+
`lockfile: ${lockFilePath}`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (lastWaitLog === 0 || now - lastWaitLog >= WAIT_LOG_EVERY_MS) {
|
|
98
|
+
console.log(
|
|
99
|
+
`[${packageName}] waiting for emulator lock (${host}) held by pid ${info.pid} ` +
|
|
100
|
+
`(${info.packageName}, since ${info.timestamp}) — waited ${Math.round((now - start) / 1000)}s`
|
|
101
|
+
);
|
|
102
|
+
lastWaitLog = now;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function release(host, packageName) {
|
|
110
|
+
const lockFilePath = lockFilePathFor(host);
|
|
111
|
+
const info = readLockInfo(lockFilePath);
|
|
112
|
+
if (!info) {
|
|
113
|
+
console.log(`[${packageName}] emulator lock (${host}) was already gone at release`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (info.pid !== process.pid) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`refusing to release emulator lock (${host}) owned by pid ${info.pid} (${info.packageName}), not us (${process.pid})`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
fs.unlinkSync(lockFilePath);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { acquire, release, lockFilePathFor };
|
package/test/setup.js
ADDED