-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy patherror-envelope.conformance.test.ts
More file actions
304 lines (290 loc) · 11.6 KB
/
Copy patherror-envelope.conformance.test.ts
File metadata and controls
304 lines (290 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Error-envelope conformance for the autonomously-mounted `/api/v1/storage/*`
* routes (#3675).
*
* The route ledgers (#3563 → #3656) audit which routes EXIST and whether the
* SDK can address them. They say nothing about what comes back, which is how
* these routes carried green `sdk` rows while emitting a bare
* `{ error: '<message>' }` — with the code, where one existed at all, as a
* SIBLING of `error` rather than a field of it — against a contract that
* declares `{ success: false, error: { code, message } }`.
*
* This file closes that gap for the error path, in two directions:
* 1. every distinct error branch is DRIVEN and its body parsed against the
* real `BaseResponseSchema` from `packages/spec` — not a local copy, so
* the assertion tracks the contract if the contract moves;
* 2. the module source is scanned so a NEW route cannot quietly reintroduce
* the bare shape. Without (2) this suite only ever covers the branches
* that existed the day it was written.
*
* The STATIC half — proving no route can bypass the `sendOk` / `sendError` pair
* — used to be an open-coded regex block right here. #3843 lifted it out to
* `scripts/check-route-envelope.mjs` (`pnpm check:route-envelope`), which audits
* EVERY route module in the repo rather than this one package.
*
* That move mattered more than deduplication. A per-package scan structurally
* cannot notice a module nobody thought to convert, and going repo-wide found two
* immediately — `share-link-routes.ts` (#3983) and the dev-only `hmr-routes.ts`,
* neither of them in #3843's hand-written survey. It also dropped the regex: the old block stripped
* comments with `String.replace`, which ate `//` inside string literals and
* truncated the rest of that line — response writes included — and counted
* `c.req.json()` (a request READ) as an unenveloped response. The AST has neither
* bug.
*/
import { describe, it, expect } from 'vitest';
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { BaseResponseSchema } from '@objectstack/spec/api';
import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
import { LocalStorageAdapter } from './local-storage-adapter';
import { StorageMetadataStore } from './metadata-store';
import { registerStorageRoutes } from './storage-routes';
const BASE = '/api/v1/storage';
interface Captured {
status: number;
body: any;
}
function mount(storage: any, store: any, routeOpts: any = {}) {
const routes = new Map<string, RouteHandler>();
const http = {
get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); },
post: (p: string, h: RouteHandler) => { routes.set(`POST:${p}`, h); },
put: (p: string, h: RouteHandler) => { routes.set(`PUT:${p}`, h); },
delete: () => {},
patch: () => {},
use: () => {},
listen: async () => {},
close: async () => {},
};
registerStorageRoutes(http as any, storage, store, { basePath: BASE, ...routeOpts });
return routes;
}
async function drive(
routes: Map<string, RouteHandler>,
method: string,
path: string,
req: Partial<IHttpRequest> = {},
): Promise<Captured> {
const handler = routes.get(`${method}:${path}`);
if (!handler) throw new Error(`no handler for ${method} ${path}`);
const captured: Captured = { status: 200, body: undefined };
const res: any = {
json(data: any) { captured.body = data; },
send() {},
status(code: number) { captured.status = code; return res; },
header() { return res; },
};
await handler(
{ params: {}, query: {}, body: undefined, headers: {}, method, path, ...req } as IHttpRequest,
res as IHttpResponse,
);
return captured;
}
/** A store whose every read throws — drives the `catch` arms. */
const explodingStore = {
getFile: async () => { throw new Error('boom'); },
getSession: async () => { throw new Error('boom'); },
createFile: async () => { throw new Error('boom'); },
} as any;
async function tmpAdapter(): Promise<LocalStorageAdapter> {
const rootDir = join(tmpdir(), `os-err-env-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await fs.mkdir(rootDir, { recursive: true });
return new LocalStorageAdapter({ rootDir, signingSecret: 'test-secret' });
}
async function committedAttachment(store: StorageMetadataStore, id: string, extra: any = {}) {
await store.createFile({
id,
key: `attachments/${id}.bin`,
name: 'x.bin',
status: 'committed',
acl: 'private',
scope: 'attachments',
...extra,
} as any);
}
describe('storage error envelope (#3675)', () => {
/**
* One entry per distinct `code` the routes can emit. `expected` is asserted
* exactly, so retiring a code without retiring its case fails here rather
* than silently shrinking coverage.
*/
const CASES: Array<{ name: string; status: number; code: string; run: () => Promise<Captured> }> = [
{
name: 'missing required upload fields',
status: 400,
code: 'INVALID_REQUEST',
run: async () => {
const routes = mount(await tmpAdapter(), new StorageMetadataStore(null));
return drive(routes, 'POST', `${BASE}/upload/presigned`, { body: {} });
},
},
{
name: 'completing an upload for an unknown file',
status: 404,
code: 'FILE_NOT_FOUND',
run: async () => {
const routes = mount(await tmpAdapter(), new StorageMetadataStore(null));
return drive(routes, 'POST', `${BASE}/upload/complete`, { body: { fileId: 'nope' } });
},
},
{
name: 'completing an unknown chunked session',
status: 404,
code: 'UPLOAD_SESSION_NOT_FOUND',
run: async () => {
const routes = mount(await tmpAdapter(), new StorageMetadataStore(null));
return drive(routes, 'POST', `${BASE}/upload/chunked/:uploadId/complete`, {
params: { uploadId: 'nope' },
});
},
},
{
name: 'chunk upload with the wrong resume token',
status: 403,
code: 'INVALID_RESUME_TOKEN',
run: async () => {
const store = new StorageMetadataStore(null);
await store.createSession({
id: 'sess-1',
file_id: 'f-1',
key: 'user/f-1.bin',
filename: 'f.bin',
mime_type: 'application/octet-stream',
total_size: 10,
chunk_size: 5,
total_chunks: 2,
resume_token: 'the-real-token',
status: 'in_progress',
} as any);
const routes = mount(await tmpAdapter(), store);
return drive(routes, 'PUT', `${BASE}/upload/chunked/:uploadId/chunk/:chunkIndex`, {
params: { uploadId: 'sess-1', chunkIndex: '0' },
headers: { 'x-resume-token': 'wrong' },
});
},
},
{
name: 'anonymous upload when a session resolver is wired',
status: 401,
code: 'AUTH_REQUIRED',
run: async () => {
const routes = mount(await tmpAdapter(), new StorageMetadataStore(null), {
resolveSession: async () => null,
});
return drive(routes, 'POST', `${BASE}/upload/presigned`, { body: {} });
},
},
{
name: 'denied download of an attachments-scope file',
status: 403,
code: 'ATTACHMENT_DOWNLOAD_DENIED',
run: async () => {
const store = new StorageMetadataStore(null);
await committedAttachment(store, 'a1');
const routes = mount(await tmpAdapter(), store, { authorizeFileRead: async () => 'deny' });
return drive(routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: 'a1' } });
},
},
{
name: 'denied download of a field-owned file',
status: 403,
code: 'FILE_DOWNLOAD_DENIED',
run: async () => {
const store = new StorageMetadataStore(null);
await committedAttachment(store, 'fo1', {
scope: 'user',
key: 'user/fo1.png',
ref_object: 'product',
ref_id: 'p1',
});
const routes = mount(await tmpAdapter(), store, { authorizeFileRead: async () => 'deny' });
return drive(routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: 'fo1' } });
},
},
{
name: 'unauthenticated download of an attachments-scope file',
status: 401,
code: 'AUTH_REQUIRED',
run: async () => {
const store = new StorageMetadataStore(null);
await committedAttachment(store, 'a2');
const routes = mount(await tmpAdapter(), store, {
authorizeFileRead: async () => 'unauthenticated',
});
return drive(routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: 'a2' } });
},
},
{
name: 'raw upload against an adapter with no token support',
status: 501,
code: 'NOT_IMPLEMENTED',
run: async () => {
const routes = mount({} as any, new StorageMetadataStore(null));
return drive(routes, 'PUT', `${BASE}/_local/raw/:token`, { params: { token: 'x' } });
},
},
{
// #3641: these two used to answer 200/302 with
// `${basePath}/_local/file/<key>` — a URL no registrar mounts — so a
// caller followed a link straight into a 404. An adapter that can
// produce no download URL now says so.
name: 'download URL from an adapter with neither presigned nor signed URLs',
status: 501,
code: 'NOT_IMPLEMENTED',
run: async () => {
const store = new StorageMetadataStore(null);
await committedAttachment(store, 'nocap', { scope: 'user', key: 'user/nocap.bin' });
const routes = mount({} as any, store);
return drive(routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: 'nocap' } });
},
},
{
name: 'the redirect twin of the same adapter limitation',
status: 501,
code: 'NOT_IMPLEMENTED',
run: async () => {
const store = new StorageMetadataStore(null);
await committedAttachment(store, 'nocap2', { scope: 'user', key: 'user/nocap2.bin' });
const routes = mount({} as any, store);
return drive(routes, 'GET', `${BASE}/files/:fileId`, { params: { fileId: 'nocap2' } });
},
},
{
name: 'raw download with a forged token signature',
status: 403,
code: 'INVALID_TOKEN',
run: async () => {
const routes = mount(await tmpAdapter(), new StorageMetadataStore(null));
return drive(routes, 'GET', `${BASE}/_local/raw/:token`, { params: { token: 'YWJj.ZGVm' } });
},
},
{
name: 'an unexpected throw from the metadata store',
status: 500,
code: 'INTERNAL',
run: async () => {
const routes = mount(await tmpAdapter(), explodingStore);
return drive(routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: 'whatever' } });
},
},
];
for (const c of CASES) {
it(`${c.name} → ${c.status} ${c.code}, in the declared envelope`, async () => {
const { status, body } = await c.run();
expect(status).toBe(c.status);
// The contract itself, imported — not a restatement of it.
const parsed = BaseResponseSchema.safeParse(body);
expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true);
expect(body.success).toBe(false);
expect(body.error.code).toBe(c.code);
expect(typeof body.error.message).toBe('string');
expect(body.error.message.length).toBeGreaterThan(0);
// The pre-#3675 shapes, explicitly dead: `error` is no longer a bare
// string, and the code is no longer a SIBLING of `error`.
expect(typeof body.error).not.toBe('string');
expect(body.code).toBeUndefined();
});
}
});