kempo-media 0.2.3
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/.env.example +1 -0
- package/.gitattributes +5 -0
- package/.github/workflows/publish.yml +85 -0
- package/.github/workflows/test.yml +123 -0
- package/CHANGELOG.md +25 -0
- package/README.md +114 -0
- package/admin/asset-picker.global.html +63 -0
- package/admin/components/MediaLibrary.js +304 -0
- package/admin/icons/archive.svg +1 -0
- package/admin/icons/audio.svg +1 -0
- package/admin/icons/image.svg +1 -0
- package/admin/icons/model3d.svg +1 -0
- package/admin/icons/video.svg +1 -0
- package/admin/index.page.html +35 -0
- package/admin/nav.global.html +3 -0
- package/docker-compose.yml +22 -0
- package/drizzle.config.js +26 -0
- package/install.js +18 -0
- package/kempo-config.json +18 -0
- package/package.json +39 -0
- package/public/api/media/DELETE.js +21 -0
- package/public/api/media/GET.js +38 -0
- package/public/api/media/PATCH.js +18 -0
- package/public/api/media/POST.js +57 -0
- package/public/sdk.js +62 -0
- package/scripts/build.js +0 -0
- package/sdk.js +15 -0
- package/server/db/schema.js +18 -0
- package/server/utils/media/createMediaAsset.js +19 -0
- package/server/utils/media/deleteMediaAsset.js +37 -0
- package/server/utils/media/getMediaAsset.js +14 -0
- package/server/utils/media/getMediaAssets.js +39 -0
- package/server/utils/media/updateMediaAsset.js +32 -0
- package/server/utils/uploads/fileTypes.js +50 -0
- package/server/utils/uploads/generateThumbnail.js +63 -0
- package/server/utils/uploads/parseMultipart.js +99 -0
- package/server/utils/uploads/storeUpload.js +89 -0
- package/tests/browser-fixtures.node-test.js +61 -0
- package/tests/browser-modules-parse.node-test.js +62 -0
- package/tests/components/MediaLibrary.browser-test.js +412 -0
- package/tests/fixtures/kempo-media-sdk-mock/sdk.js +16 -0
- package/tests/media-api-contract.node-test.js +363 -0
- package/tests/media-data-layer.node-test.js +268 -0
- package/tests/module-graph.node-test.js +86 -0
- package/tests/package-invariants.node-test.js +247 -0
- package/tests/permission-name-consistency.node-test.js +102 -0
- package/tests/uploads.node-test.js +255 -0
- package/update.js +3 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { spawn, execFileSync } from 'child_process';
|
|
2
|
+
import { writeFile, mkdir, rm, readFile, stat } from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
5
|
+
import { sql, eq } from 'drizzle-orm';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
Drives a real kempo-server instance, with this extension actually installed, over HTTP — the
|
|
9
|
+
thing tests/uploads.node-test.js does not cover, since it calls storeUpload/parseMultipart
|
|
10
|
+
directly and never touches public/api/media/*.js. That gap was not theoretical: a 401-vs-403 bug
|
|
11
|
+
in these exact routes (a bad session cookie answering 403 instead of 401) was only ever caught by
|
|
12
|
+
hand-curling the running demo, not by any automated test — this suite exists so that class of bug
|
|
13
|
+
fails a `npm test` run instead.
|
|
14
|
+
|
|
15
|
+
Requires a reachable Postgres with kempo's core schema and this extension's own applied (see
|
|
16
|
+
drizzle.config.js). Skips itself with a clear message when there is none, so a checkout without
|
|
17
|
+
one still runs `npm test` cleanly.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const KEMPO_DIR = path.join(root, 'node_modules', 'kempo');
|
|
22
|
+
|
|
23
|
+
const db = (await import(pathToFileURL(path.join(KEMPO_DIR, 'server/db/index.js')).href)).default;
|
|
24
|
+
const {
|
|
25
|
+
user, userGroup, session, extension: extensionTable, kempoMediaAsset,
|
|
26
|
+
} = await (async () => {
|
|
27
|
+
const kempoSchema = await import(pathToFileURL(path.join(KEMPO_DIR, 'server/db/schema.js')).href);
|
|
28
|
+
const ownSchema = await import(pathToFileURL(path.join(root, 'server/db/schema.js')).href);
|
|
29
|
+
return { ...kempoSchema, extension: kempoSchema.extension, ...ownSchema };
|
|
30
|
+
})();
|
|
31
|
+
|
|
32
|
+
const { createUser, addUserToGroup, installExtension, uninstallExtension } =
|
|
33
|
+
await import(pathToFileURL(path.join(KEMPO_DIR, 'server/sdk.js')).href);
|
|
34
|
+
|
|
35
|
+
const databaseReachable = await db.execute(sql`select 1`).then(() => true).catch(() => false);
|
|
36
|
+
|
|
37
|
+
const ADMIN = { name: 'Media Contract Admin', email: 'media-contract-admin@test.local', password: 'MediaContractAdmin123!' };
|
|
38
|
+
const MEMBER = { name: 'Media Contract Member', email: 'media-contract-member@test.local', password: 'MediaContractMember123!' };
|
|
39
|
+
// Member of kempo-media:editor — the group this extension actually declares (browse+upload+update,
|
|
40
|
+
// deliberately not delete). Nothing else in this suite exercises that this group's own grants work.
|
|
41
|
+
const EDITOR = { name: 'Media Contract Editor', email: 'media-contract-editor@test.local', password: 'MediaContractEditor123!' };
|
|
42
|
+
|
|
43
|
+
const state = { server: null, port: null, tmp: null, cookies: {}, uploadedId: null };
|
|
44
|
+
|
|
45
|
+
const skipped = reason => ({
|
|
46
|
+
'media api contract (SKIPPED)': async ({ pass }) => pass(`skipped: ${reason}`),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const purgeUsers = async () => {
|
|
50
|
+
for(const email of [ADMIN.email, MEMBER.email, EDITOR.email]){
|
|
51
|
+
const [row] = await db.select().from(user).where(eq(user.email, email));
|
|
52
|
+
if(!row) continue;
|
|
53
|
+
/*
|
|
54
|
+
kempoMediaAsset.uploadedBy has no foreign key to user.id — deleting the user first would
|
|
55
|
+
leave orphaned rows pointing at an id nothing owns. Only the admin fixture uploads in this
|
|
56
|
+
suite, but this runs unconditionally so a future fixture that also uploads is covered too.
|
|
57
|
+
*/
|
|
58
|
+
await db.delete(kempoMediaAsset).where(eq(kempoMediaAsset.uploadedBy, row.id)).catch(() => {});
|
|
59
|
+
await db.delete(session).where(eq(session.userId, row.id)).catch(() => {});
|
|
60
|
+
await db.delete(userGroup).where(eq(userGroup.userId, row.id)).catch(() => {});
|
|
61
|
+
await db.delete(user).where(eq(user.id, row.id)).catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
installExtension resolves the package via <cwd>/node_modules/kempo-media, so this repo needs to
|
|
67
|
+
be able to find itself there — a self-referential junction, exactly like a real site's
|
|
68
|
+
node_modules/kempo-media would point at this exact package. Idempotent: safe to call on a
|
|
69
|
+
checkout that already has it from a previous run.
|
|
70
|
+
*/
|
|
71
|
+
const ensureSelfLink = async () => {
|
|
72
|
+
const link = path.join(root, 'node_modules', 'kempo-media');
|
|
73
|
+
try {
|
|
74
|
+
const stats = await stat(link);
|
|
75
|
+
if(stats.isDirectory()) return; // junction already present and resolving
|
|
76
|
+
} catch { /* fall through and create it */ }
|
|
77
|
+
const { symlink } = await import('fs/promises');
|
|
78
|
+
await rm(link, { recursive: true, force: true }).catch(() => {});
|
|
79
|
+
await symlink(root, link, 'junction');
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/*
|
|
83
|
+
Deliberately never purgeData:true here. That drops the kempoMediaAsset table itself — fine in
|
|
84
|
+
isolation, but this suite shares one database and one test process with
|
|
85
|
+
media-data-layer.node-test.js, and a dropped table under it is not "this file's tests failed",
|
|
86
|
+
it is every other file's tests failing too. The table's lifecycle belongs to the schema push
|
|
87
|
+
(drizzle.config.js), not to a single suite's cleanup. Un-purged uninstall still removes the
|
|
88
|
+
extension's own registration/hooks, which is all re-running install needs to be idempotent.
|
|
89
|
+
*/
|
|
90
|
+
const purgeExtension = async () => {
|
|
91
|
+
await uninstallExtension({ name: 'kempo-media' }).catch(() => {});
|
|
92
|
+
await db.delete(extensionTable).where(eq(extensionTable.name, 'kempo-media')).catch(() => {});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const randomPort = () => 10000 + Math.floor(Math.random() * 20000);
|
|
96
|
+
|
|
97
|
+
const waitForServer = async port => {
|
|
98
|
+
for(let i = 0; i < 100; i++){
|
|
99
|
+
try {
|
|
100
|
+
await fetch(`http://127.0.0.1:${port}/login`);
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
await new Promise(r => setTimeout(r, 200));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const login = async ({ email, password }) => {
|
|
110
|
+
const res = await fetch(`http://127.0.0.1:${state.port}/kempo/api/auth/login/email`, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ email, password }),
|
|
114
|
+
redirect: 'manual',
|
|
115
|
+
});
|
|
116
|
+
const cookie = res.headers.get('set-cookie') || '';
|
|
117
|
+
return cookie.match(/session_token=([^;]+)/)?.[1] || null;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const req = async (method, urlPath, { as, body, headers = {} } = {}) => {
|
|
121
|
+
const finalHeaders = { ...headers };
|
|
122
|
+
if(as && state.cookies[as]) finalHeaders.cookie = `session_token=${state.cookies[as]}`;
|
|
123
|
+
const opts = { method, headers: finalHeaders, redirect: 'manual', signal: AbortSignal.timeout(8000) };
|
|
124
|
+
if(body !== undefined){
|
|
125
|
+
if(body instanceof FormData){
|
|
126
|
+
opts.body = body; // fetch sets its own multipart Content-Type + boundary
|
|
127
|
+
} else {
|
|
128
|
+
finalHeaders['Content-Type'] = 'application/json';
|
|
129
|
+
opts.body = JSON.stringify(body);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const res = await fetch(`http://127.0.0.1:${state.port}${urlPath}`, opts);
|
|
133
|
+
const json = await res.json().catch(() => null);
|
|
134
|
+
return { status: res.status, json };
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
A tiny real PNG (1x1, red), built by hand rather than depending on sharp here — this suite is
|
|
139
|
+
about the HTTP layer, not image encoding, and a minimal-but-valid file is enough to exercise
|
|
140
|
+
storage + thumbnailing through the real route.
|
|
141
|
+
*/
|
|
142
|
+
const PNG_1X1_RED = Buffer.from(
|
|
143
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
|
144
|
+
'base64'
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const buildTests = () => {
|
|
148
|
+
return {
|
|
149
|
+
'a server boots, all fixture accounts can sign in, and kempo-media is installed': async ({ pass, fail }) => {
|
|
150
|
+
await purgeUsers();
|
|
151
|
+
await purgeExtension();
|
|
152
|
+
await ensureSelfLink();
|
|
153
|
+
|
|
154
|
+
/*
|
|
155
|
+
Seeds system:Administrators / system:admin:access. Idempotent, run here rather than as a CI
|
|
156
|
+
step so the suite needs nothing but a database with the schema applied. It calls
|
|
157
|
+
process.exit, hence a subprocess rather than an import.
|
|
158
|
+
*/
|
|
159
|
+
try {
|
|
160
|
+
execFileSync(process.execPath, [path.join(KEMPO_DIR, 'scripts', 'init-db.js')], {
|
|
161
|
+
cwd: root,
|
|
162
|
+
stdio: 'ignore',
|
|
163
|
+
env: { ...process.env },
|
|
164
|
+
});
|
|
165
|
+
} catch(e) {
|
|
166
|
+
return fail(`could not seed system groups and permissions: ${e.message}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const [installError] = await installExtension({ name: 'kempo-media' });
|
|
170
|
+
if(installError) return fail(`could not install kempo-media: ${installError.msg}`);
|
|
171
|
+
|
|
172
|
+
const [adminErr, adminResult] = await createUser({ ...ADMIN, emailVerified: true });
|
|
173
|
+
if(adminErr) return fail(`could not create admin fixture: ${adminErr.msg}`);
|
|
174
|
+
const [groupErr] = await addUserToGroup(adminResult.user.id, 'system:Administrators');
|
|
175
|
+
if(groupErr) return fail(`could not grant admin: ${groupErr.msg}`);
|
|
176
|
+
|
|
177
|
+
const [memberErr] = await createUser({ ...MEMBER, emailVerified: true });
|
|
178
|
+
if(memberErr) return fail(`could not create member fixture: ${memberErr.msg}`);
|
|
179
|
+
|
|
180
|
+
const [editorErr, editorResult] = await createUser({ ...EDITOR, emailVerified: true });
|
|
181
|
+
if(editorErr) return fail(`could not create editor fixture: ${editorErr.msg}`);
|
|
182
|
+
const [editorGroupErr] = await addUserToGroup(editorResult.user.id, 'kempo-media:editor');
|
|
183
|
+
if(editorGroupErr) return fail(`could not grant kempo-media:editor: ${editorGroupErr.msg}`);
|
|
184
|
+
|
|
185
|
+
/*
|
|
186
|
+
state.tmp stands in for a real site's project root: its own public/ and — critically — its
|
|
187
|
+
own node_modules, symlinked rather than installed. This matters because public/api/media/
|
|
188
|
+
POST.js resolves its storage directory as join(process.cwd(), 'public'), the same way every
|
|
189
|
+
route in this codebase does, since in a real deployment cwd is always the site root that
|
|
190
|
+
kempo-server was launched from. Spawning the server with cwd elsewhere (kempo-media's own
|
|
191
|
+
repo root, purely to make node_modules resolve) silently breaks that — uploads succeed and
|
|
192
|
+
report a path, but the bytes land next to this repo's own package.json, not under state.tmp.
|
|
193
|
+
*/
|
|
194
|
+
state.tmp = path.join(root, 'tests', '.tmp-media-contract');
|
|
195
|
+
await mkdir(path.join(state.tmp, 'public', 'media', 'uploads'), { recursive: true });
|
|
196
|
+
await mkdir(path.join(state.tmp, 'public', 'media', 'thumbnails'), { recursive: true });
|
|
197
|
+
await rm(path.join(state.tmp, 'node_modules'), { recursive: true, force: true }).catch(() => {});
|
|
198
|
+
await (await import('fs/promises')).symlink(path.join(root, 'node_modules'), path.join(state.tmp, 'node_modules'), 'junction');
|
|
199
|
+
|
|
200
|
+
// Resolves against rootPath (state.tmp/public/) — one directory up reaches state.tmp/node_modules.
|
|
201
|
+
await writeFile(path.join(state.tmp, 'contract.config.json'), JSON.stringify({
|
|
202
|
+
customRoutes: { '/kempo/**': '../node_modules/kempo/dist/kempo/**' },
|
|
203
|
+
middleware: { custom: ['../node_modules/kempo/middleware/kempo.js'] },
|
|
204
|
+
templating: { ssr: true, ssrPriority: true, preRender: false },
|
|
205
|
+
}, null, 2));
|
|
206
|
+
|
|
207
|
+
state.port = randomPort();
|
|
208
|
+
state.server = spawn(process.execPath, [
|
|
209
|
+
path.join(root, 'node_modules', 'kempo-server', 'dist', 'index.js'),
|
|
210
|
+
'--root', path.join(state.tmp, 'public'),
|
|
211
|
+
'--config', path.join(state.tmp, 'contract.config.json'),
|
|
212
|
+
'--port', String(state.port),
|
|
213
|
+
], { cwd: state.tmp, stdio: 'ignore' });
|
|
214
|
+
|
|
215
|
+
if(!await waitForServer(state.port)) return fail(`server did not start on port ${state.port}`);
|
|
216
|
+
|
|
217
|
+
state.cookies.invalid = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
|
218
|
+
state.cookies.admin = await login(ADMIN);
|
|
219
|
+
state.cookies.member = await login(MEMBER);
|
|
220
|
+
state.cookies.editor = await login(EDITOR);
|
|
221
|
+
if(!state.cookies.admin) return fail('admin could not sign in');
|
|
222
|
+
if(!state.cookies.member) return fail('member could not sign in');
|
|
223
|
+
if(!state.cookies.editor) return fail('editor could not sign in');
|
|
224
|
+
pass();
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
/*
|
|
228
|
+
Each row is a permission boundary someone could quietly move. The two 401 rows and the 403
|
|
229
|
+
row are the exact distinction the bug this suite exists for got wrong: no session and an
|
|
230
|
+
invalid session both mean "authenticate and retry" (401); a valid session lacking the
|
|
231
|
+
permission means retrying will not help (403).
|
|
232
|
+
*/
|
|
233
|
+
'GET /kempo-media/api/media answers the status its audience should see': async ({ pass, fail }) => {
|
|
234
|
+
if(!state.port) return fail('server did not start');
|
|
235
|
+
const cases = [
|
|
236
|
+
[null, 401], ['invalid', 401], ['member', 403], ['admin', 200],
|
|
237
|
+
];
|
|
238
|
+
const failures = [];
|
|
239
|
+
for(const [as, expected] of cases){
|
|
240
|
+
const { status } = await req('GET', '/kempo-media/api/media', { as });
|
|
241
|
+
if(status !== expected) failures.push(`as ${as ?? 'anonymous'}: expected ${expected}, got ${status}`);
|
|
242
|
+
}
|
|
243
|
+
if(failures.length) return fail(failures.join('; '));
|
|
244
|
+
pass();
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
'PATCH and DELETE answer 401/401/403 the same way for a request with no target yet': async ({ pass, fail }) => {
|
|
248
|
+
if(!state.port) return fail('server did not start');
|
|
249
|
+
const failures = [];
|
|
250
|
+
for(const method of ['PATCH', 'DELETE']){
|
|
251
|
+
for(const [as, expected] of [[null, 401], ['invalid', 401], ['member', 403]]){
|
|
252
|
+
const { status } = await req(method, '/kempo-media/api/media', { as, body: { id: 'does-not-matter-yet' } });
|
|
253
|
+
if(status !== expected) failures.push(`${method} as ${as ?? 'anonymous'}: expected ${expected}, got ${status}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if(failures.length) return fail(failures.join('; '));
|
|
257
|
+
pass();
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
'a member without media:upload is rejected before any bytes are parsed': async ({ pass, fail }) => {
|
|
261
|
+
if(!state.port) return fail('server did not start');
|
|
262
|
+
const form = new FormData();
|
|
263
|
+
form.append('file', new Blob([PNG_1X1_RED], { type: 'image/png' }), 'x.png');
|
|
264
|
+
const { status } = await req('POST', '/kempo-media/api/media', { as: 'member', body: form });
|
|
265
|
+
if(status !== 403) return fail(`expected 403, got ${status}`);
|
|
266
|
+
pass();
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
'an admin can upload a real file over HTTP and the bytes survive intact': async ({ pass, fail }) => {
|
|
270
|
+
if(!state.port) return fail('server did not start');
|
|
271
|
+
|
|
272
|
+
const form = new FormData();
|
|
273
|
+
form.append('file', new Blob([PNG_1X1_RED], { type: 'image/png' }), 'contract-test.png');
|
|
274
|
+
form.append('alt', 'A one pixel test image');
|
|
275
|
+
|
|
276
|
+
const { status, json } = await req('POST', '/kempo-media/api/media', { as: 'admin', body: form });
|
|
277
|
+
if(status !== 201) return fail(`expected 201, got ${status}: ${JSON.stringify(json)}`);
|
|
278
|
+
if(!json?.asset?.id) return fail(`no asset in the response: ${JSON.stringify(json)}`);
|
|
279
|
+
if(json.asset.kind !== 'image') return fail(`expected kind image, got ${json.asset.kind}`);
|
|
280
|
+
if(json.asset.sizeBytes !== PNG_1X1_RED.length) return fail(`sizeBytes ${json.asset.sizeBytes} does not match the ${PNG_1X1_RED.length}-byte fixture — the body was likely mangled in transit`);
|
|
281
|
+
if(json.asset.altText !== 'A one pixel test image') return fail(`alt text wrong: ${json.asset.altText}`);
|
|
282
|
+
|
|
283
|
+
const onDisk = await readFile(path.join(state.tmp, 'public', json.asset.path));
|
|
284
|
+
if(!onDisk.equals(PNG_1X1_RED)) return fail('the file written to disk does not match what was uploaded — byte corruption in the real HTTP path');
|
|
285
|
+
|
|
286
|
+
state.uploadedId = json.asset.id;
|
|
287
|
+
pass();
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
'the upload is servable directly, and kempo-media:editor really does grant browse': async ({ pass, fail }) => {
|
|
291
|
+
if(!state.uploadedId) return fail('upload step did not complete');
|
|
292
|
+
|
|
293
|
+
const [row] = await db.select().from(kempoMediaAsset).where(eq(kempoMediaAsset.id, state.uploadedId));
|
|
294
|
+
if(!row) return fail('no DB row for the uploaded asset');
|
|
295
|
+
|
|
296
|
+
// Confirms the whole point of storing under the site's own public/: kempo-server's native
|
|
297
|
+
// static scanner serves it directly, with no serving code of this extension's own involved.
|
|
298
|
+
const fileRes = await fetch(`http://127.0.0.1:${state.port}/${row.path}`);
|
|
299
|
+
if(fileRes.status !== 200) return fail(`expected the uploaded file to be servable, got ${fileRes.status}`);
|
|
300
|
+
|
|
301
|
+
// kempo-media:editor grants browse+upload+update — this is the only test in the suite that
|
|
302
|
+
// exercises the group itself rather than a bare permission check
|
|
303
|
+
const editorView = await req('GET', `/kempo-media/api/media?id=${state.uploadedId}`, { as: 'editor' });
|
|
304
|
+
if(editorView.status !== 200) return fail(`editor (kempo-media:editor) should be able to view an asset, got ${editorView.status}`);
|
|
305
|
+
if(editorView.json?.asset?.id !== state.uploadedId) return fail('wrong asset returned to editor');
|
|
306
|
+
|
|
307
|
+
// member has no media:* grant at all — still denied, same as every other route
|
|
308
|
+
const memberView = await req('GET', `/kempo-media/api/media?id=${state.uploadedId}`, { as: 'member' });
|
|
309
|
+
if(memberView.status !== 403) return fail(`member with no media permissions should be denied, got ${memberView.status}`);
|
|
310
|
+
pass();
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
'admin can update the alt text, member without media:update cannot': async ({ pass, fail }) => {
|
|
314
|
+
if(!state.uploadedId) return fail('upload step did not complete');
|
|
315
|
+
|
|
316
|
+
const denied = await req('PATCH', '/kempo-media/api/media', { as: 'member', body: { id: state.uploadedId, altText: 'nope' } });
|
|
317
|
+
if(denied.status !== 403) return fail(`expected 403 for member, got ${denied.status}`);
|
|
318
|
+
|
|
319
|
+
const { status, json } = await req('PATCH', '/kempo-media/api/media', { as: 'admin', body: { id: state.uploadedId, altText: 'Updated over HTTP' } });
|
|
320
|
+
if(status !== 200) return fail(`expected 200, got ${status}: ${JSON.stringify(json)}`);
|
|
321
|
+
if(json?.asset?.altText !== 'Updated over HTTP') return fail(`alt text did not update: ${JSON.stringify(json)}`);
|
|
322
|
+
pass();
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
'member cannot delete, admin can, and the files are actually gone afterward': async ({ pass, fail }) => {
|
|
326
|
+
if(!state.uploadedId) return fail('upload step did not complete');
|
|
327
|
+
|
|
328
|
+
const [row] = await db.select().from(kempoMediaAsset).where(eq(kempoMediaAsset.id, state.uploadedId));
|
|
329
|
+
const absolutePath = path.join(state.tmp, 'public', row.path);
|
|
330
|
+
const absoluteThumb = row.thumbnailPath ? path.join(state.tmp, 'public', row.thumbnailPath) : null;
|
|
331
|
+
|
|
332
|
+
const denied = await req('DELETE', '/kempo-media/api/media', { as: 'member', body: { id: state.uploadedId } });
|
|
333
|
+
if(denied.status !== 403) return fail(`expected 403 for member, got ${denied.status}`);
|
|
334
|
+
|
|
335
|
+
const { status } = await req('DELETE', '/kempo-media/api/media', { as: 'admin', body: { id: state.uploadedId } });
|
|
336
|
+
if(status !== 200) return fail(`expected 200, got ${status}`);
|
|
337
|
+
|
|
338
|
+
const [, stillThere] = await db.select().from(kempoMediaAsset).where(eq(kempoMediaAsset.id, state.uploadedId));
|
|
339
|
+
if(stillThere) return fail('the DB row survived an admin delete');
|
|
340
|
+
|
|
341
|
+
const fileGone = await stat(absolutePath).then(() => false).catch(() => true);
|
|
342
|
+
if(!fileGone) return fail('the uploaded file was still on disk after delete');
|
|
343
|
+
if(absoluteThumb){
|
|
344
|
+
const thumbGone = await stat(absoluteThumb).then(() => false).catch(() => true);
|
|
345
|
+
if(!thumbGone) return fail('the thumbnail file was still on disk after delete');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// A route that never responds is worth naming, not the point of this suite, but 404 is
|
|
349
|
+
const { status: secondDelete } = await req('DELETE', '/kempo-media/api/media', { as: 'admin', body: { id: state.uploadedId } });
|
|
350
|
+
if(secondDelete !== 404) return fail(`deleting an already-deleted asset should 404, got ${secondDelete}`);
|
|
351
|
+
|
|
352
|
+
state.server?.kill();
|
|
353
|
+
await rm(state.tmp, { recursive: true, force: true }).catch(() => {});
|
|
354
|
+
await purgeUsers();
|
|
355
|
+
await purgeExtension();
|
|
356
|
+
pass();
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
export default databaseReachable
|
|
362
|
+
? buildTests()
|
|
363
|
+
: skipped('no reachable database — see the "Local development" section of the README (docker compose up -d, then npx drizzle-kit push)');
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { mkdtemp, rm, mkdir, writeFile, stat } from 'fs/promises';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import { sql, inArray } from 'drizzle-orm';
|
|
6
|
+
import db from 'kempo/server/db/index.js';
|
|
7
|
+
import { kempoMediaAsset } from '../server/db/schema.js';
|
|
8
|
+
import createMediaAsset from '../server/utils/media/createMediaAsset.js';
|
|
9
|
+
import getMediaAsset from '../server/utils/media/getMediaAsset.js';
|
|
10
|
+
import getMediaAssets from '../server/utils/media/getMediaAssets.js';
|
|
11
|
+
import updateMediaAsset from '../server/utils/media/updateMediaAsset.js';
|
|
12
|
+
import deleteMediaAsset from '../server/utils/media/deleteMediaAsset.js';
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
Direct tests of server/utils/media/*.js against a real database — the CRUD layer every route in
|
|
16
|
+
public/api/media/ sits on top of, and which had no test coverage of its own before this: only the
|
|
17
|
+
storage/parsing layer below it (tests/uploads.node-test.js) and, indirectly, one manual curl
|
|
18
|
+
session. Kept separate from the API-contract suite (tests/media-api-contract.node-test.js) the
|
|
19
|
+
same way kempo itself keeps its own data-layer/route concerns apart — this needs a database and
|
|
20
|
+
nothing else, no server, no HTTP.
|
|
21
|
+
|
|
22
|
+
Requires a reachable Postgres with this package's schema applied (see drizzle.config.js and the
|
|
23
|
+
"Local development" section of the README). Skips itself with a clear message when there is none,
|
|
24
|
+
so a checkout without one still runs `npm test`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const databaseReachable = await db.execute(sql`select 1`).then(() => true).catch(() => false);
|
|
28
|
+
|
|
29
|
+
const skipped = reason => ({
|
|
30
|
+
'media data layer (SKIPPED)': async ({ pass }) => pass(`skipped: ${reason}`),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const OWNER = 'test-uploader-id';
|
|
34
|
+
const createdIds = [];
|
|
35
|
+
|
|
36
|
+
// Every column createMediaAsset expects, so each test only needs to override what it cares about
|
|
37
|
+
const fixtureAsset = (overrides = {}) => ({
|
|
38
|
+
id: crypto.randomBytes(8).toString('hex'),
|
|
39
|
+
filename: 'fixture.png',
|
|
40
|
+
originalName: 'My Photo.png',
|
|
41
|
+
mimeType: 'image/png',
|
|
42
|
+
kind: 'image',
|
|
43
|
+
sizeBytes: 1234,
|
|
44
|
+
path: 'media/uploads/fixture.png',
|
|
45
|
+
thumbnailPath: 'media/thumbnails/fixture.webp',
|
|
46
|
+
altText: '',
|
|
47
|
+
width: 100,
|
|
48
|
+
height: 80,
|
|
49
|
+
uploadedBy: OWNER,
|
|
50
|
+
...overrides,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const create = async (overrides = {}) => {
|
|
54
|
+
const [error, row] = await createMediaAsset(fixtureAsset(overrides));
|
|
55
|
+
if(!error) createdIds.push(row.id);
|
|
56
|
+
return [error, row];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const purge = async () => {
|
|
60
|
+
if(createdIds.length){
|
|
61
|
+
await db.delete(kempoMediaAsset).where(inArray(kempoMediaAsset.id, createdIds)).catch(() => {});
|
|
62
|
+
createdIds.length = 0;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const withTempRoot = async fn => {
|
|
67
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'kempo-media-data-'));
|
|
68
|
+
try {
|
|
69
|
+
return await fn(dir);
|
|
70
|
+
} finally {
|
|
71
|
+
await rm(dir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const buildTests = () => ({
|
|
76
|
+
'createMediaAsset records every column and stamps timestamps': async ({ pass, fail }) => {
|
|
77
|
+
const [error, row] = await create({ altText: 'A test image' });
|
|
78
|
+
if(error) return fail(`unexpected error: ${error.msg}`);
|
|
79
|
+
|
|
80
|
+
if(row.originalName !== 'My Photo.png') return fail(`originalName wrong: ${row.originalName}`);
|
|
81
|
+
if(row.kind !== 'image') return fail(`kind wrong: ${row.kind}`);
|
|
82
|
+
if(row.altText !== 'A test image') return fail(`altText wrong: ${row.altText}`);
|
|
83
|
+
if(!(row.createdAt instanceof Date)) return fail(`createdAt is not a Date: ${row.createdAt}`);
|
|
84
|
+
if(row.createdAt.getTime() !== row.updatedAt.getTime()) return fail('createdAt and updatedAt should match on insert');
|
|
85
|
+
pass();
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
'getMediaAsset returns the row by id, and a 404 tuple for one that does not exist': async ({ pass, fail }) => {
|
|
89
|
+
const [, created] = await create();
|
|
90
|
+
const [error, found] = await getMediaAsset(created.id);
|
|
91
|
+
if(error) return fail(`unexpected error fetching an existing asset: ${error.msg}`);
|
|
92
|
+
if(found.id !== created.id) return fail('fetched the wrong row');
|
|
93
|
+
|
|
94
|
+
const [missingError, missing] = await getMediaAsset('0000000000000000');
|
|
95
|
+
if(missing) return fail('expected no data for a missing id');
|
|
96
|
+
if(missingError?.code !== 404) return fail(`expected a 404 tuple, got ${JSON.stringify(missingError)}`);
|
|
97
|
+
pass();
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
'getMediaAssets filters by a single kind': async ({ pass, fail }) => {
|
|
101
|
+
const [, image] = await create({ kind: 'image' });
|
|
102
|
+
const [, video] = await create({ kind: 'video', thumbnailPath: null, width: null, height: null });
|
|
103
|
+
|
|
104
|
+
const [error, data] = await getMediaAssets({ kind: 'video', limit: 100 });
|
|
105
|
+
if(error) return fail(`unexpected error: ${error.msg}`);
|
|
106
|
+
const ids = data.assets.map(a => a.id);
|
|
107
|
+
if(!ids.includes(video.id)) return fail('the video fixture is missing from a kind=video query');
|
|
108
|
+
if(ids.includes(image.id)) return fail('an image fixture leaked into a kind=video query');
|
|
109
|
+
pass();
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
'getMediaAssets filters by a comma-separated list of kinds': async ({ pass, fail }) => {
|
|
113
|
+
const [, image] = await create({ kind: 'image' });
|
|
114
|
+
const [, audio] = await create({ kind: 'audio', thumbnailPath: null, width: null, height: null });
|
|
115
|
+
const [, archive] = await create({ kind: 'archive', thumbnailPath: null, width: null, height: null });
|
|
116
|
+
|
|
117
|
+
const [error, data] = await getMediaAssets({ kind: 'image,audio', limit: 100 });
|
|
118
|
+
if(error) return fail(`unexpected error: ${error.msg}`);
|
|
119
|
+
const ids = data.assets.map(a => a.id);
|
|
120
|
+
if(!ids.includes(image.id) || !ids.includes(audio.id)) return fail('image/audio fixtures missing from the combined-kind query');
|
|
121
|
+
if(ids.includes(archive.id)) return fail('an archive fixture leaked into an image,audio query');
|
|
122
|
+
pass();
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
'getMediaAssets ignores an unrecognised kind rather than matching nothing silently': async ({ pass, fail }) => {
|
|
126
|
+
const [, row] = await create();
|
|
127
|
+
/*
|
|
128
|
+
A typo in a query string (?kind=iamge) should not read as "the library is empty" — that is
|
|
129
|
+
indistinguishable from a real empty state and would be a confusing UI to debug.
|
|
130
|
+
*/
|
|
131
|
+
const [error, data] = await getMediaAssets({ kind: 'not-a-real-kind', limit: 100 });
|
|
132
|
+
if(error) return fail(`unexpected error: ${error.msg}`);
|
|
133
|
+
if(!data.assets.some(a => a.id === row.id)) return fail('an unrecognised kind filtered out everything instead of being ignored');
|
|
134
|
+
pass();
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
'getMediaAssets searches originalName, case-insensitively': async ({ pass, fail }) => {
|
|
138
|
+
const [, row] = await create({ originalName: 'Sunset-Over-Mountains.PNG' });
|
|
139
|
+
const [error, data] = await getMediaAssets({ search: 'sunset-over', limit: 100 });
|
|
140
|
+
if(error) return fail(`unexpected error: ${error.msg}`);
|
|
141
|
+
if(!data.assets.some(a => a.id === row.id)) return fail('case-insensitive substring search did not find the fixture');
|
|
142
|
+
pass();
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
'getMediaAssets paginates with limit/offset and reports an accurate total': async ({ pass, fail }) => {
|
|
146
|
+
const marker = crypto.randomBytes(4).toString('hex');
|
|
147
|
+
const rows = [];
|
|
148
|
+
for(let i = 0; i < 5; i++){
|
|
149
|
+
const [, row] = await create({ originalName: `page-fixture-${marker}-${i}.png` });
|
|
150
|
+
rows.push(row);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const [error, data] = await getMediaAssets({ search: `page-fixture-${marker}`, limit: 2, offset: 0 });
|
|
154
|
+
if(error) return fail(`unexpected error: ${error.msg}`);
|
|
155
|
+
if(data.total !== 5) return fail(`expected total 5, got ${data.total}`);
|
|
156
|
+
if(data.assets.length !== 2) return fail(`expected a page of 2, got ${data.assets.length}`);
|
|
157
|
+
|
|
158
|
+
const [, page2] = await getMediaAssets({ search: `page-fixture-${marker}`, limit: 2, offset: 2 });
|
|
159
|
+
if(page2.assets.length !== 2) return fail(`expected the second page to have 2, got ${page2.assets.length}`);
|
|
160
|
+
if(page2.assets.some(a => data.assets.some(first => first.id === a.id))){
|
|
161
|
+
return fail('the second page repeated a row from the first');
|
|
162
|
+
}
|
|
163
|
+
pass();
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
'getMediaAssets orders newest first': async ({ pass, fail }) => {
|
|
167
|
+
const marker = crypto.randomBytes(4).toString('hex');
|
|
168
|
+
const [, older] = await create({ originalName: `order-fixture-${marker}-older.png` });
|
|
169
|
+
await new Promise(r => setTimeout(r, 20));
|
|
170
|
+
const [, newer] = await create({ originalName: `order-fixture-${marker}-newer.png` });
|
|
171
|
+
|
|
172
|
+
const [error, data] = await getMediaAssets({ search: `order-fixture-${marker}`, limit: 10 });
|
|
173
|
+
if(error) return fail(`unexpected error: ${error.msg}`);
|
|
174
|
+
const ids = data.assets.map(a => a.id);
|
|
175
|
+
if(ids.indexOf(newer.id) > ids.indexOf(older.id)) return fail(`expected the newer row first, got order ${ids}`);
|
|
176
|
+
pass();
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
'updateMediaAsset updates altText and originalName independently': async ({ pass, fail }) => {
|
|
180
|
+
const [, row] = await create({ altText: 'before' });
|
|
181
|
+
|
|
182
|
+
const [altError, altUpdated] = await updateMediaAsset(row.id, { altText: 'after' });
|
|
183
|
+
if(altError) return fail(`unexpected error: ${altError.msg}`);
|
|
184
|
+
if(altUpdated.altText !== 'after') return fail(`altText did not update: ${altUpdated.altText}`);
|
|
185
|
+
if(altUpdated.originalName !== row.originalName) return fail('originalName changed when only altText was given');
|
|
186
|
+
|
|
187
|
+
const [nameError, nameUpdated] = await updateMediaAsset(row.id, { originalName: ' Renamed.png ' });
|
|
188
|
+
if(nameError) return fail(`unexpected error: ${nameError.msg}`);
|
|
189
|
+
if(nameUpdated.originalName !== 'Renamed.png') return fail(`expected a trimmed name, got ${JSON.stringify(nameUpdated.originalName)}`);
|
|
190
|
+
pass();
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
'updateMediaAsset rejects an empty name and a no-op update': async ({ pass, fail }) => {
|
|
194
|
+
const [, row] = await create();
|
|
195
|
+
|
|
196
|
+
const [emptyError] = await updateMediaAsset(row.id, { originalName: ' ' });
|
|
197
|
+
if(emptyError?.code !== 400) return fail(`expected 400 for a blank name, got ${JSON.stringify(emptyError)}`);
|
|
198
|
+
|
|
199
|
+
const [noopError] = await updateMediaAsset(row.id, {});
|
|
200
|
+
if(noopError?.code !== 400) return fail(`expected 400 for an update with nothing to change, got ${JSON.stringify(noopError)}`);
|
|
201
|
+
pass();
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
'updateMediaAsset on a missing id returns a 404 tuple': async ({ pass, fail }) => {
|
|
205
|
+
const [error, data] = await updateMediaAsset('0000000000000000', { altText: 'x' });
|
|
206
|
+
if(data) return fail('expected no data for a missing id');
|
|
207
|
+
if(error?.code !== 404) return fail(`expected a 404 tuple, got ${JSON.stringify(error)}`);
|
|
208
|
+
pass();
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
'deleteMediaAsset removes the row and both files on disk': async ({ pass, fail }) => {
|
|
212
|
+
await withTempRoot(async rootDir => {
|
|
213
|
+
const uploadRel = 'media/uploads/delete-me.png';
|
|
214
|
+
const thumbRel = 'media/thumbnails/delete-me.webp';
|
|
215
|
+
await mkdir(path.join(rootDir, 'media', 'uploads'), { recursive: true });
|
|
216
|
+
await mkdir(path.join(rootDir, 'media', 'thumbnails'), { recursive: true });
|
|
217
|
+
await writeFile(path.join(rootDir, uploadRel), 'x');
|
|
218
|
+
await writeFile(path.join(rootDir, thumbRel), 'y');
|
|
219
|
+
|
|
220
|
+
const [, row] = await create({ path: uploadRel, thumbnailPath: thumbRel });
|
|
221
|
+
const [error] = await deleteMediaAsset(row.id, { rootDir });
|
|
222
|
+
if(error) throw new Error(`unexpected error: ${error.msg}`);
|
|
223
|
+
|
|
224
|
+
const [, stillThere] = await getMediaAsset(row.id);
|
|
225
|
+
if(stillThere) throw new Error('the row survived deleteMediaAsset');
|
|
226
|
+
|
|
227
|
+
const uploadGone = await stat(path.join(rootDir, uploadRel)).then(() => false).catch(() => true);
|
|
228
|
+
const thumbGone = await stat(path.join(rootDir, thumbRel)).then(() => false).catch(() => true);
|
|
229
|
+
if(!uploadGone) throw new Error('the uploaded file was not removed');
|
|
230
|
+
if(!thumbGone) throw new Error('the thumbnail file was not removed');
|
|
231
|
+
|
|
232
|
+
createdIds.splice(createdIds.indexOf(row.id), 1); // already gone, do not delete it again in purge()
|
|
233
|
+
});
|
|
234
|
+
pass();
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
'deleteMediaAsset tolerates a file already missing from disk': async ({ pass, fail }) => {
|
|
238
|
+
await withTempRoot(async rootDir => {
|
|
239
|
+
// rootDir deliberately has neither file — simulates one having been removed by hand
|
|
240
|
+
const [, row] = await create({ path: 'media/uploads/already-gone.png', thumbnailPath: 'media/thumbnails/already-gone.webp' });
|
|
241
|
+
|
|
242
|
+
const [error] = await deleteMediaAsset(row.id, { rootDir });
|
|
243
|
+
if(error) throw new Error(`a missing file should not fail the delete: ${error.msg}`);
|
|
244
|
+
|
|
245
|
+
const [, stillThere] = await getMediaAsset(row.id);
|
|
246
|
+
if(stillThere) throw new Error('the row should still be gone even though its files already were');
|
|
247
|
+
|
|
248
|
+
createdIds.splice(createdIds.indexOf(row.id), 1);
|
|
249
|
+
});
|
|
250
|
+
pass();
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
'deleteMediaAsset on a missing id returns a 404 tuple and touches no files': async ({ pass, fail }) => {
|
|
254
|
+
const [error, data] = await deleteMediaAsset('0000000000000000', { rootDir: os.tmpdir() });
|
|
255
|
+
if(data) return fail('expected no data for a missing id');
|
|
256
|
+
if(error?.code !== 404) return fail(`expected a 404 tuple, got ${JSON.stringify(error)}`);
|
|
257
|
+
pass();
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
'cleanup: fixtures are purged': async ({ pass }) => {
|
|
261
|
+
await purge();
|
|
262
|
+
pass();
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
export default databaseReachable
|
|
267
|
+
? buildTests()
|
|
268
|
+
: skipped('no reachable database — see the "Local development" section of the README (docker compose up -d, then npx drizzle-kit push)');
|