lucille-node 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/STORAGE.md +419 -0
- package/data/lucille/50a84a5a-6b2b-492e-b279-61eddb392e34_video:test-video-1773549/834/196 +1 -0
- package/data/lucille/dfa790d7-c4f5-43a9-b7ff-f166f0f344c9_video:test-video-1773549/541/230 +1 -0
- package/data/lucille/k/eys +1 -0
- package/data/lucille/pubKey_02b0071b6d90ce7598eabdcfa0596f95359799fd413e300dcbdca823095a/4cf/884 +1 -0
- package/data/lucille/pubKey_030e4e2f145c4b68863ca42868e08b1559c3c9c10774e61c3f4ef9766d0c/df7/e30 +1 -0
- package/data/lucille/user_50a84a5a-6b2b-492e-b279-61eddb/392/e34 +1 -0
- package/data/lucille/user_dfa790d7-c4f5-43a9-b7ff-f166f0/f34/4c9 +1 -0
- package/data/lucille/videos_50a84a5a-6b2b-492e-b279-61eddb/392/e34 +1 -0
- package/data/lucille/videos_dfa790d7-c4f5-43a9-b7ff-f166f0/f34/4c9 +1 -0
- package/integration-test.js +346 -0
- package/lucille.js +498 -0
- package/package.json +29 -0
- package/src/config.js +104 -0
- package/src/persistence/client.js +83 -0
- package/src/persistence/db.js +105 -0
- package/src/seeder.js +112 -0
- package/src/spaces.js +68 -0
- package/src/tracker.js +49 -0
- package/templates/player.html +236 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Lucille Integration Tests
|
|
4
|
+
*
|
|
5
|
+
* Tests all API endpoints against a running lucille instance.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node --env-file=.env integration-test.js # defaults to http://localhost:5444
|
|
9
|
+
* node --env-file=.env integration-test.js http://localhost:5444
|
|
10
|
+
* SERVER_URL=https://lucille.example.com node integration-test.js
|
|
11
|
+
*
|
|
12
|
+
* Options:
|
|
13
|
+
* --skip-upload Skip the video file upload test (skips DO Spaces + seeder)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import sessionless from 'sessionless-node';
|
|
17
|
+
|
|
18
|
+
const SERVER_URL = (process.argv[2] && !process.argv[2].startsWith('--'))
|
|
19
|
+
? process.argv[2].replace(/\/$/, '')
|
|
20
|
+
: (process.env.SERVER_URL || 'http://localhost:5444');
|
|
21
|
+
|
|
22
|
+
const SKIP_UPLOAD = process.argv.includes('--skip-upload');
|
|
23
|
+
|
|
24
|
+
// ── result tracking ───────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
let passed = 0;
|
|
27
|
+
let failed = 0;
|
|
28
|
+
const failures = [];
|
|
29
|
+
|
|
30
|
+
function pass(name) {
|
|
31
|
+
passed++;
|
|
32
|
+
console.log(` ✅ ${name}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fail(name, reason) {
|
|
36
|
+
failed++;
|
|
37
|
+
failures.push({ name, reason });
|
|
38
|
+
console.log(` ❌ ${name}: ${reason}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function section(title) {
|
|
42
|
+
console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 50 - title.length))}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
async function get(route) {
|
|
48
|
+
const res = await fetch(`${SERVER_URL}${route}`);
|
|
49
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function put(route, body) {
|
|
53
|
+
const res = await fetch(`${SERVER_URL}${route}`, {
|
|
54
|
+
method: 'PUT',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify(body),
|
|
57
|
+
});
|
|
58
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function post(route, body) {
|
|
62
|
+
const res = await fetch(`${SERVER_URL}${route}`, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: { 'Content-Type': 'application/json' },
|
|
65
|
+
body: JSON.stringify(body),
|
|
66
|
+
});
|
|
67
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function putFile(route, fileBuffer, filename, timestamp, signature) {
|
|
71
|
+
const form = new FormData();
|
|
72
|
+
const blob = new Blob([fileBuffer], { type: 'video/mp4' });
|
|
73
|
+
form.append('video', blob, filename);
|
|
74
|
+
|
|
75
|
+
const res = await fetch(`${SERVER_URL}${route}`, {
|
|
76
|
+
method: 'PUT',
|
|
77
|
+
headers: {
|
|
78
|
+
'x-pn-timestamp': timestamp,
|
|
79
|
+
'x-pn-signature': signature,
|
|
80
|
+
},
|
|
81
|
+
body: form,
|
|
82
|
+
});
|
|
83
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── assertion helpers ─────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
function assertOk(name, { status, body }, extraCheck) {
|
|
89
|
+
if (status !== 200) {
|
|
90
|
+
fail(name, `HTTP ${status} — ${JSON.stringify(body).slice(0, 120)}`);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
if (extraCheck) {
|
|
94
|
+
const err = extraCheck(body);
|
|
95
|
+
if (err) { fail(name, err); return false; }
|
|
96
|
+
}
|
|
97
|
+
pass(name);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function assertStatus(name, { status }, expected) {
|
|
102
|
+
if (status !== expected) {
|
|
103
|
+
fail(name, `expected HTTP ${expected}, got ${status}`);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
pass(name);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── sessionless helper ────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
async function makeKeys() {
|
|
113
|
+
let keys = null;
|
|
114
|
+
sessionless.getKeys = async () => keys;
|
|
115
|
+
await sessionless.generateKeys((k) => { keys = k; }, sessionless.getKeys);
|
|
116
|
+
return keys;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function sign(message) {
|
|
120
|
+
return sessionless.sign(message);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── tests ─────────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
async function testHealth() {
|
|
126
|
+
section('Health');
|
|
127
|
+
|
|
128
|
+
const r = await get('/health');
|
|
129
|
+
assertOk('GET /health returns ok', r, b => b.status === 'ok' ? null : `status=${b.status}`);
|
|
130
|
+
assertOk('GET /health has service=lucille', r, b => b.service === 'lucille' ? null : `service=${b.service}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function testStatus() {
|
|
134
|
+
section('Seeder Status');
|
|
135
|
+
|
|
136
|
+
const r = await get('/status');
|
|
137
|
+
assertOk('GET /status returns torrents array', r, b =>
|
|
138
|
+
Array.isArray(b.torrents) ? null : 'torrents is not an array');
|
|
139
|
+
assertOk('GET /status has totalPeers', r, b =>
|
|
140
|
+
typeof b.totalPeers === 'number' ? null : 'missing totalPeers');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function testUser() {
|
|
144
|
+
section('User');
|
|
145
|
+
|
|
146
|
+
const keys = await makeKeys();
|
|
147
|
+
|
|
148
|
+
if (!keys?.pubKey) {
|
|
149
|
+
fail('generate sessionless keys', 'generateKeys() did not produce keys');
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const pubKey = keys.pubKey;
|
|
154
|
+
|
|
155
|
+
// ── create user ──
|
|
156
|
+
const ts1 = Date.now().toString();
|
|
157
|
+
const sig1 = await sign(ts1 + pubKey);
|
|
158
|
+
|
|
159
|
+
const r1 = await put('/user/create', { timestamp: ts1, pubKey, signature: sig1 });
|
|
160
|
+
if (!assertOk('PUT /user/create returns uuid', r1, b => b.uuid ? null : `missing uuid`)) return null;
|
|
161
|
+
assertOk('PUT /user/create returns pubKey', r1, b => b.pubKey === pubKey ? null : `pubKey mismatch`);
|
|
162
|
+
|
|
163
|
+
const uuid = r1.body.uuid;
|
|
164
|
+
|
|
165
|
+
// ── create again returns same user (idempotent) ──
|
|
166
|
+
const ts2 = Date.now().toString();
|
|
167
|
+
const sig2 = await sign(ts2 + pubKey);
|
|
168
|
+
const r2 = await put('/user/create', { timestamp: ts2, pubKey, signature: sig2 });
|
|
169
|
+
assertOk('PUT /user/create is idempotent', r2, b => b.uuid === uuid ? null : `uuid changed: ${b.uuid}`);
|
|
170
|
+
|
|
171
|
+
// ── bad signature ──
|
|
172
|
+
const ts3 = Date.now().toString();
|
|
173
|
+
const r3 = await put('/user/create', { timestamp: ts3, pubKey, signature: 'badsig' });
|
|
174
|
+
assertStatus('PUT /user/create rejects bad signature → 401', r3, 401);
|
|
175
|
+
|
|
176
|
+
// ── missing fields ──
|
|
177
|
+
const r4 = await put('/user/create', { pubKey });
|
|
178
|
+
assertStatus('PUT /user/create rejects missing fields → 400', r4, 400);
|
|
179
|
+
|
|
180
|
+
return { uuid, pubKey, keys };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function testVideoMetadata(uuid, pubKey) {
|
|
184
|
+
section('Video Metadata');
|
|
185
|
+
|
|
186
|
+
const title = `test-video-${Date.now()}`;
|
|
187
|
+
|
|
188
|
+
// ── create video ──
|
|
189
|
+
const ts1 = Date.now().toString();
|
|
190
|
+
const sig1 = await sign(ts1 + pubKey);
|
|
191
|
+
const r1 = await put(`/user/${uuid}/video/${title}`, {
|
|
192
|
+
timestamp: ts1,
|
|
193
|
+
signature: sig1,
|
|
194
|
+
description: 'A test video',
|
|
195
|
+
tags: ['test', 'integration']
|
|
196
|
+
});
|
|
197
|
+
if (!assertOk('PUT /user/:uuid/video/:title creates video', r1,
|
|
198
|
+
b => b.videoId ? null : 'missing videoId')) return null;
|
|
199
|
+
assertOk('PUT /user/:uuid/video/:title has correct title', r1,
|
|
200
|
+
b => b.title === title ? null : `title=${b.title}`);
|
|
201
|
+
assertOk('PUT /user/:uuid/video/:title has description', r1,
|
|
202
|
+
b => b.description === 'A test video' ? null : `description=${b.description}`);
|
|
203
|
+
|
|
204
|
+
const videoId = r1.body.videoId;
|
|
205
|
+
|
|
206
|
+
// ── get videos (authed) ──
|
|
207
|
+
const ts2 = Date.now().toString();
|
|
208
|
+
const sig2 = await sign(ts2 + pubKey);
|
|
209
|
+
const r2 = await get(`/user/${uuid}/videos?timestamp=${ts2}&signature=${sig2}`);
|
|
210
|
+
assertOk('GET /user/:uuid/videos returns video', r2,
|
|
211
|
+
b => b[title] ? null : `video "${title}" not found in listing`);
|
|
212
|
+
|
|
213
|
+
// ── get videos (public) ──
|
|
214
|
+
const r3 = await get(`/videos/${uuid}`);
|
|
215
|
+
assertOk('GET /videos/:uuid public listing returns video', r3,
|
|
216
|
+
b => b[title] ? null : `video "${title}" not found in public listing`);
|
|
217
|
+
assertOk('GET /videos/:uuid strips no public fields', r3,
|
|
218
|
+
b => b[title]?.videoId === videoId ? null : 'videoId missing from public listing');
|
|
219
|
+
|
|
220
|
+
// ── auth required for user-scoped listing ──
|
|
221
|
+
const r4 = await get(`/user/${uuid}/videos`);
|
|
222
|
+
assertStatus('GET /user/:uuid/videos without auth → 400 or 401', r4, r4.status === 400 ? 400 : 401);
|
|
223
|
+
|
|
224
|
+
return { title, videoId };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function testVideoUpload(uuid, pubKey, title) {
|
|
228
|
+
section('Video File Upload');
|
|
229
|
+
|
|
230
|
+
if (SKIP_UPLOAD) {
|
|
231
|
+
console.log(' ⏩ skipped (--skip-upload)');
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Create a small dummy binary buffer as a stand-in for a video file
|
|
236
|
+
const dummyVideo = Buffer.alloc(1024 * 64, 0x00); // 64KB of zeros
|
|
237
|
+
|
|
238
|
+
const ts = Date.now().toString();
|
|
239
|
+
const sig = await sign(ts + pubKey);
|
|
240
|
+
|
|
241
|
+
const r = await putFile(
|
|
242
|
+
`/user/${uuid}/video/${title}/file`,
|
|
243
|
+
dummyVideo,
|
|
244
|
+
`${title}.mp4`,
|
|
245
|
+
ts,
|
|
246
|
+
sig
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (!assertOk('PUT /user/:uuid/video/:title/file uploads to Spaces', r,
|
|
250
|
+
b => b.spacesKey ? null : `missing spacesKey, got: ${JSON.stringify(b).slice(0, 120)}`)) return false;
|
|
251
|
+
assertOk('PUT /user/:uuid/video/:title/file returns cdnUrl', r,
|
|
252
|
+
b => b.cdnUrl ? null : 'missing cdnUrl');
|
|
253
|
+
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function testWatch(videoId, expectMagnet) {
|
|
258
|
+
section('Watch / Magnet');
|
|
259
|
+
|
|
260
|
+
if (!expectMagnet) {
|
|
261
|
+
// No upload was done — endpoint should 404 or return no spacesKey yet
|
|
262
|
+
const r = await get(`/video/${videoId}/watch`);
|
|
263
|
+
assertStatus('GET /video/:videoId/watch without upload → 404', r, 404);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Give seeder a moment to seed the newly uploaded file
|
|
268
|
+
console.log(' ⏳ waiting 3s for seeder...');
|
|
269
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
270
|
+
|
|
271
|
+
const r = await get(`/video/${videoId}/watch`);
|
|
272
|
+
assertOk('GET /video/:videoId/watch returns magnetURI', r,
|
|
273
|
+
b => b.magnetURI ? null : `missing magnetURI, got: ${JSON.stringify(b).slice(0, 120)}`);
|
|
274
|
+
assertOk('GET /video/:videoId/watch returns trackerUrl', r,
|
|
275
|
+
b => b.trackerUrl ? null : 'missing trackerUrl');
|
|
276
|
+
assertOk('GET /video/:videoId/watch returns title', r,
|
|
277
|
+
b => b.title ? null : 'missing title');
|
|
278
|
+
|
|
279
|
+
// ── not found ──
|
|
280
|
+
const r2 = await get('/video/nonexistent-video-id-00000/watch');
|
|
281
|
+
assertStatus('GET /video/:videoId/watch with bad id → 404', r2, 404);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function testSeedEndpoint() {
|
|
285
|
+
section('Seed API');
|
|
286
|
+
|
|
287
|
+
// ── missing key ──
|
|
288
|
+
const r1 = await post('/seed', {});
|
|
289
|
+
assertStatus('POST /seed without key → 400', r1, 400);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── main ──────────────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
async function main() {
|
|
295
|
+
console.log(`\n🎬 Lucille Integration Tests`);
|
|
296
|
+
console.log(` Target: ${SERVER_URL}`);
|
|
297
|
+
console.log(` Time: ${new Date().toISOString()}`);
|
|
298
|
+
if (SKIP_UPLOAD) console.log(' Upload: skipped (--skip-upload)');
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
await testHealth();
|
|
302
|
+
await testStatus();
|
|
303
|
+
|
|
304
|
+
const user = await testUser();
|
|
305
|
+
if (!user) {
|
|
306
|
+
console.error('\n💥 User creation failed — skipping remaining tests');
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const { uuid, pubKey } = user;
|
|
311
|
+
const video = await testVideoMetadata(uuid, pubKey);
|
|
312
|
+
if (!video) {
|
|
313
|
+
console.error('\n💥 Video metadata failed — skipping upload/watch tests');
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const { title, videoId } = video;
|
|
318
|
+
const uploaded = await testVideoUpload(uuid, pubKey, title);
|
|
319
|
+
await testWatch(videoId, uploaded);
|
|
320
|
+
await testSeedEndpoint();
|
|
321
|
+
|
|
322
|
+
} catch (err) {
|
|
323
|
+
console.error('\n💥 Unexpected error during test run:', err.message);
|
|
324
|
+
if (err.cause?.code === 'ECONNREFUSED') {
|
|
325
|
+
console.error(` Server not reachable at ${SERVER_URL}`);
|
|
326
|
+
console.error(' Make sure lucille is running: node --env-file=.env lucille.js');
|
|
327
|
+
} else {
|
|
328
|
+
console.error(err);
|
|
329
|
+
}
|
|
330
|
+
process.exit(2);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── summary ──
|
|
334
|
+
console.log(`\n${'═'.repeat(52)}`);
|
|
335
|
+
const total = passed + failed;
|
|
336
|
+
console.log(`Results: ${passed}/${total} passed`);
|
|
337
|
+
if (failures.length > 0) {
|
|
338
|
+
console.log('\nFailed tests:');
|
|
339
|
+
failures.forEach(f => console.log(` ❌ ${f.name}: ${f.reason}`));
|
|
340
|
+
}
|
|
341
|
+
console.log('');
|
|
342
|
+
|
|
343
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
main();
|