@medialit/integration-tests 0.0.0-stage → 0.1.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.
Files changed (3) hide show
  1. package/README.md +37 -2
  2. package/dist/bin.mjs +1656 -0
  3. package/package.json +43 -5
package/dist/bin.mjs ADDED
@@ -0,0 +1,1656 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/synthetic.ts
4
+ import assert from "node:assert/strict";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ // src/utils.ts
8
+ import { deflateSync } from "node:zlib";
9
+ function generatePng() {
10
+ function chunk(type, data) {
11
+ const payload = Buffer.concat(
12
+ [Buffer.from(type), data].map((bytes) => Uint8Array.from(bytes))
13
+ );
14
+ let crc = 4294967295;
15
+ for (const byte of payload) {
16
+ crc ^= byte;
17
+ for (let bit = 0; bit < 8; bit++) {
18
+ crc = crc >>> 1 ^ (crc & 1 ? 3988292384 : 0);
19
+ }
20
+ }
21
+ const length = Buffer.alloc(4);
22
+ length.writeUInt32BE(data.length);
23
+ const checksum = Buffer.alloc(4);
24
+ checksum.writeUInt32BE((crc ^ 4294967295) >>> 0);
25
+ return Buffer.concat(
26
+ [length, payload, checksum].map((bytes) => Uint8Array.from(bytes))
27
+ );
28
+ }
29
+ const header = Buffer.alloc(13);
30
+ header.writeUInt32BE(1, 0);
31
+ header.writeUInt32BE(1, 4);
32
+ header[8] = 8;
33
+ header[9] = 6;
34
+ return Buffer.concat(
35
+ [
36
+ Buffer.from("89504e470d0a1a0a", "hex"),
37
+ chunk("IHDR", header),
38
+ chunk("IDAT", deflateSync(Uint8Array.from([0, 0, 0, 0, 0]))),
39
+ chunk("IEND", Buffer.alloc(0))
40
+ ].map((bytes) => Uint8Array.from(bytes))
41
+ );
42
+ }
43
+
44
+ // src/synthetic.ts
45
+ var GROUP_PREFIX = "__synthetic_";
46
+ var STALE_AFTER_MS = 60 * 60 * 1e3;
47
+ async function runSyntheticCheck(options = {}) {
48
+ const apiKey = options.apiKey ?? process.env.MEDIALIT_APIKEY;
49
+ assert(
50
+ typeof apiKey === "string" && apiKey.trim(),
51
+ "MEDIALIT_APIKEY is required"
52
+ );
53
+ const server = options.server ?? process.env.MEDIALIT_SERVER ?? "localhost:8000";
54
+ const base = new URL(
55
+ /^https?:\/\//i.test(server) ? server : `http://${server}`
56
+ );
57
+ assert(
58
+ ["http:", "https:"].includes(base.protocol),
59
+ "MEDIALIT_SERVER must use HTTP or HTTPS"
60
+ );
61
+ assert(
62
+ !base.username && !base.password && !base.search && !base.hash,
63
+ "MEDIALIT_SERVER must be a base URL without credentials, query, or fragment"
64
+ );
65
+ base.pathname = `${base.pathname.replace(/\/+$/, "")}/`;
66
+ const heartbeatUrl = options.heartbeatUrl ?? process.env.MEDIALIT_SYNTHETIC_HEARTBEAT_URL;
67
+ const log = options.log ?? console.log;
68
+ const group = `${GROUP_PREFIX}${Date.now()}_${randomUUID()}`;
69
+ const png = generatePng();
70
+ const result = { ok: true, steps: [] };
71
+ let mediaId;
72
+ function redact(value) {
73
+ return value.split(apiKey).join("[redacted]");
74
+ }
75
+ async function request(path, init = {}, status = 200) {
76
+ const response = await fetch(new URL(path, base), {
77
+ ...init,
78
+ headers: { "x-medialit-apikey": apiKey, ...init.headers },
79
+ redirect: "error",
80
+ signal: AbortSignal.timeout(3e4)
81
+ });
82
+ if (response.status !== status) {
83
+ const body = redact(await response.text()).slice(0, 500);
84
+ throw new Error(
85
+ `${init.method ?? "GET"} /${path}: expected HTTP ${status}, got ${response.status}: ${body}`
86
+ );
87
+ }
88
+ return response;
89
+ }
90
+ async function api(path, body) {
91
+ const response = await request(path, {
92
+ method: "POST",
93
+ headers: { "content-type": "application/json" },
94
+ ...body ? { body: JSON.stringify(body) } : {}
95
+ });
96
+ return response.json();
97
+ }
98
+ async function step(name, run) {
99
+ const started = Date.now();
100
+ try {
101
+ await run();
102
+ result.steps.push({ name, ms: Date.now() - started });
103
+ log(`PASS ${name} (${Date.now() - started} ms)`);
104
+ } catch (error) {
105
+ const message = redact(describe(error));
106
+ result.steps.push({
107
+ name,
108
+ ms: Date.now() - started,
109
+ error: message
110
+ });
111
+ log(`FAIL ${name} (${Date.now() - started} ms): ${message}`);
112
+ throw error;
113
+ }
114
+ }
115
+ log(`MediaLit synthetic check ${group} against ${base.origin}`);
116
+ try {
117
+ await step("ready", async () => {
118
+ const response = await fetch(new URL("ready", base), {
119
+ signal: AbortSignal.timeout(3e4)
120
+ });
121
+ const report = await response.text();
122
+ assert.equal(
123
+ response.status,
124
+ 200,
125
+ `/ready returned ${response.status}: ${report.slice(0, 500)}`
126
+ );
127
+ });
128
+ await step("upload", async () => {
129
+ const form = new FormData();
130
+ form.set(
131
+ "file",
132
+ new Blob([Uint8Array.from(png)], { type: "image/png" }),
133
+ "synthetic-check.png"
134
+ );
135
+ form.set("access", "public");
136
+ form.set("group", group);
137
+ const media = await (await request("media/create", { method: "POST", body: form })).json();
138
+ assert.equal(
139
+ typeof media.mediaId,
140
+ "string",
141
+ "upload returned no mediaId"
142
+ );
143
+ mediaId = media.mediaId;
144
+ assert.equal(media.size, png.length);
145
+ });
146
+ let fileUrl = "";
147
+ await step("seal", async () => {
148
+ const media = await api(`media/seal/${mediaId}`);
149
+ assert.equal(media.mediaId, mediaId);
150
+ assert.equal(media.access, "public");
151
+ fileUrl = String(media.file);
152
+ assert.match(fileUrl, /^https?:\/\//, "seal returned no file URL");
153
+ });
154
+ await step("download", async () => {
155
+ const response = await fetch(fileUrl, {
156
+ signal: AbortSignal.timeout(3e4)
157
+ });
158
+ assert.equal(
159
+ response.status,
160
+ 200,
161
+ `the file URL returned ${response.status}`
162
+ );
163
+ const bytes = Buffer.from(await response.arrayBuffer());
164
+ assert(
165
+ bytes.toString("base64") === png.toString("base64"),
166
+ "the downloaded file differs from the upload"
167
+ );
168
+ });
169
+ await step("delete", async () => {
170
+ await request(`media/delete/${mediaId}`, { method: "DELETE" });
171
+ mediaId = void 0;
172
+ });
173
+ } catch {
174
+ result.ok = false;
175
+ } finally {
176
+ if (mediaId) {
177
+ await request(`media/delete/${mediaId}`, {
178
+ method: "DELETE"
179
+ }).catch(
180
+ () => log(`CLEANUP could not delete ${mediaId}; the next run will`)
181
+ );
182
+ }
183
+ }
184
+ if (result.ok) {
185
+ await sweepStaleSyntheticFiles().catch(
186
+ (error) => log(
187
+ `CLEANUP could not sweep old synthetic-check files: ${String(error)}`
188
+ )
189
+ );
190
+ }
191
+ if (heartbeatUrl) {
192
+ const url = result.ok ? heartbeatUrl : `${withoutTrailingSlashes(heartbeatUrl)}/fail`;
193
+ try {
194
+ await fetch(url, {
195
+ method: "POST",
196
+ body: JSON.stringify(result),
197
+ headers: { "content-type": "application/json" },
198
+ signal: AbortSignal.timeout(1e4)
199
+ });
200
+ log(`Heartbeat sent (${result.ok ? "up" : "down"})`);
201
+ } catch (error) {
202
+ log(`Heartbeat could not be sent: ${String(error)}`);
203
+ }
204
+ }
205
+ log(
206
+ result.ok ? "PASS MediaLit synthetic check" : "FAIL MediaLit synthetic check"
207
+ );
208
+ return result;
209
+ async function sweepStaleSyntheticFiles() {
210
+ const media = await api("media/get", {
211
+ page: 1,
212
+ limit: 50,
213
+ group: GROUP_PREFIX
214
+ });
215
+ assert(Array.isArray(media), "media/get must return an array");
216
+ for (const item of media) {
217
+ if (!String(item.group ?? "").startsWith(GROUP_PREFIX)) continue;
218
+ const started = Number(
219
+ String(item.group).slice(GROUP_PREFIX.length).split("_")[0]
220
+ );
221
+ if (!(Date.now() - started > STALE_AFTER_MS)) continue;
222
+ const id = String(item.mediaId);
223
+ await request(`media/delete/${id}`, { method: "DELETE" });
224
+ log(`CLEANUP deleted old synthetic-check file ${id}`);
225
+ }
226
+ }
227
+ }
228
+ function describe(error) {
229
+ if (!(error instanceof Error)) return String(error);
230
+ const cause = error.cause;
231
+ const reason = cause?.code ?? cause?.message;
232
+ return reason ? `${error.message} (${reason})` : error.message;
233
+ }
234
+ function withoutTrailingSlashes(url) {
235
+ let end = url.length;
236
+ while (end > 0 && url[end - 1] === "/") end--;
237
+ return url.slice(0, end);
238
+ }
239
+
240
+ // src/cli.ts
241
+ import assert2 from "node:assert/strict";
242
+ import { spawn } from "node:child_process";
243
+ import { randomUUID as randomUUID2 } from "node:crypto";
244
+ import { existsSync } from "node:fs";
245
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
246
+ import { tmpdir } from "node:os";
247
+ import { join } from "node:path";
248
+ import { fileURLToPath } from "node:url";
249
+ var EXPECTED_CLI_COMMANDS = [
250
+ "get",
251
+ "login",
252
+ "logout",
253
+ "ls",
254
+ "rm",
255
+ "seal",
256
+ "upload",
257
+ "whoami"
258
+ ].sort();
259
+ var CLI_PACKAGE = fileURLToPath(new URL("../../cli/", import.meta.url));
260
+ function object(value, label = "value") {
261
+ assert2(
262
+ value && typeof value === "object" && !Array.isArray(value),
263
+ `Expected ${label} to be a JSON object`
264
+ );
265
+ return value;
266
+ }
267
+ function nonemptyText(value, label) {
268
+ assert2(
269
+ typeof value === "string" && value.length > 0,
270
+ `${label} must be a nonempty string`
271
+ );
272
+ return value;
273
+ }
274
+ function integer(value, label) {
275
+ assert2(
276
+ typeof value === "number" && Number.isSafeInteger(value) && value >= 0,
277
+ `${label} must be a nonnegative integer`
278
+ );
279
+ return value;
280
+ }
281
+ async function runCliIntegrationTests(options = {}) {
282
+ const apiKey = options.apiKey ?? process.env.MEDIALIT_APIKEY;
283
+ assert2(
284
+ typeof apiKey === "string" && apiKey.trim(),
285
+ "MEDIALIT_APIKEY is required"
286
+ );
287
+ const server = options.server ?? process.env.MEDIALIT_SERVER ?? "localhost:8000";
288
+ const base = new URL(
289
+ /^https?:\/\//i.test(server) ? server : `http://${server}`
290
+ );
291
+ assert2(
292
+ ["http:", "https:"].includes(base.protocol),
293
+ "MEDIALIT_SERVER must use HTTP or HTTPS"
294
+ );
295
+ assert2(
296
+ !base.username && !base.password && !base.search && !base.hash,
297
+ "MEDIALIT_SERVER must be a base URL without credentials, query, or fragment"
298
+ );
299
+ const endpoint = `${base.origin}${base.pathname.replace(/\/+$/, "")}`;
300
+ const cli = options.cli ?? process.env.MEDIALIT_CLI;
301
+ const builtCli = join(CLI_PACKAGE, "dist", "index.mjs");
302
+ if (!cli) {
303
+ assert2(
304
+ existsSync(builtCli),
305
+ "No CLI to test. Set MEDIALIT_CLI to the medialit command, or run `bun --filter @medialit/cli build` in the MediaLit repository."
306
+ );
307
+ }
308
+ const testLogin = options.testLogin ?? process.env.MEDIALIT_CLI_TEST_LOGIN === "true";
309
+ const log = options.log ?? console.log;
310
+ const group = `__cli_integration_${randomUUID2()}`;
311
+ const png = generatePng();
312
+ const pending = /* @__PURE__ */ new Set();
313
+ const workDir = await mkdtemp(join(tmpdir(), "medialit-cli-it-"));
314
+ const configDir = join(workDir, "config");
315
+ let baseline;
316
+ let uploadAttempted = false;
317
+ let failure;
318
+ const cleanupErrors = [];
319
+ function redact(value) {
320
+ return value.split(apiKey).join("[redacted]");
321
+ }
322
+ function medialit(args, env = {}) {
323
+ const [command, commandArgs] = cli ? [cli, args] : [process.env.MEDIALIT_NODE ?? "node", [builtCli, ...args]];
324
+ const childEnv = {
325
+ ...process.env,
326
+ MEDIALIT_CONFIG_DIR: configDir,
327
+ MEDIALIT_ENDPOINT: endpoint
328
+ };
329
+ delete childEnv.MEDIALIT_API_KEY;
330
+ if (env.apiKey !== null) childEnv.MEDIALIT_API_KEY = apiKey;
331
+ return new Promise((resolve, reject) => {
332
+ const child = spawn(command, commandArgs, {
333
+ env: childEnv,
334
+ stdio: ["ignore", "pipe", "pipe"]
335
+ });
336
+ let stdout = "";
337
+ let stderr = "";
338
+ child.stdout.on("data", (chunk) => stdout += chunk);
339
+ child.stderr.on("data", (chunk) => stderr += chunk);
340
+ const timeout = setTimeout(() => {
341
+ child.kill();
342
+ reject(
343
+ new Error(`medialit ${args.join(" ")} timed out after 60s`)
344
+ );
345
+ }, 6e4);
346
+ child.on("error", (error) => {
347
+ clearTimeout(timeout);
348
+ reject(error);
349
+ });
350
+ child.on("close", (code) => {
351
+ clearTimeout(timeout);
352
+ resolve({ code, stdout, stderr });
353
+ });
354
+ });
355
+ }
356
+ async function ok(args) {
357
+ const result = await medialit(args);
358
+ if (result.code !== 0) {
359
+ throw new Error(
360
+ `medialit ${args.join(" ")}: exited with ${result.code}: ${redact(
361
+ result.stderr
362
+ ).slice(0, 1e3)}`
363
+ );
364
+ }
365
+ return result;
366
+ }
367
+ async function json(args) {
368
+ const result = await ok([...args, "--json"]);
369
+ try {
370
+ return JSON.parse(result.stdout);
371
+ } catch {
372
+ throw new Error(
373
+ `medialit ${args.join(" ")} --json printed invalid JSON`
374
+ );
375
+ }
376
+ }
377
+ async function fails(args, message, env) {
378
+ const result = await medialit(args, env);
379
+ assert2.equal(
380
+ result.code,
381
+ 1,
382
+ `medialit ${args.join(" ")} must exit with 1`
383
+ );
384
+ assert2.match(result.stderr, /^error: /m);
385
+ assert2.match(result.stderr, message);
386
+ assert2.equal(result.stdout, "", "Errors must not print to stdout");
387
+ }
388
+ async function stats() {
389
+ const info = object(await json(["whoami"]), "whoami");
390
+ assert2.equal(info.endpoint, endpoint);
391
+ assert2.equal(info.auth, "api-key");
392
+ return {
393
+ files: integer(info.files, "files"),
394
+ storage: integer(info.storage, "storage"),
395
+ maxStorage: integer(info.maxStorage, "maxStorage")
396
+ };
397
+ }
398
+ async function list(...filters) {
399
+ const result = await json(["ls", "--group", group, ...filters]);
400
+ assert2(Array.isArray(result), "ls --json must print an array");
401
+ return result.map((media) => object(media, "media item"));
402
+ }
403
+ async function checkTotals(bytes, files) {
404
+ assert2(baseline);
405
+ const current = await stats();
406
+ assert2.equal(current.maxStorage, baseline.maxStorage);
407
+ assert2.equal(current.storage, baseline.storage + bytes);
408
+ assert2.equal(current.files, baseline.files + files);
409
+ }
410
+ async function writeImage(name) {
411
+ const path = join(workDir, name);
412
+ await writeFile(path, Uint8Array.from(png));
413
+ return path;
414
+ }
415
+ async function download(url) {
416
+ const response = await fetch(url, {
417
+ signal: AbortSignal.timeout(3e4)
418
+ });
419
+ assert2.equal(
420
+ response.status,
421
+ 200,
422
+ `Downloading ${new URL(url).pathname} failed`
423
+ );
424
+ return Buffer.from(await response.arrayBuffer());
425
+ }
426
+ function checkMedia(media, expected) {
427
+ const mediaId = nonemptyText(media.mediaId, "mediaId");
428
+ assert2.equal(media.originalFileName, expected.name);
429
+ assert2.equal(media.mimeType, "image/png");
430
+ assert2.equal(media.size, png.length);
431
+ assert2.equal(media.access, expected.access);
432
+ assert2.equal(media.group, group);
433
+ if (expected.caption) assert2.equal(media.caption, expected.caption);
434
+ nonemptyText(media.file, "file URL");
435
+ return mediaId;
436
+ }
437
+ log(`MediaLit CLI integration run ${group} against ${endpoint}`);
438
+ try {
439
+ const version = (await ok(["--version"])).stdout.trim();
440
+ if (!cli) {
441
+ const pkg = JSON.parse(
442
+ await readFile(join(CLI_PACKAGE, "package.json"), "utf8")
443
+ );
444
+ assert2.equal(version, pkg.version);
445
+ }
446
+ const help = (await ok(["--help"])).stdout;
447
+ const commandsSection = help.split(/\n\s*\n/)[1] ?? "";
448
+ const commands = commandsSection.split("\n").slice(1).map((line) => line.trim().split(/\s+/)[0]).filter(Boolean).sort();
449
+ assert2.deepEqual(
450
+ commands,
451
+ EXPECTED_CLI_COMMANDS,
452
+ "CLI command inventory changed; update this integration test and exercise every command"
453
+ );
454
+ log(
455
+ `PASS medialit ${version}: --version and complete command inventory`
456
+ );
457
+ await fails(["ls"], /Not logged in to .*medialit login/, {
458
+ apiKey: null
459
+ });
460
+ await fails(["frobnicate"], /Unknown command "frobnicate"/);
461
+ await fails(["get"], /Pass one media ID/);
462
+ await fails(["upload"], /Pass at least one file/);
463
+ await fails(["upload", join(workDir, "missing.png")], /is not a file/);
464
+ log("PASS errors go to stderr with exit code 1");
465
+ baseline = await stats();
466
+ assert2(
467
+ baseline.maxStorage - baseline.storage >= png.length * 3,
468
+ "Insufficient available storage for the CLI uploads"
469
+ );
470
+ assert2.deepEqual(await list(), [], "Run group already contains media");
471
+ log("PASS whoami with MEDIALIT_API_KEY and ls baseline");
472
+ const tempName = `${group}-temp.png`;
473
+ const caption = `${group}: CLI integration upload`;
474
+ uploadAttempted = true;
475
+ const tempUploads = await json([
476
+ "upload",
477
+ await writeImage(tempName),
478
+ "--temp",
479
+ "--group",
480
+ group,
481
+ "--caption",
482
+ caption
483
+ ]);
484
+ assert2(Array.isArray(tempUploads) && tempUploads.length === 1);
485
+ const tempId = checkMedia(object(tempUploads[0]), {
486
+ name: tempName,
487
+ access: "private",
488
+ caption
489
+ });
490
+ pending.add(tempId);
491
+ assert2.deepEqual(await list(), [], "--temp uploads must stay hidden");
492
+ await checkTotals(0, 0);
493
+ log("PASS upload --temp leaves the upload temporary and hidden");
494
+ const sealed = object(await json(["seal", tempId]), "sealed media");
495
+ assert2.equal(sealed.mediaId, tempId);
496
+ assert2.deepEqual(
497
+ (await list()).map((media) => media.mediaId),
498
+ [tempId]
499
+ );
500
+ await checkTotals(png.length, 1);
501
+ const fetched = object(await json(["get", tempId]), "media");
502
+ checkMedia(fetched, { name: tempName, access: "private", caption });
503
+ const privateUrl = (await ok(["get", tempId])).stdout.trim();
504
+ assert2.match(privateUrl, /^https?:\/\//);
505
+ assert2.deepEqual(await download(privateUrl), png);
506
+ log("PASS seal, get --json, get URL download and storage/count totals");
507
+ const publicNames = [`${group}-a.png`, `${group}-b.png`];
508
+ const human = await ok([
509
+ "upload",
510
+ await writeImage(publicNames[0]),
511
+ await writeImage(publicNames[1]),
512
+ "--public",
513
+ "--group",
514
+ group
515
+ ]);
516
+ const urls = human.stdout.trim().split("\n");
517
+ assert2.equal(urls.length, 2, "upload must print one URL per file");
518
+ for (const url of urls) assert2.match(url, /^https?:\/\//);
519
+ const publicIds = publicNames.map((name) => {
520
+ const line = human.stderr.split("\n").find((entry) => entry.startsWith(`${name} `));
521
+ return nonemptyText(line?.split(/\s+/)[1], `${name} mediaId`);
522
+ });
523
+ publicIds.forEach((id) => pending.add(id));
524
+ for (const url of urls) assert2.deepEqual(await download(url), png);
525
+ await checkTotals(png.length * 3, 3);
526
+ log("PASS upload of several files seals them and prints only URLs");
527
+ const publicListed = (await list("--public")).map((m) => m.mediaId);
528
+ assert2.deepEqual([...publicListed].sort(), [...publicIds].sort());
529
+ const privateListed = (await list("--private")).map((m) => m.mediaId);
530
+ assert2.deepEqual(privateListed, [tempId]);
531
+ const firstPage = await list("--limit", "2", "--page", "1");
532
+ const secondPage = await list("--limit", "2", "--page", "2");
533
+ assert2.equal(firstPage.length, 2);
534
+ assert2.equal(secondPage.length, 1);
535
+ assert2.deepEqual(
536
+ [...firstPage, ...secondPage].map((m) => m.mediaId).sort(),
537
+ [tempId, ...publicIds].sort()
538
+ );
539
+ const table = (await ok(["ls", "--group", group])).stdout;
540
+ for (const id of [tempId, ...publicIds])
541
+ assert2.match(table, new RegExp(id));
542
+ log("PASS ls filters (--public, --private, --group), paging and table");
543
+ for (const id of [tempId, ...publicIds]) {
544
+ const deleted = object(await json(["rm", id]), "rm result");
545
+ assert2.equal(deleted.deleted, true);
546
+ assert2.equal(deleted.mediaId, id);
547
+ pending.delete(id);
548
+ }
549
+ await fails(["get", tempId], /./);
550
+ assert2.deepEqual(await list(), []);
551
+ await checkTotals(0, 0);
552
+ log("PASS rm and restored storage/count totals");
553
+ if (testLogin) {
554
+ await checkLoginReachesSignIn();
555
+ log(
556
+ "PASS login discovers the server, registers and reaches sign-in"
557
+ );
558
+ } else {
559
+ log("SKIP login (set MEDIALIT_CLI_TEST_LOGIN=true to check it)");
560
+ }
561
+ } catch (error) {
562
+ failure = error;
563
+ } finally {
564
+ for (const mediaId of pending) {
565
+ try {
566
+ await ok(["rm", mediaId]);
567
+ pending.delete(mediaId);
568
+ log(`CLEANUP deleted ${mediaId}`);
569
+ } catch (error) {
570
+ cleanupErrors.push(error);
571
+ log(`CLEANUP FAILED for ${mediaId}: ${String(error)}`);
572
+ }
573
+ }
574
+ if (uploadAttempted && baseline) {
575
+ try {
576
+ assert2.deepEqual(
577
+ await list(),
578
+ [],
579
+ "Run media remains after cleanup"
580
+ );
581
+ await checkTotals(0, 0);
582
+ } catch (error) {
583
+ cleanupErrors.push(error);
584
+ log(`CLEANUP verification failed: ${String(error)}`);
585
+ }
586
+ }
587
+ await rm(workDir, { recursive: true, force: true }).catch(
588
+ (error) => cleanupErrors.push(error)
589
+ );
590
+ }
591
+ if (failure) throw failure;
592
+ if (cleanupErrors.length) {
593
+ throw new AggregateError(
594
+ cleanupErrors,
595
+ "CLI integration cleanup failed"
596
+ );
597
+ }
598
+ log("PASS MediaLit CLI lifecycle; media and config cleaned up");
599
+ async function checkLoginReachesSignIn() {
600
+ const [command, args] = cli ? [cli, ["login", "--no-browser"]] : [
601
+ process.env.MEDIALIT_NODE ?? "node",
602
+ [builtCli, "login", "--no-browser"]
603
+ ];
604
+ const childEnv = {
605
+ ...process.env,
606
+ MEDIALIT_CONFIG_DIR: join(workDir, "login-config"),
607
+ MEDIALIT_ENDPOINT: endpoint
608
+ };
609
+ delete childEnv.MEDIALIT_API_KEY;
610
+ const child = spawn(command, args, {
611
+ env: childEnv,
612
+ stdio: ["ignore", "pipe", "pipe"]
613
+ });
614
+ try {
615
+ const authorizeUrl = await new Promise(
616
+ (resolve, reject) => {
617
+ let stderr = "";
618
+ const timeout = setTimeout(
619
+ () => reject(new Error("login did not print a URL")),
620
+ 3e4
621
+ );
622
+ child.stderr.on("data", (chunk) => {
623
+ stderr += chunk;
624
+ const match = stderr.match(
625
+ /https?:\/\/\S+authorize\?\S+/
626
+ );
627
+ if (match) {
628
+ clearTimeout(timeout);
629
+ resolve(match[0]);
630
+ }
631
+ });
632
+ child.on("close", (code) => {
633
+ clearTimeout(timeout);
634
+ reject(
635
+ new Error(
636
+ `login exited with ${code} before printing a URL: ${stderr.slice(0, 1e3)}`
637
+ )
638
+ );
639
+ });
640
+ }
641
+ );
642
+ const url = new URL(authorizeUrl);
643
+ assert2.equal(url.searchParams.get("code_challenge_method"), "S256");
644
+ assert2.match(
645
+ url.searchParams.get("redirect_uri") ?? "",
646
+ /^http:\/\/127\.0\.0\.1:\d+\/callback$/
647
+ );
648
+ nonemptyText(url.searchParams.get("client_id"), "client_id");
649
+ const response = await fetch(url, {
650
+ redirect: "manual",
651
+ signal: AbortSignal.timeout(3e4)
652
+ });
653
+ assert2(
654
+ response.status >= 300 && response.status < 400,
655
+ `The authorize URL must redirect to sign-in, got ${response.status}`
656
+ );
657
+ const location = new URL(
658
+ nonemptyText(response.headers.get("location"), "Location"),
659
+ url
660
+ );
661
+ assert2.doesNotMatch(
662
+ location.search,
663
+ /[?&]error=/,
664
+ `The server rejected the login request: ${location.search}`
665
+ );
666
+ const signIn = await fetch(location, {
667
+ signal: AbortSignal.timeout(3e4)
668
+ });
669
+ assert2.equal(signIn.status, 200);
670
+ assert2.match(await signIn.text(), /Sign in/i);
671
+ } finally {
672
+ child.kill();
673
+ }
674
+ }
675
+ }
676
+
677
+ // src/mcp.ts
678
+ import assert3 from "node:assert/strict";
679
+ import { randomUUID as randomUUID3 } from "node:crypto";
680
+ var EXPECTED_MCP_TOOLS = [
681
+ "create_upload_signature",
682
+ "delete_media",
683
+ "get_media",
684
+ "get_media_count",
685
+ "get_media_settings",
686
+ "get_total_storage",
687
+ "list_media",
688
+ "seal_media",
689
+ "update_media_settings",
690
+ "upload_media",
691
+ "whoami"
692
+ ].sort();
693
+ function object2(value, label = "value") {
694
+ assert3(
695
+ value && typeof value === "object" && !Array.isArray(value),
696
+ `Expected ${label} to be a JSON object`
697
+ );
698
+ return value;
699
+ }
700
+ function nonemptyText2(value, label) {
701
+ assert3(
702
+ typeof value === "string" && value.length > 0,
703
+ `${label} must be a nonempty string`
704
+ );
705
+ return value;
706
+ }
707
+ function integer2(value, label) {
708
+ assert3(
709
+ typeof value === "number" && Number.isSafeInteger(value) && value >= 0,
710
+ `${label} must be a nonnegative integer`
711
+ );
712
+ return value;
713
+ }
714
+ function mediaSettings(value) {
715
+ const settings = object2(value, "media settings");
716
+ assert3.equal(typeof settings.useWebP, "boolean");
717
+ return {
718
+ useWebP: settings.useWebP,
719
+ webpOutputQuality: integer2(
720
+ settings.webpOutputQuality,
721
+ "webpOutputQuality"
722
+ ),
723
+ thumbnailWidth: integer2(settings.thumbnailWidth, "thumbnailWidth"),
724
+ thumbnailHeight: integer2(settings.thumbnailHeight, "thumbnailHeight")
725
+ };
726
+ }
727
+ var McpSession = class {
728
+ constructor(endpoint, apiKey) {
729
+ this.endpoint = endpoint;
730
+ this.apiKey = apiKey;
731
+ }
732
+ sessionId;
733
+ protocolVersion;
734
+ lastSessionId;
735
+ nextId = 1;
736
+ async send(message, expectedStatus) {
737
+ const headers = new Headers({
738
+ accept: "application/json, text/event-stream",
739
+ "content-type": "application/json",
740
+ "x-medialit-apikey": this.apiKey
741
+ });
742
+ if (this.sessionId) headers.set("mcp-session-id", this.sessionId);
743
+ if (this.protocolVersion) {
744
+ headers.set("mcp-protocol-version", this.protocolVersion);
745
+ }
746
+ const response = await fetch(this.endpoint, {
747
+ method: "POST",
748
+ headers,
749
+ body: JSON.stringify(message),
750
+ redirect: "error",
751
+ signal: AbortSignal.timeout(3e4)
752
+ });
753
+ this.lastSessionId = response.headers.get("mcp-session-id") ?? this.lastSessionId;
754
+ const body = await response.text();
755
+ if (response.status !== expectedStatus) {
756
+ throw new Error(
757
+ `MCP ${String(message.method)}: expected HTTP ${expectedStatus}, got ${response.status}: ${body.split(this.apiKey).join("[redacted]").slice(0, 1e3)}`
758
+ );
759
+ }
760
+ if (expectedStatus === 202) return void 0;
761
+ let parsed;
762
+ try {
763
+ parsed = JSON.parse(body);
764
+ } catch {
765
+ throw new Error(
766
+ `MCP ${String(message.method)} returned invalid JSON`
767
+ );
768
+ }
769
+ return object2(parsed, "JSON-RPC response");
770
+ }
771
+ async connect() {
772
+ const response = await this.send(
773
+ {
774
+ jsonrpc: "2.0",
775
+ id: this.nextId++,
776
+ method: "initialize",
777
+ params: {
778
+ protocolVersion: "2025-11-25",
779
+ capabilities: {},
780
+ clientInfo: {
781
+ name: "medialit-integration-test",
782
+ version: "1.0.0"
783
+ }
784
+ }
785
+ },
786
+ 200
787
+ );
788
+ assert3(response);
789
+ assert3.equal(
790
+ response.error,
791
+ void 0,
792
+ "MCP initialize returned an error"
793
+ );
794
+ const result = object2(response.result, "initialize result");
795
+ this.sessionId = nonemptyText2(this.lastSessionId, "MCP session ID");
796
+ this.protocolVersion = nonemptyText2(
797
+ result.protocolVersion,
798
+ "MCP protocol version"
799
+ );
800
+ await this.notify("notifications/initialized");
801
+ }
802
+ async notify(method) {
803
+ await this.send({ jsonrpc: "2.0", method }, 202);
804
+ }
805
+ async request(method, params = {}) {
806
+ const id = this.nextId++;
807
+ const response = await this.send(
808
+ { jsonrpc: "2.0", id, method, params },
809
+ 200
810
+ );
811
+ assert3(response);
812
+ assert3.equal(
813
+ response.id,
814
+ id,
815
+ `MCP ${method} returned an unexpected ID`
816
+ );
817
+ if (response.error) {
818
+ const error = object2(response.error, "JSON-RPC error");
819
+ throw new Error(`MCP ${method} failed: ${String(error.message)}`);
820
+ }
821
+ return object2(response.result, `MCP ${method} result`);
822
+ }
823
+ async callToolResult(name, args) {
824
+ return this.request("tools/call", {
825
+ name,
826
+ ...args ? { arguments: args } : {}
827
+ });
828
+ }
829
+ async callTool(name, args) {
830
+ const result = await this.callToolResult(name, args);
831
+ if (result.isError === true) {
832
+ const content = Array.isArray(result.content) ? result.content : [];
833
+ const detail = content.map((item) => object2(item).text).filter((item) => typeof item === "string").join("; ");
834
+ throw new Error(`MCP tool ${name} failed: ${detail}`);
835
+ }
836
+ return object2(result.structuredContent, `${name} structuredContent`);
837
+ }
838
+ async close() {
839
+ if (!this.sessionId) return;
840
+ const headers = new Headers({ "x-medialit-apikey": this.apiKey });
841
+ headers.set("mcp-session-id", this.sessionId);
842
+ if (this.protocolVersion) {
843
+ headers.set("mcp-protocol-version", this.protocolVersion);
844
+ }
845
+ const response = await fetch(this.endpoint, {
846
+ method: "DELETE",
847
+ headers,
848
+ redirect: "error",
849
+ signal: AbortSignal.timeout(3e4)
850
+ });
851
+ if (response.status !== 200) {
852
+ const body = (await response.text()).split(this.apiKey).join("[redacted]");
853
+ throw new Error(
854
+ `MCP session close: expected HTTP 200, got ${response.status}: ${body.slice(0, 1e3)}`
855
+ );
856
+ }
857
+ this.sessionId = void 0;
858
+ }
859
+ };
860
+ async function runMcpIntegrationTests(options = {}) {
861
+ const apiKey = options.apiKey ?? process.env.MEDIALIT_APIKEY;
862
+ assert3(
863
+ typeof apiKey === "string" && apiKey.trim(),
864
+ "MEDIALIT_APIKEY is required"
865
+ );
866
+ const server = options.server ?? process.env.MEDIALIT_SERVER ?? "localhost:8000";
867
+ const base = new URL(
868
+ /^https?:\/\//i.test(server) ? server : `http://${server}`
869
+ );
870
+ assert3(
871
+ ["http:", "https:"].includes(base.protocol),
872
+ "MEDIALIT_SERVER must use HTTP or HTTPS"
873
+ );
874
+ assert3(
875
+ !base.username && !base.password && !base.search && !base.hash,
876
+ "MEDIALIT_SERVER must be a base URL without credentials, query, or fragment"
877
+ );
878
+ base.pathname = `${base.pathname.replace(/\/+$/, "")}/`;
879
+ const endpoint = new URL("mcp", base);
880
+ const log = options.log ?? console.log;
881
+ const group = `__mcp_integration_${randomUUID3()}`;
882
+ const png = generatePng();
883
+ const pending = /* @__PURE__ */ new Set();
884
+ let baseline;
885
+ let baselineCount;
886
+ let uploadAttempted = false;
887
+ let settingsToRestore;
888
+ let failure;
889
+ const cleanupErrors = [];
890
+ const client = new McpSession(endpoint, apiKey);
891
+ async function storage() {
892
+ const result = await client.callTool("get_total_storage");
893
+ return {
894
+ storage: integer2(result.storage, "storage"),
895
+ maxStorage: integer2(result.maxStorage, "maxStorage")
896
+ };
897
+ }
898
+ async function count() {
899
+ return integer2(
900
+ (await client.callTool("get_media_count")).count,
901
+ "count"
902
+ );
903
+ }
904
+ async function list(groupFilter) {
905
+ const result = await client.callTool("list_media", {
906
+ page: 1,
907
+ limit: 100,
908
+ group: groupFilter
909
+ });
910
+ assert3(Array.isArray(result.mediaItems), "mediaItems must be an array");
911
+ return result.mediaItems.map((media) => object2(media, "media item"));
912
+ }
913
+ async function getMediaSettings() {
914
+ return mediaSettings(await client.callTool("get_media_settings"));
915
+ }
916
+ async function updateMediaSettings(settings) {
917
+ const result = await client.callTool("update_media_settings", settings);
918
+ assert3.equal(result.message, "success");
919
+ }
920
+ async function restoreMediaSettings() {
921
+ if (!settingsToRestore) return;
922
+ const original = settingsToRestore;
923
+ await updateMediaSettings(original);
924
+ assert3.deepEqual(await getMediaSettings(), original);
925
+ settingsToRestore = void 0;
926
+ }
927
+ async function uploadWithSignature(signature, filename, caption) {
928
+ const form = new FormData();
929
+ form.set(
930
+ "file",
931
+ new Blob([Uint8Array.from(png)], { type: "image/png" }),
932
+ filename
933
+ );
934
+ form.set("access", "private");
935
+ form.set("caption", caption);
936
+ const response = await fetch(new URL("media/create", base), {
937
+ method: "POST",
938
+ headers: { "x-medialit-signature": signature },
939
+ body: form,
940
+ redirect: "error",
941
+ signal: AbortSignal.timeout(3e4)
942
+ });
943
+ const body = await response.text();
944
+ if (response.status !== 200) {
945
+ throw new Error(
946
+ `Signed upload: expected HTTP 200, got ${response.status}: ${body.split(signature).join("[redacted]").slice(0, 1e3)}`
947
+ );
948
+ }
949
+ return object2(JSON.parse(body), "signed upload response");
950
+ }
951
+ async function checkWhoami(expected) {
952
+ const result = await client.callTool("whoami");
953
+ assert3.equal(result.auth, "apikey");
954
+ nonemptyText2(result.email, "whoami email");
955
+ const app = object2(result.app, "whoami app");
956
+ nonemptyText2(app.id, "whoami app id");
957
+ nonemptyText2(app.name, "whoami app name");
958
+ assert3.equal(typeof app.default, "boolean");
959
+ assert3.equal(
960
+ JSON.stringify(result).includes(apiKey),
961
+ false,
962
+ "whoami must not return the API key"
963
+ );
964
+ assert3.equal(result.files, expected.files);
965
+ assert3.equal(result.storage, expected.storage);
966
+ assert3.equal(result.maxStorage, expected.maxStorage);
967
+ }
968
+ async function checkTotals(bytes, files) {
969
+ assert3(baseline && baselineCount !== void 0);
970
+ const current = await storage();
971
+ assert3.equal(current.maxStorage, baseline.maxStorage);
972
+ assert3.equal(current.storage, baseline.storage + bytes);
973
+ assert3.equal(
974
+ current.maxStorage - current.storage,
975
+ baseline.maxStorage - baseline.storage - bytes
976
+ );
977
+ assert3.equal(await count(), baselineCount + files);
978
+ await checkWhoami({ ...current, files: baselineCount + files });
979
+ }
980
+ log(`MediaLit MCP integration run ${group} against ${endpoint.origin}`);
981
+ try {
982
+ await client.connect();
983
+ const listedTools = await client.request("tools/list");
984
+ assert3(
985
+ Array.isArray(listedTools.tools),
986
+ "tools/list must return tools"
987
+ );
988
+ const toolNames = new Set(
989
+ listedTools.tools.map(
990
+ (tool) => nonemptyText2(object2(tool).name, "tool name")
991
+ )
992
+ );
993
+ assert3.deepEqual(
994
+ [...toolNames].sort(),
995
+ EXPECTED_MCP_TOOLS,
996
+ "MCP tool inventory changed; update this integration test and exercise every tool"
997
+ );
998
+ log("PASS MCP initialize and complete tool inventory");
999
+ const originalSettings = await getMediaSettings();
1000
+ settingsToRestore = originalSettings;
1001
+ const changedSettings = {
1002
+ ...originalSettings,
1003
+ useWebP: !originalSettings.useWebP
1004
+ };
1005
+ await updateMediaSettings(changedSettings);
1006
+ assert3.deepEqual(await getMediaSettings(), changedSettings);
1007
+ await restoreMediaSettings();
1008
+ log("PASS get_media_settings and update_media_settings round trip");
1009
+ baseline = await storage();
1010
+ baselineCount = await count();
1011
+ assert3(
1012
+ baseline.maxStorage - baseline.storage >= png.length,
1013
+ "Insufficient available storage for the MCP upload"
1014
+ );
1015
+ assert3.deepEqual(
1016
+ await list(group),
1017
+ [],
1018
+ "Run group already contains media"
1019
+ );
1020
+ await checkWhoami({ ...baseline, files: baselineCount });
1021
+ log(
1022
+ "PASS get_total_storage, get_media_count, whoami and list_media baseline"
1023
+ );
1024
+ const signed = await client.callTool("create_upload_signature", {
1025
+ group
1026
+ });
1027
+ const signature = nonemptyText2(signed.signature, "signature");
1028
+ uploadAttempted = true;
1029
+ const signedFilename = `${group}-signed.png`;
1030
+ const signedCaption = `${group}: signed upload`;
1031
+ const signedUpload = await uploadWithSignature(
1032
+ signature,
1033
+ signedFilename,
1034
+ signedCaption
1035
+ );
1036
+ const signedMediaId = nonemptyText2(
1037
+ signedUpload.mediaId,
1038
+ "signed upload mediaId"
1039
+ );
1040
+ pending.add(signedMediaId);
1041
+ assert3.equal(signedUpload.originalFileName, signedFilename);
1042
+ assert3.equal(signedUpload.size, png.length);
1043
+ assert3.equal(signedUpload.caption, signedCaption);
1044
+ assert3.equal(signedUpload.group, group);
1045
+ assert3.deepEqual(
1046
+ await list(group),
1047
+ [],
1048
+ "Signed draft uploads must stay hidden"
1049
+ );
1050
+ const signedDelete = await client.callTool("delete_media", {
1051
+ mediaId: signedMediaId
1052
+ });
1053
+ assert3.equal(signedDelete.message, "success");
1054
+ pending.delete(signedMediaId);
1055
+ await checkTotals(0, 0);
1056
+ log("PASS create_upload_signature and delete_media on a signed draft");
1057
+ uploadAttempted = true;
1058
+ const filename = `${group}.png`;
1059
+ const caption = `${group}: MCP integration upload`;
1060
+ const uploaded = await client.callTool("upload_media", {
1061
+ fileBase64: png.toString("base64"),
1062
+ fileName: filename,
1063
+ mimeType: "image/png",
1064
+ caption,
1065
+ access: "private",
1066
+ group
1067
+ });
1068
+ const mediaId = nonemptyText2(uploaded.mediaId, "mediaId");
1069
+ pending.add(mediaId);
1070
+ assert3.equal(uploaded.originalFileName, filename);
1071
+ assert3.equal(uploaded.size, png.length);
1072
+ assert3.equal(uploaded.caption, caption);
1073
+ assert3.equal(uploaded.group, group);
1074
+ assert3.deepEqual(
1075
+ await list(group),
1076
+ [],
1077
+ "Draft MCP uploads must stay hidden"
1078
+ );
1079
+ await checkTotals(0, 0);
1080
+ log("PASS upload_media and draft visibility");
1081
+ const sealed = await client.callTool("seal_media", { mediaId });
1082
+ assert3.equal(sealed.mediaId, mediaId);
1083
+ const retrieved = await client.callTool("get_media", { mediaId });
1084
+ assert3.equal(retrieved.mediaId, mediaId);
1085
+ assert3.equal(retrieved.originalFileName, filename);
1086
+ assert3.equal(retrieved.size, png.length);
1087
+ assert3.equal(retrieved.group, group);
1088
+ assert3.deepEqual(
1089
+ (await list(group)).map((item) => item.mediaId),
1090
+ [mediaId]
1091
+ );
1092
+ await checkTotals(png.length, 1);
1093
+ log("PASS seal_media, get_media, list_media and storage/count totals");
1094
+ const deleted = await client.callTool("delete_media", { mediaId });
1095
+ assert3.equal(deleted.message, "success");
1096
+ pending.delete(mediaId);
1097
+ const missing = await client.callToolResult("get_media", { mediaId });
1098
+ assert3.equal(
1099
+ missing.isError,
1100
+ true,
1101
+ "Deleted media must not be retrievable"
1102
+ );
1103
+ assert3.deepEqual(await list(group), []);
1104
+ await checkTotals(0, 0);
1105
+ log("PASS delete_media and restored storage/count totals");
1106
+ } catch (error) {
1107
+ failure = error;
1108
+ } finally {
1109
+ try {
1110
+ await restoreMediaSettings();
1111
+ } catch (error) {
1112
+ cleanupErrors.push(error);
1113
+ log(`CLEANUP failed to restore media settings: ${String(error)}`);
1114
+ }
1115
+ for (const mediaId of pending) {
1116
+ try {
1117
+ await client.callTool("delete_media", { mediaId });
1118
+ pending.delete(mediaId);
1119
+ log(`CLEANUP deleted ${mediaId}`);
1120
+ } catch (error) {
1121
+ cleanupErrors.push(error);
1122
+ log(`CLEANUP FAILED for ${mediaId}: ${String(error)}`);
1123
+ }
1124
+ }
1125
+ if (uploadAttempted && baseline && baselineCount !== void 0) {
1126
+ try {
1127
+ assert3.deepEqual(
1128
+ await list(group),
1129
+ [],
1130
+ "Run media remains after cleanup"
1131
+ );
1132
+ await checkTotals(0, 0);
1133
+ } catch (error) {
1134
+ cleanupErrors.push(error);
1135
+ log(`CLEANUP verification failed: ${String(error)}`);
1136
+ }
1137
+ }
1138
+ try {
1139
+ await client.close();
1140
+ } catch (error) {
1141
+ cleanupErrors.push(error);
1142
+ log(`CLEANUP failed to close MCP session: ${String(error)}`);
1143
+ }
1144
+ }
1145
+ if (failure) throw failure;
1146
+ if (cleanupErrors.length) {
1147
+ throw new AggregateError(
1148
+ cleanupErrors,
1149
+ "MCP integration cleanup failed"
1150
+ );
1151
+ }
1152
+ log("PASS MediaLit MCP lifecycle; media and session cleaned up");
1153
+ }
1154
+
1155
+ // src/rest.ts
1156
+ import assert4 from "node:assert/strict";
1157
+ import { randomUUID as randomUUID4 } from "node:crypto";
1158
+ function object3(value) {
1159
+ assert4(
1160
+ value && typeof value === "object" && !Array.isArray(value),
1161
+ "Expected a JSON object"
1162
+ );
1163
+ return value;
1164
+ }
1165
+ function text(value, label) {
1166
+ assert4(
1167
+ typeof value === "string" && value.length > 0,
1168
+ `${label} must be a nonempty string`
1169
+ );
1170
+ return value;
1171
+ }
1172
+ function integer3(value, label) {
1173
+ assert4(
1174
+ typeof value === "number" && Number.isSafeInteger(value) && value >= 0,
1175
+ `${label} must be a nonnegative integer`
1176
+ );
1177
+ return value;
1178
+ }
1179
+ async function runIntegrationTests(options = {}) {
1180
+ const apiKey = options.apiKey ?? process.env.MEDIALIT_APIKEY;
1181
+ assert4(apiKey?.trim(), "MEDIALIT_APIKEY is required");
1182
+ const server = options.server ?? process.env.MEDIALIT_SERVER ?? "localhost:8000";
1183
+ const base = new URL(
1184
+ /^https?:\/\//i.test(server) ? server : `http://${server}`
1185
+ );
1186
+ assert4(
1187
+ ["http:", "https:"].includes(base.protocol),
1188
+ "MEDIALIT_SERVER must use HTTP or HTTPS"
1189
+ );
1190
+ assert4(
1191
+ !base.username && !base.password && !base.search && !base.hash,
1192
+ "MEDIALIT_SERVER must be a base URL without credentials, query, or fragment"
1193
+ );
1194
+ base.pathname = `${base.pathname.replace(/\/+$/, "")}/`;
1195
+ const log = options.log ?? console.log;
1196
+ const group = `__integration_${randomUUID4()}`;
1197
+ const png = generatePng();
1198
+ const pending = /* @__PURE__ */ new Set();
1199
+ const uploads = [];
1200
+ let baseline;
1201
+ let baselineCount;
1202
+ let uploadsAttempted = false;
1203
+ let failure;
1204
+ const cleanupErrors = [];
1205
+ function url(path) {
1206
+ return new URL(path.replace(/^\//, ""), base);
1207
+ }
1208
+ async function request(target, init, status = 200) {
1209
+ const response = await fetch(target, {
1210
+ ...init,
1211
+ redirect: "error",
1212
+ signal: AbortSignal.timeout(3e4)
1213
+ });
1214
+ if (response.status !== status) {
1215
+ const detail = (await response.text()).split(apiKey).join("[redacted]");
1216
+ throw new Error(
1217
+ `${init.method ?? "GET"} ${target.pathname}: expected HTTP ${status}, got ${response.status}: ${detail.slice(0, 1e3)}`
1218
+ );
1219
+ }
1220
+ return response;
1221
+ }
1222
+ async function api(path, body, method = "POST") {
1223
+ const response = await request(url(path), {
1224
+ method,
1225
+ headers: {
1226
+ "x-medialit-apikey": apiKey,
1227
+ "content-type": "application/json"
1228
+ },
1229
+ ...body ? { body: JSON.stringify(body) } : {}
1230
+ });
1231
+ return response.json();
1232
+ }
1233
+ async function list(filters = {}) {
1234
+ const result = await api("media/get", {
1235
+ page: 1,
1236
+ limit: 100,
1237
+ ...filters
1238
+ });
1239
+ assert4(Array.isArray(result), "List media must return an array");
1240
+ return result.map(object3);
1241
+ }
1242
+ async function stats() {
1243
+ const result = object3(await api("media/get/size"));
1244
+ return {
1245
+ storage: integer3(result.storage, "storage"),
1246
+ maxStorage: integer3(result.maxStorage, "maxStorage")
1247
+ };
1248
+ }
1249
+ async function count() {
1250
+ return integer3(object3(await api("media/get/count")).count, "count");
1251
+ }
1252
+ async function checkTotals(bytes, files) {
1253
+ assert4(baseline && baselineCount !== void 0);
1254
+ const current = await stats();
1255
+ assert4.equal(
1256
+ current.maxStorage,
1257
+ baseline.maxStorage,
1258
+ "Storage quota changed during the run"
1259
+ );
1260
+ assert4.equal(
1261
+ current.storage,
1262
+ baseline.storage + bytes,
1263
+ "Occupied storage delta is incorrect (check for concurrent writes)"
1264
+ );
1265
+ assert4.equal(
1266
+ current.maxStorage - current.storage,
1267
+ baseline.maxStorage - baseline.storage - bytes,
1268
+ "Available storage delta is incorrect"
1269
+ );
1270
+ assert4.equal(
1271
+ await count(),
1272
+ baselineCount + files,
1273
+ "Media count delta is incorrect"
1274
+ );
1275
+ }
1276
+ function track(value) {
1277
+ const media = object3(value);
1278
+ const id = text(media.mediaId, "mediaId");
1279
+ assert4(!pending.has(id), "Upload returned a duplicate mediaId");
1280
+ pending.add(id);
1281
+ return media;
1282
+ }
1283
+ function checkMedia(media, expected) {
1284
+ assert4.equal(media.mediaId, expected.mediaId);
1285
+ assert4.equal(media.originalFileName, expected.filename);
1286
+ assert4.equal(media.size, png.length);
1287
+ assert4.equal(media.access, expected.access);
1288
+ assert4.equal(media.group, group);
1289
+ assert4.equal(media.caption, expected.caption);
1290
+ assert4(
1291
+ ["image/png", "image/webp"].includes(
1292
+ text(media.mimeType, "mimeType")
1293
+ ),
1294
+ "Unexpected image MIME type"
1295
+ );
1296
+ const file = new URL(text(media.file, "file URL"));
1297
+ assert4(["http:", "https:"].includes(file.protocol));
1298
+ }
1299
+ async function download(media) {
1300
+ const response = await fetch(text(media.file, "file URL"), {
1301
+ signal: AbortSignal.timeout(3e4)
1302
+ });
1303
+ assert4.equal(response.status, 200, "File download failed");
1304
+ const bytes = Buffer.from(await response.arrayBuffer());
1305
+ if (media.mimeType === "image/png") {
1306
+ assert4.deepEqual(
1307
+ bytes,
1308
+ png,
1309
+ "Downloaded PNG differs from the uploaded bytes"
1310
+ );
1311
+ } else {
1312
+ assert4.equal(bytes.toString("ascii", 0, 4), "RIFF");
1313
+ assert4.equal(bytes.toString("ascii", 8, 12), "WEBP");
1314
+ assert4(bytes.length > 12, "Downloaded WebP is empty");
1315
+ }
1316
+ }
1317
+ async function signature() {
1318
+ return text(
1319
+ object3(await api("media/signature/create", { group })).signature,
1320
+ "signature"
1321
+ );
1322
+ }
1323
+ async function upload(tus, signed, access) {
1324
+ const label = `${tus ? "tus" : "multipart"}-${signed ? "signature" : "apikey"}`;
1325
+ const filename = `${label}.png`;
1326
+ const caption = `${group}:${label}`;
1327
+ const headers = signed ? { "x-medialit-signature": await signature() } : { "x-medialit-apikey": apiKey };
1328
+ uploadsAttempted = true;
1329
+ let media;
1330
+ if (tus) {
1331
+ const metadata = {
1332
+ fileName: filename,
1333
+ mimeType: "image/png",
1334
+ access,
1335
+ caption,
1336
+ group
1337
+ };
1338
+ const tusHeaders = { ...headers, "Tus-Resumable": "1.0.0" };
1339
+ const created = await request(
1340
+ url("media/create/resumable"),
1341
+ {
1342
+ method: "POST",
1343
+ headers: {
1344
+ ...tusHeaders,
1345
+ "Upload-Length": String(png.length),
1346
+ "Upload-Metadata": Object.entries(metadata).map(
1347
+ ([key, value]) => `${key} ${Buffer.from(value).toString("base64")}`
1348
+ ).join(",")
1349
+ }
1350
+ },
1351
+ 201
1352
+ );
1353
+ const location = new URL(
1354
+ text(created.headers.get("location"), "tus Location"),
1355
+ url("media/create/resumable")
1356
+ );
1357
+ assert4.equal(
1358
+ location.origin,
1359
+ base.origin,
1360
+ "tus Location points to a different origin"
1361
+ );
1362
+ const split = Math.floor(png.length / 2);
1363
+ await request(
1364
+ location,
1365
+ {
1366
+ method: "PATCH",
1367
+ headers: {
1368
+ ...tusHeaders,
1369
+ "content-type": "application/offset+octet-stream",
1370
+ "Upload-Offset": "0"
1371
+ },
1372
+ body: Uint8Array.from(png.subarray(0, split))
1373
+ },
1374
+ 204
1375
+ );
1376
+ const head = await request(location, {
1377
+ method: "HEAD",
1378
+ headers: tusHeaders
1379
+ });
1380
+ assert4.equal(head.headers.get("upload-offset"), String(split));
1381
+ assert4.equal(head.headers.get("upload-length"), String(png.length));
1382
+ const completed = await request(
1383
+ location,
1384
+ {
1385
+ method: "PATCH",
1386
+ headers: {
1387
+ ...tusHeaders,
1388
+ "content-type": "application/offset+octet-stream",
1389
+ "Upload-Offset": String(split)
1390
+ },
1391
+ body: Uint8Array.from(png.subarray(split))
1392
+ },
1393
+ 204
1394
+ );
1395
+ media = track(
1396
+ JSON.parse(
1397
+ text(completed.headers.get("media"), "tus media header")
1398
+ )
1399
+ );
1400
+ assert4.equal(
1401
+ completed.headers.get("upload-offset"),
1402
+ String(png.length)
1403
+ );
1404
+ } else {
1405
+ const form = new FormData();
1406
+ form.set(
1407
+ "file",
1408
+ new Blob([Uint8Array.from(png)], { type: "image/png" }),
1409
+ filename
1410
+ );
1411
+ form.set("access", access);
1412
+ form.set("caption", caption);
1413
+ if (!signed) form.set("group", group);
1414
+ media = track(
1415
+ await (await request(url("media/create"), {
1416
+ method: "POST",
1417
+ headers,
1418
+ body: form
1419
+ })).json()
1420
+ );
1421
+ }
1422
+ const expected = {
1423
+ mediaId: text(media.mediaId, "mediaId"),
1424
+ filename,
1425
+ caption,
1426
+ access
1427
+ };
1428
+ uploads.push(expected);
1429
+ checkMedia(media, expected);
1430
+ const retrieved = object3(
1431
+ await api(`media/get/${encodeURIComponent(expected.mediaId)}`)
1432
+ );
1433
+ checkMedia(retrieved, expected);
1434
+ await download(retrieved);
1435
+ await checkTotals(0, 0);
1436
+ log(`PASS upload and get ${label} (${access})`);
1437
+ }
1438
+ log(
1439
+ `MediaLit integration run ${group} against ${base.origin}${base.pathname}`
1440
+ );
1441
+ try {
1442
+ baseline = await stats();
1443
+ baselineCount = await count();
1444
+ assert4(
1445
+ baseline.maxStorage - baseline.storage >= png.length * 4,
1446
+ "Insufficient available storage for four PNGs"
1447
+ );
1448
+ await list();
1449
+ assert4.deepEqual(
1450
+ await list({ group }),
1451
+ [],
1452
+ "Run group already contains media"
1453
+ );
1454
+ log("PASS list media and baseline storage/count");
1455
+ await upload(false, false, "public");
1456
+ await upload(false, true, "private");
1457
+ await upload(true, false, "private");
1458
+ await upload(true, true, "public");
1459
+ assert4.deepEqual(
1460
+ await list({ group }),
1461
+ [],
1462
+ "Unsealed uploads should not be listed"
1463
+ );
1464
+ for (let index = 0; index < uploads.length; index++) {
1465
+ const expected = uploads[index];
1466
+ checkMedia(
1467
+ object3(
1468
+ await api(
1469
+ `media/seal/${encodeURIComponent(expected.mediaId)}`
1470
+ )
1471
+ ),
1472
+ expected
1473
+ );
1474
+ await checkTotals(png.length * (index + 1), index + 1);
1475
+ log(`PASS seal ${expected.filename} and storage decrement`);
1476
+ }
1477
+ const listed = await list({ group });
1478
+ assert4.deepEqual(
1479
+ listed.map((media) => media.mediaId).sort(),
1480
+ uploads.map((media) => media.mediaId).sort(),
1481
+ "List must contain all run media"
1482
+ );
1483
+ for (const access of ["public", "private"]) {
1484
+ assert4.deepEqual(
1485
+ (await list({ group, access })).map((media) => media.mediaId).sort(),
1486
+ uploads.filter((media) => media.access === access).map((media) => media.mediaId).sort()
1487
+ );
1488
+ }
1489
+ const paginated = [];
1490
+ for (let page = 1; page <= uploads.length + 1; page++) {
1491
+ const items = await list({ group, page, limit: 1 });
1492
+ assert4.equal(items.length, page <= uploads.length ? 1 : 0);
1493
+ paginated.push(...items.map((media) => media.mediaId));
1494
+ }
1495
+ assert4.deepEqual(
1496
+ paginated.sort(),
1497
+ uploads.map((media) => media.mediaId).sort()
1498
+ );
1499
+ for (const expected of uploads) {
1500
+ const media = object3(
1501
+ await api(`media/get/${encodeURIComponent(expected.mediaId)}`)
1502
+ );
1503
+ checkMedia(media, expected);
1504
+ await download(media);
1505
+ }
1506
+ log(
1507
+ "PASS list, pagination, access filters, get and download all sealed media"
1508
+ );
1509
+ for (let index = 0; index < uploads.length; index++) {
1510
+ const { mediaId } = uploads[index];
1511
+ assert4.equal(
1512
+ object3(
1513
+ await api(
1514
+ `media/delete/${encodeURIComponent(mediaId)}`,
1515
+ void 0,
1516
+ "DELETE"
1517
+ )
1518
+ ).message,
1519
+ "success"
1520
+ );
1521
+ pending.delete(mediaId);
1522
+ await request(
1523
+ url(`media/get/${encodeURIComponent(mediaId)}`),
1524
+ { method: "POST", headers: { "x-medialit-apikey": apiKey } },
1525
+ 404
1526
+ );
1527
+ await checkTotals(
1528
+ png.length * (uploads.length - index - 1),
1529
+ uploads.length - index - 1
1530
+ );
1531
+ log(`PASS delete ${mediaId} and storage increment`);
1532
+ }
1533
+ assert4.deepEqual(await list({ group }), []);
1534
+ } catch (error) {
1535
+ failure = error;
1536
+ } finally {
1537
+ for (const mediaId of pending) {
1538
+ try {
1539
+ await api(
1540
+ `media/delete/${encodeURIComponent(mediaId)}`,
1541
+ void 0,
1542
+ "DELETE"
1543
+ );
1544
+ pending.delete(mediaId);
1545
+ log(`CLEANUP deleted ${mediaId}`);
1546
+ } catch (error) {
1547
+ cleanupErrors.push(error);
1548
+ log(`CLEANUP FAILED for ${mediaId}: ${String(error)}`);
1549
+ }
1550
+ }
1551
+ if (uploadsAttempted && baseline && baselineCount !== void 0) {
1552
+ try {
1553
+ assert4.deepEqual(
1554
+ await list({ group }),
1555
+ [],
1556
+ "Run media remains after cleanup"
1557
+ );
1558
+ await checkTotals(0, 0);
1559
+ } catch (error) {
1560
+ cleanupErrors.push(error);
1561
+ log(`CLEANUP verification failed: ${String(error)}`);
1562
+ }
1563
+ }
1564
+ }
1565
+ if (failure) throw failure;
1566
+ if (cleanupErrors.length)
1567
+ throw new AggregateError(cleanupErrors, "Integration cleanup failed");
1568
+ log(
1569
+ "PASS MediaLit public API lifecycle; storage and count restored to baseline"
1570
+ );
1571
+ }
1572
+
1573
+ // src/index.ts
1574
+ async function runAllIntegrationTests() {
1575
+ const suites = [
1576
+ ["REST", runIntegrationTests],
1577
+ ["MCP", runMcpIntegrationTests],
1578
+ ["CLI", runCliIntegrationTests]
1579
+ ];
1580
+ const failed = [];
1581
+ for (const [name, run] of suites) {
1582
+ console.log(`
1583
+ === ${name} ===`);
1584
+ try {
1585
+ await run();
1586
+ } catch (error) {
1587
+ failed.push(name);
1588
+ console.error(
1589
+ `FAIL ${name}: ${error instanceof Error ? error.message : String(error)}`
1590
+ );
1591
+ }
1592
+ }
1593
+ console.log(
1594
+ failed.length ? `
1595
+ ${failed.length} of ${suites.length} suites failed: ${failed.join(", ")}` : `
1596
+ All ${suites.length} suites passed`
1597
+ );
1598
+ return failed.length === 0;
1599
+ }
1600
+
1601
+ // src/bin.ts
1602
+ var HELP = `Usage: medialit-integration-tests <command>
1603
+
1604
+ Commands:
1605
+ synthetic Quick check that MediaLit is up: upload, seal, download and
1606
+ delete one file. Safe to run against production every few
1607
+ minutes.
1608
+ all Run the REST, MCP and CLI suites
1609
+ rest Run the REST API suite
1610
+ mcp Run the MCP server suite
1611
+ cli Run the CLI suite (set MEDIALIT_CLI to the medialit command)
1612
+
1613
+ The suites change settings and create files, so run them against a test app.
1614
+
1615
+ Environment:
1616
+ MEDIALIT_APIKEY API key of the app to test (required)
1617
+ MEDIALIT_SERVER MediaLit URL (default: localhost:8000)
1618
+ MEDIALIT_SYNTHETIC_HEARTBEAT_URL synthetic: heartbeat to ping after each
1619
+ run, <url>/fail on failure
1620
+ `;
1621
+ async function main(command) {
1622
+ switch (command) {
1623
+ case "synthetic":
1624
+ return (await runSyntheticCheck()).ok;
1625
+ case "all":
1626
+ return runAllIntegrationTests();
1627
+ case "rest":
1628
+ await runIntegrationTests();
1629
+ return true;
1630
+ case "mcp":
1631
+ await runMcpIntegrationTests();
1632
+ return true;
1633
+ case "cli":
1634
+ await runCliIntegrationTests();
1635
+ return true;
1636
+ case void 0:
1637
+ case "help":
1638
+ case "--help":
1639
+ case "-h":
1640
+ console.log(HELP);
1641
+ return true;
1642
+ default:
1643
+ console.error(`Unknown command "${command}".
1644
+
1645
+ ${HELP}`);
1646
+ return false;
1647
+ }
1648
+ }
1649
+ main(process.argv[2]).then((ok) => {
1650
+ if (!ok) process.exitCode = 1;
1651
+ }).catch((error) => {
1652
+ console.error(
1653
+ `FAIL: ${error instanceof Error ? error.message : String(error)}`
1654
+ );
1655
+ process.exitCode = 1;
1656
+ });