@filelayer/core 0.3.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 +338 -0
- package/LICENSE +202 -0
- package/MIGRATIONS.md +328 -0
- package/NOTICE +37 -0
- package/README.md +343 -0
- package/SEMANTICS.md +729 -0
- package/dist/authz.d.ts +524 -0
- package/dist/authz.d.ts.map +1 -0
- package/dist/authz.js +889 -0
- package/dist/authz.js.map +1 -0
- package/dist/db.d.ts +145 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +217 -0
- package/dist/db.js.map +1 -0
- package/dist/delivery.d.ts +293 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +519 -0
- package/dist/delivery.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +21 -0
- package/dist/errors.js.map +1 -0
- package/dist/filelayer.d.ts +542 -0
- package/dist/filelayer.d.ts.map +1 -0
- package/dist/filelayer.js +1360 -0
- package/dist/filelayer.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/simple.d.ts +297 -0
- package/dist/simple.d.ts.map +1 -0
- package/dist/simple.js +492 -0
- package/dist/simple.js.map +1 -0
- package/dist/storage.d.ts +269 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +700 -0
- package/dist/storage.js.map +1 -0
- package/dist/store.d.ts +432 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +862 -0
- package/dist/store.js.map +1 -0
- package/package.json +77 -0
- package/schema.sql +1190 -0
- package/src/authz.ts +1398 -0
- package/src/db.ts +271 -0
- package/src/delivery.ts +737 -0
- package/src/errors.ts +24 -0
- package/src/filelayer.ts +1836 -0
- package/src/index.ts +7 -0
- package/src/simple.ts +666 -0
- package/src/storage.ts +917 -0
- package/src/store.ts +1072 -0
- package/test/delivery.test.ts +0 -0
- package/test/group-subjects.test.ts +1072 -0
- package/test/helpers.ts +65 -0
- package/test/listing.test.ts +689 -0
- package/test/local-s3.d.mts +33 -0
- package/test/local-s3.mjs +400 -0
- package/test/persistence.test.ts +953 -0
- package/test/regression.test.ts +619 -0
- package/test/s3-live.test.ts +322 -0
- package/test/security.test.ts +1652 -0
- package/test/semantics.test.ts +888 -0
- package/test/storage.test.ts +437 -0
- package/test/tiers.test.ts +432 -0
- package/test/vault-example.test.ts +302 -0
- package/tsconfig.build.json +29 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PROGRESSIVE DISCLOSURE -- the property suite for the tiered API.
|
|
3
|
+
*
|
|
4
|
+
* Two things are being tested here and they matter in this order:
|
|
5
|
+
*
|
|
6
|
+
* 1. Each tier WORKS. The examples in `examples/tier{1,2,3}-*` are imported
|
|
7
|
+
* and run, not transcribed. If an example stops working, this fails.
|
|
8
|
+
*
|
|
9
|
+
* 2. Each tier is still SAFE. The whole risk of a "simple mode" is that the
|
|
10
|
+
* ergonomics quietly buy their simplicity by turning a property off. Every
|
|
11
|
+
* security property the full API sells is re-asserted at the tier that
|
|
12
|
+
* first exposes it, against the tiered surface rather than the full one --
|
|
13
|
+
* because a property that holds only when you use the verbose API is a
|
|
14
|
+
* property we do not have.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { test, describe } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { createServer } from 'node:http';
|
|
20
|
+
import type { AddressInfo } from 'node:net';
|
|
21
|
+
|
|
22
|
+
import { Filelayer } from '../src/filelayer.ts';
|
|
23
|
+
import { DEFAULT_WORKSPACE, SYSTEM_ACTOR, sniffContentType } from '../src/simple.ts';
|
|
24
|
+
import { fileDownloadRoute } from '../src/delivery.ts';
|
|
25
|
+
import { rejects } from './helpers.ts';
|
|
26
|
+
|
|
27
|
+
import { tier1, tier1Server } from '../../../examples/tier1-avatar/app.ts';
|
|
28
|
+
import { tier2, tier2App } from '../../../examples/tier2-user-files/app.ts';
|
|
29
|
+
import { tier3 } from '../../../examples/tier3-org-roles/app.ts';
|
|
30
|
+
|
|
31
|
+
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
|
|
32
|
+
const utf8 = (s: string) => new TextEncoder().encode(s);
|
|
33
|
+
|
|
34
|
+
async function listen(server: ReturnType<typeof createServer>): Promise<string> {
|
|
35
|
+
await new Promise<void>((r) => server.listen(0, r));
|
|
36
|
+
return `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// TIER 1 -- public avatar
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
describe('TIER 1: a public avatar costs one call and no concepts', () => {
|
|
44
|
+
test('example runs: put -> URL -> anonymous GET returns the bytes', async () => {
|
|
45
|
+
const { fl, url } = await tier1(PNG);
|
|
46
|
+
const server = tier1Server(fl);
|
|
47
|
+
const base = await listen(server);
|
|
48
|
+
try {
|
|
49
|
+
assert.match(url, /^http:\/\/localhost:3000\/f\/[0-9a-f-]{36}$/);
|
|
50
|
+
const res = await fetch(`${base}${new URL(url).pathname}`);
|
|
51
|
+
assert.equal(res.status, 200);
|
|
52
|
+
assert.deepEqual(new Uint8Array(await res.arrayBuffer()), PNG);
|
|
53
|
+
} finally {
|
|
54
|
+
server.close();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('P1 SURVIVES: `public: true` is a grant row, not a boolean column', async () => {
|
|
59
|
+
const fl = await Filelayer.quickstart();
|
|
60
|
+
const { id } = await fl.files.put(PNG, { public: true });
|
|
61
|
+
|
|
62
|
+
// There is still no public/visibility-public column anywhere.
|
|
63
|
+
const cols = await fl.store.db.query<{ column_name: string }>(
|
|
64
|
+
`SELECT column_name FROM information_schema.columns WHERE table_name = 'file'`,
|
|
65
|
+
);
|
|
66
|
+
const names = cols.rows.map((r) => r.column_name);
|
|
67
|
+
assert.ok(!names.includes('public'), 'a `public` column has appeared on `file`');
|
|
68
|
+
assert.ok(!names.includes('is_public'));
|
|
69
|
+
|
|
70
|
+
// What exists instead is one explicit, listable anonymous grant.
|
|
71
|
+
const grants = await fl.store.db.query<{ subject_type: string; capabilities: string }>(
|
|
72
|
+
`SELECT subject_type, capabilities::text FROM file_grant WHERE file_id = $1`,
|
|
73
|
+
[id],
|
|
74
|
+
);
|
|
75
|
+
assert.equal(grants.rows.length, 1);
|
|
76
|
+
assert.equal(grants.rows[0]!.subject_type, 'anonymous');
|
|
77
|
+
assert.equal(grants.rows[0]!.capabilities, '{read}');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('P4 SURVIVES: unpublish() kills a URL already in the wild', async () => {
|
|
81
|
+
const fl = await Filelayer.quickstart();
|
|
82
|
+
const server = tier1Server(fl);
|
|
83
|
+
const base = await listen(server);
|
|
84
|
+
try {
|
|
85
|
+
const { id } = await fl.files.put(PNG, { public: true });
|
|
86
|
+
assert.equal((await fetch(`${base}/f/${id}`)).status, 200);
|
|
87
|
+
|
|
88
|
+
const { revoked } = await fl.files.unpublish(id);
|
|
89
|
+
assert.equal(revoked, 1);
|
|
90
|
+
|
|
91
|
+
// Same URL. Same process. No deletion, no cache purge, no key rotation.
|
|
92
|
+
assert.equal((await fetch(`${base}/f/${id}`)).status, 404);
|
|
93
|
+
|
|
94
|
+
// ...and the bytes are still there for anyone who still has authority.
|
|
95
|
+
const still = await fl.files.get(id, { as: SYSTEM_ACTOR });
|
|
96
|
+
assert.deepEqual(still.body, PNG);
|
|
97
|
+
} finally {
|
|
98
|
+
server.close();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('P1 SURVIVES: a NON-public file is not reachable from the public route', async () => {
|
|
103
|
+
const fl = await Filelayer.quickstart();
|
|
104
|
+
const server = tier1Server(fl);
|
|
105
|
+
const base = await listen(server);
|
|
106
|
+
try {
|
|
107
|
+
const { id } = await fl.files.put(utf8('secret'), {}); // note: no `public`
|
|
108
|
+
const res = await fetch(`${base}/f/${id}`);
|
|
109
|
+
assert.equal(res.status, 404);
|
|
110
|
+
// 404, not 403: the public route is not an existence oracle for private files.
|
|
111
|
+
assert.deepEqual(await res.json(), { error: 'not_found' });
|
|
112
|
+
} finally {
|
|
113
|
+
server.close();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('the default workspace is a real org, not a NULL one', async () => {
|
|
118
|
+
const fl = await Filelayer.quickstart();
|
|
119
|
+
const { id } = await fl.files.put(PNG, { public: true });
|
|
120
|
+
const { rows } = await fl.store.db.query<{ org_id: string | null; external_id: string }>(
|
|
121
|
+
`SELECT f.org_id, o.external_id FROM file f JOIN org o ON o.id = f.org_id WHERE f.id = $1`,
|
|
122
|
+
[id],
|
|
123
|
+
);
|
|
124
|
+
assert.equal(rows.length, 1, 'the file must be joined to a real org row');
|
|
125
|
+
assert.notEqual(rows[0]!.org_id, null);
|
|
126
|
+
assert.equal(rows[0]!.external_id, DEFAULT_WORKSPACE);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('active content is served inert even when uploaded as a "public avatar"', async () => {
|
|
130
|
+
const fl = await Filelayer.quickstart();
|
|
131
|
+
const server = tier1Server(fl);
|
|
132
|
+
const base = await listen(server);
|
|
133
|
+
try {
|
|
134
|
+
const { id } = await fl.files.put(utf8('<script>alert(document.cookie)</script>'), {
|
|
135
|
+
public: true,
|
|
136
|
+
name: 'x.svg',
|
|
137
|
+
contentType: 'image/svg+xml',
|
|
138
|
+
});
|
|
139
|
+
const res = await fetch(`${base}/f/${id}`);
|
|
140
|
+
assert.equal(res.status, 200);
|
|
141
|
+
// Requested `inline`, but an active type is forced back to attachment and
|
|
142
|
+
// its content type is neutralised.
|
|
143
|
+
assert.equal(res.headers.get('content-type'), 'application/octet-stream');
|
|
144
|
+
assert.match(res.headers.get('content-disposition') ?? '', /^attachment/);
|
|
145
|
+
assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
|
|
146
|
+
} finally {
|
|
147
|
+
server.close();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('sniffing is conservative: unknown bytes are octet-stream, never guessed active', () => {
|
|
152
|
+
assert.equal(sniffContentType(PNG), 'image/png');
|
|
153
|
+
assert.equal(sniffContentType(new Uint8Array([0xff, 0xd8, 0xff, 0xe0])), 'image/jpeg');
|
|
154
|
+
assert.equal(sniffContentType(utf8('%PDF-1.7')), 'application/pdf');
|
|
155
|
+
assert.equal(sniffContentType(utf8('<html><script>')), 'application/octet-stream');
|
|
156
|
+
assert.equal(sniffContentType(utf8('<svg onload=alert(1)>')), 'application/octet-stream');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('publishing is audited, so "who made this public?" has an answer', async () => {
|
|
160
|
+
const fl = await Filelayer.quickstart();
|
|
161
|
+
const { id } = await fl.files.put(PNG, { public: true });
|
|
162
|
+
const { rows } = await fl.store.db.query<{ action: string; context: unknown }>(
|
|
163
|
+
`SELECT action, context::text FROM audit_event WHERE file_id = $1 ORDER BY id`,
|
|
164
|
+
[id],
|
|
165
|
+
);
|
|
166
|
+
const actions = rows.map((r) => r.action);
|
|
167
|
+
assert.ok(actions.includes('file.create'));
|
|
168
|
+
assert.ok(actions.includes('grant.create'), 'making a file public must be an audited event');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// =============================================================================
|
|
173
|
+
// TIER 2 -- user-owned private files
|
|
174
|
+
// =============================================================================
|
|
175
|
+
|
|
176
|
+
describe('TIER 2: an owner is the only new concept', () => {
|
|
177
|
+
test('example runs: alice can read her own file', async () => {
|
|
178
|
+
const fl = await Filelayer.quickstart();
|
|
179
|
+
const { id } = await tier2(fl);
|
|
180
|
+
const mine = await fl.files.get(id, { as: 'alice' });
|
|
181
|
+
assert.equal(new TextDecoder().decode(mine.body), 'alice private notes');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('P1 SURVIVES: bob cannot read alice`s file, and gets 404 not 403', async () => {
|
|
185
|
+
const fl = await Filelayer.quickstart();
|
|
186
|
+
const { id } = await tier2(fl);
|
|
187
|
+
await fl.files.put(utf8('bob file'), { owner: 'bob' }); // bob is a real user
|
|
188
|
+
await rejects(() => fl.files.get(id, { as: 'bob' }), 404);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('P2 SURVIVES: an anonymous caller holding the id gets nothing', async () => {
|
|
192
|
+
const fl = await Filelayer.quickstart();
|
|
193
|
+
const { id } = await tier2(fl);
|
|
194
|
+
await rejects(() => fl.files.get(id), 404);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('P2 SURVIVES: an UNKNOWN `as:` is denied, never demoted to anonymous', async () => {
|
|
198
|
+
// This is the single most dangerous shortcut available in a "simple mode":
|
|
199
|
+
// resolve the user, and if you cannot, just carry on as nobody. It would
|
|
200
|
+
// turn a typo in a user id into a silent read of every public file and an
|
|
201
|
+
// audit trail attributed to no one.
|
|
202
|
+
const fl = await Filelayer.quickstart();
|
|
203
|
+
const { id } = await fl.files.put(PNG, { public: true });
|
|
204
|
+
// The file IS anonymously readable...
|
|
205
|
+
assert.ok(await fl.files.get(id));
|
|
206
|
+
// ...but a bogus identity is still an error, not a fallback.
|
|
207
|
+
await rejects(() => fl.files.get(id, { as: 'nobody-by-that-name' }), 404);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('auto-provisioning grants an identity, never a permission', async () => {
|
|
211
|
+
const fl = await Filelayer.quickstart();
|
|
212
|
+
await fl.files.put(utf8('a'), { owner: 'alice' });
|
|
213
|
+
const { rows } = await fl.store.db.query<{ role: string }>(
|
|
214
|
+
`SELECT m.role FROM membership m
|
|
215
|
+
JOIN actor a ON a.id = m.actor_id
|
|
216
|
+
WHERE a.external_id = 'alice'`,
|
|
217
|
+
);
|
|
218
|
+
assert.deepEqual(rows.map((r) => r.role), ['member']);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('auto-provisioning cannot DEMOTE an identity a developer promoted', async () => {
|
|
222
|
+
const fl = await Filelayer.quickstart();
|
|
223
|
+
await fl.orgs.create('acme', { owner: 'root' });
|
|
224
|
+
await fl.orgs.setRole('acme', 'alice', 'admin', { as: 'root' });
|
|
225
|
+
// An ordinary upload runs the same ensure-membership path.
|
|
226
|
+
await fl.files.put(utf8('a'), { org: 'acme', owner: 'alice' });
|
|
227
|
+
const { rows } = await fl.store.db.query<{ role: string }>(
|
|
228
|
+
`SELECT m.role FROM membership m
|
|
229
|
+
JOIN actor a ON a.id = m.actor_id
|
|
230
|
+
JOIN org o ON o.id = m.org_id
|
|
231
|
+
WHERE a.external_id = 'alice' AND o.external_id = 'acme'`,
|
|
232
|
+
);
|
|
233
|
+
assert.deepEqual(rows.map((r) => r.role), ['admin']);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('the tier-2 example app returns 404 for the wrong user over real HTTP', async () => {
|
|
237
|
+
const fl = await Filelayer.quickstart();
|
|
238
|
+
const { id } = await tier2(fl);
|
|
239
|
+
await fl.files.put(utf8('b'), { owner: 'bob' });
|
|
240
|
+
const server = tier2App(fl);
|
|
241
|
+
const base = await listen(server);
|
|
242
|
+
try {
|
|
243
|
+
assert.equal((await fetch(`${base}/files/${id}`, { headers: { 'x-user-id': 'alice' } })).status, 200);
|
|
244
|
+
assert.equal((await fetch(`${base}/files/${id}`, { headers: { 'x-user-id': 'bob' } })).status, 404);
|
|
245
|
+
assert.equal((await fetch(`${base}/files/${id}`)).status, 404);
|
|
246
|
+
} finally {
|
|
247
|
+
server.close();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// =============================================================================
|
|
253
|
+
// TIER 3 -- orgs and roles
|
|
254
|
+
// =============================================================================
|
|
255
|
+
|
|
256
|
+
describe('TIER 3: adding `org:` is an option, not a migration', () => {
|
|
257
|
+
test('example runs: role matrix behaves as the full API does', async () => {
|
|
258
|
+
const fl = await Filelayer.quickstart();
|
|
259
|
+
const { privateId, sharedId } = await tier3(fl);
|
|
260
|
+
|
|
261
|
+
// private: owner yes, everyone else no
|
|
262
|
+
assert.ok(await fl.files.get(privateId, { as: 'ceo' }));
|
|
263
|
+
await rejects(() => fl.files.get(privateId, { as: 'analyst' }), 404);
|
|
264
|
+
await rejects(() => fl.files.get(privateId, { as: 'auditor' }), 404);
|
|
265
|
+
|
|
266
|
+
// visibility 'org': every member, including a viewer
|
|
267
|
+
assert.ok(await fl.files.get(sharedId, { as: 'analyst' }));
|
|
268
|
+
assert.ok(await fl.files.get(sharedId, { as: 'auditor' }));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('P3 SURVIVES: a file in acme is unreachable from another tenant', async () => {
|
|
272
|
+
const fl = await Filelayer.quickstart();
|
|
273
|
+
await tier3(fl);
|
|
274
|
+
await fl.orgs.create('globex', { owner: 'rival' });
|
|
275
|
+
const { privateId } = await tier3Ids(fl);
|
|
276
|
+
await rejects(() => fl.files.get(privateId, { as: 'rival' }), 404);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('P3 SURVIVES: every tiered file still carries a non-null org_id', async () => {
|
|
280
|
+
const fl = await Filelayer.quickstart();
|
|
281
|
+
await fl.files.put(PNG, { public: true }); // tier 1
|
|
282
|
+
await fl.files.put(utf8('a'), { owner: 'alice' }); // tier 2
|
|
283
|
+
await tier3(fl); // tier 3
|
|
284
|
+
const { rows } = await fl.store.db.query<{ n: number }>(
|
|
285
|
+
`SELECT count(*)::int AS n FROM file WHERE org_id IS NULL`,
|
|
286
|
+
);
|
|
287
|
+
assert.equal(Number(rows[0]!.n), 0);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('membership changes are NOT simplified: `as` is required and authorized', async () => {
|
|
291
|
+
const fl = await Filelayer.quickstart();
|
|
292
|
+
await fl.orgs.create('acme', { owner: 'ceo' });
|
|
293
|
+
await fl.orgs.setRole('acme', 'mallory', 'viewer', { as: 'ceo' });
|
|
294
|
+
// A viewer cannot promote themselves, through the tiered API either.
|
|
295
|
+
await rejects(() => fl.orgs.setRole('acme', 'mallory', 'owner', { as: 'mallory' }), 404);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('a tier-1 public put into a NAMED org does not make us an admin of it', async () => {
|
|
299
|
+
// The service identity is `owner` of the default workspace but only
|
|
300
|
+
// `member` of a customer tenant, so an unowned upload cannot be a back door
|
|
301
|
+
// into that tenant's private documents.
|
|
302
|
+
const fl = await Filelayer.quickstart();
|
|
303
|
+
await fl.orgs.create('acme', { owner: 'ceo' });
|
|
304
|
+
const { id: secret } = await fl.files.put(utf8('board deck'), { org: 'acme', owner: 'ceo' });
|
|
305
|
+
await fl.files.put(PNG, { org: 'acme', public: true }); // unowned -> system actor
|
|
306
|
+
await rejects(() => fl.files.get(secret, { as: SYSTEM_ACTOR }), 404);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// =============================================================================
|
|
311
|
+
// TIER 4 -- sharing, expiry, caps, audit, through the tiered surface
|
|
312
|
+
// =============================================================================
|
|
313
|
+
|
|
314
|
+
describe('TIER 4: the full Vault capabilities, reached by adding options', () => {
|
|
315
|
+
test('share link with expiry, password and cap; revocation beats the URL', async () => {
|
|
316
|
+
const fl = await Filelayer.quickstart({ baseUrl: 'https://vault.test' });
|
|
317
|
+
await fl.orgs.create('acme', { owner: 'ceo' });
|
|
318
|
+
const { id } = await fl.files.put(utf8('contract'), { org: 'acme', owner: 'ceo' });
|
|
319
|
+
|
|
320
|
+
const share = await fl.shares.create(id, {
|
|
321
|
+
as: 'ceo',
|
|
322
|
+
expiresIn: 3600,
|
|
323
|
+
maxDownloads: 2,
|
|
324
|
+
password: 'hunter2',
|
|
325
|
+
});
|
|
326
|
+
assert.ok(share.secret);
|
|
327
|
+
assert.equal(share.maxDownloads, 2);
|
|
328
|
+
|
|
329
|
+
// wrong password
|
|
330
|
+
await rejects(() => fl.shares.redeem(share.secret!, { password: 'wrong' }), 401);
|
|
331
|
+
// right password, twice, then the cap bites
|
|
332
|
+
assert.ok(await fl.shares.redeem(share.secret!, { password: 'hunter2' }));
|
|
333
|
+
assert.ok(await fl.shares.redeem(share.secret!, { password: 'hunter2' }));
|
|
334
|
+
await rejects(() => fl.shares.redeem(share.secret!, { password: 'hunter2' }), 404);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('revocation through the tiered API is immediate', async () => {
|
|
338
|
+
const fl = await Filelayer.quickstart({ baseUrl: 'https://vault.test' });
|
|
339
|
+
await fl.orgs.create('acme', { owner: 'ceo' });
|
|
340
|
+
const { id } = await fl.files.put(utf8('contract'), { org: 'acme', owner: 'ceo' });
|
|
341
|
+
const share = await fl.shares.create(id, { as: 'ceo' });
|
|
342
|
+
assert.ok(await fl.shares.redeem(share.secret!));
|
|
343
|
+
await fl.shares.revoke(share.grantId, { as: 'ceo' });
|
|
344
|
+
await rejects(() => fl.shares.redeem(share.secret!), 404);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('P5 SURVIVES: the audit trail of a tiered app is complete and verifiable', async () => {
|
|
348
|
+
const fl = await Filelayer.quickstart();
|
|
349
|
+
await fl.orgs.create('acme', { owner: 'ceo' });
|
|
350
|
+
const { id } = await fl.files.put(utf8('x'), { org: 'acme', owner: 'ceo' });
|
|
351
|
+
await fl.files.get(id, { as: 'ceo' });
|
|
352
|
+
await fl.orgs.setRole('acme', 'temp', 'viewer', { as: 'ceo' });
|
|
353
|
+
await rejects(() => fl.files.get(id, { as: 'temp' }), 404); // a denial
|
|
354
|
+
|
|
355
|
+
const log = await fl.orgs.audit('acme', { as: 'ceo' });
|
|
356
|
+
const actions = log.map((e) => e.action);
|
|
357
|
+
assert.ok(actions.includes('file.create'));
|
|
358
|
+
assert.ok(actions.includes('member.add'));
|
|
359
|
+
assert.ok(log.some((e) => e.decision === 'deny'), 'denials must be recorded');
|
|
360
|
+
|
|
361
|
+
const chain = await fl.orgs.verifyAudit('acme', { as: 'ceo' });
|
|
362
|
+
assert.equal(chain.valid, true);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('audit is not readable by a non-admin, through the tiered API either', async () => {
|
|
366
|
+
const fl = await Filelayer.quickstart();
|
|
367
|
+
await fl.orgs.create('acme', { owner: 'ceo' });
|
|
368
|
+
await fl.orgs.setRole('acme', 'temp', 'member', { as: 'ceo' });
|
|
369
|
+
await rejects(() => fl.orgs.audit('acme', { as: 'temp' }), 404);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// =============================================================================
|
|
374
|
+
// CROSS-TIER -- the claim that tiers COMPOSE rather than fork
|
|
375
|
+
// =============================================================================
|
|
376
|
+
|
|
377
|
+
describe('the tiers are one model, not four', () => {
|
|
378
|
+
test('a tier-1 file can be promoted to tier 4 without moving it', async () => {
|
|
379
|
+
// The architectural claim under test: nothing about starting simple has to
|
|
380
|
+
// be undone in order to get the advanced capabilities. Same row, same id,
|
|
381
|
+
// same URL space.
|
|
382
|
+
const fl = await Filelayer.quickstart({ baseUrl: 'https://x.test' });
|
|
383
|
+
const { id } = await fl.files.put(utf8('doc'), { public: true }); // tier 1
|
|
384
|
+
|
|
385
|
+
await fl.files.unpublish(id); // withdraw public access
|
|
386
|
+
await rejects(() => fl.files.get(id), 404);
|
|
387
|
+
|
|
388
|
+
// Now hand it to a named user, with an expiring capped link. Same file id.
|
|
389
|
+
const share = await fl.shares.create(id, { as: SYSTEM_ACTOR, maxDownloads: 1 });
|
|
390
|
+
const got = await fl.shares.redeem(share.secret!);
|
|
391
|
+
assert.equal(new TextDecoder().decode(got.body), 'doc');
|
|
392
|
+
await rejects(() => fl.shares.redeem(share.secret!), 404);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test('the full API and the tiered API are the same objects, not parallel worlds', async () => {
|
|
396
|
+
const fl = await Filelayer.quickstart();
|
|
397
|
+
const { id } = await fl.files.put(utf8('doc'), { owner: 'alice' });
|
|
398
|
+
|
|
399
|
+
// Reached through the tiered surface, inspected through the full one.
|
|
400
|
+
const { rows } = await fl.store.db.query<{ external_id: string }>(
|
|
401
|
+
`SELECT a.external_id FROM file f JOIN actor a ON a.id = f.owner_id WHERE f.id = $1`,
|
|
402
|
+
[id],
|
|
403
|
+
);
|
|
404
|
+
assert.equal(rows[0]!.external_id, 'alice');
|
|
405
|
+
|
|
406
|
+
// `getFileRecord` used to be reachable from here without a principal; it is
|
|
407
|
+
// private now, so the full-API inspection goes through the authorized
|
|
408
|
+
// `stat()` and must name who is asking.
|
|
409
|
+
const record = await fl.files.stat(id, { as: 'alice' });
|
|
410
|
+
assert.equal(record.visibility, 'private');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test('metering: distinct file-owning users are actually recorded', async () => {
|
|
414
|
+
const fl = await Filelayer.quickstart();
|
|
415
|
+
await fl.files.put(utf8('a'), { owner: 'alice' });
|
|
416
|
+
await fl.files.put(utf8('b'), { owner: 'alice' }); // same user, same day
|
|
417
|
+
await fl.files.put(utf8('c'), { owner: 'bob' });
|
|
418
|
+
const { rows } = await fl.store.db.query<{ n: number }>(
|
|
419
|
+
`SELECT count(*)::int AS n FROM file_owning_user_daily`,
|
|
420
|
+
);
|
|
421
|
+
assert.equal(Number(rows[0]!.n), 2, 'distinct owners per org per day');
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
/** Re-run tier3 against an already-seeded world without re-creating the org. */
|
|
426
|
+
async function tier3Ids(fl: Filelayer) {
|
|
427
|
+
const { rows } = await fl.store.db.query<{ id: string; visibility: string }>(
|
|
428
|
+
`SELECT f.id, f.visibility FROM file f JOIN org o ON o.id = f.org_id
|
|
429
|
+
WHERE o.external_id = 'acme' AND f.visibility = 'private' ORDER BY f.created_at LIMIT 1`,
|
|
430
|
+
);
|
|
431
|
+
return { privateId: rows[0]!.id };
|
|
432
|
+
}
|