@digitalpaws/starr 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.rst +75 -0
- package/data/.nfs.811bbb0d.43c34.4 +0 -0
- package/data/.nfs.81ba4cd0.ffff87804.4 +0 -0
- package/data/.nfs.82d127f7.fffff5704.4 +0 -0
- package/data/.nfs.82d127fe.fffff5704.4 +0 -0
- package/data/.nfs.82d159a5.fffff5aa4.4 +0 -0
- package/data/.nfs.82d159aa.fffff5aa4.4 +0 -0
- package/data/.nfs.82d15a9c.fffff5bb4.4 +0 -0
- package/data/.nfs.82d15aa1.fffff5bb4.4 +0 -0
- package/data/.nfs.82d15c65.fffff5c24.4 +0 -0
- package/data/.nfs.82d1852c.fffff5e24.4 +0 -0
- package/data/.nfs.82d18536.fffff5e24.4 +0 -0
- package/data/.nfs.82d18dbe.fffff5e44.4 +0 -0
- package/data/.nfs.82d18dc6.fffff5e44.4 +0 -0
- package/data/.nfs.8309a3f5.fffff82f4.4 +0 -0
- package/data/.nfs.8309a3fe.fffff82f4.4 +0 -0
- package/data/.nfs.8314122e.fffffb044.4 +0 -0
- package/data/.nfs.83141236.fffffb044.4 +0 -0
- package/data/.nfs.833c4fe9.fffffbc34.4 +0 -0
- package/data/.nfs.833c4ff3.fffffbc34.4 +0 -0
- package/data/logs/starr.log +66244 -0
- package/data/starr.db +0 -0
- package/data/starr.db-shm +0 -0
- package/data/starr.db-wal +0 -0
- package/data/starr.jwt +1 -0
- package/data/starr.key +1 -0
- package/dist/auth/auth-service.d.ts +42 -0
- package/dist/auth/auth-service.d.ts.map +1 -0
- package/dist/auth/auth-service.js +226 -0
- package/dist/auth/auth-service.js.map +1 -0
- package/dist/auth/http-server.d.ts +16 -0
- package/dist/auth/http-server.d.ts.map +1 -0
- package/dist/auth/http-server.js +207 -0
- package/dist/auth/http-server.js.map +1 -0
- package/dist/auth/jwt-secret.d.ts +6 -0
- package/dist/auth/jwt-secret.d.ts.map +1 -0
- package/dist/auth/jwt-secret.js +51 -0
- package/dist/auth/jwt-secret.js.map +1 -0
- package/dist/auth/oidc/oidc-flow.d.ts +60 -0
- package/dist/auth/oidc/oidc-flow.d.ts.map +1 -0
- package/dist/auth/oidc/oidc-flow.js +201 -0
- package/dist/auth/oidc/oidc-flow.js.map +1 -0
- package/dist/auth/oidc/oidc-provider-service.d.ts +59 -0
- package/dist/auth/oidc/oidc-provider-service.d.ts.map +1 -0
- package/dist/auth/oidc/oidc-provider-service.js +124 -0
- package/dist/auth/oidc/oidc-provider-service.js.map +1 -0
- package/dist/auth/oidc/user-service.d.ts +67 -0
- package/dist/auth/oidc/user-service.d.ts.map +1 -0
- package/dist/auth/oidc/user-service.js +177 -0
- package/dist/auth/oidc/user-service.js.map +1 -0
- package/dist/auth/rate-limiter.d.ts +31 -0
- package/dist/auth/rate-limiter.d.ts.map +1 -0
- package/dist/auth/rate-limiter.js +70 -0
- package/dist/auth/rate-limiter.js.map +1 -0
- package/dist/config/config-service.d.ts +83 -0
- package/dist/config/config-service.d.ts.map +1 -0
- package/dist/config/config-service.js +309 -0
- package/dist/config/config-service.js.map +1 -0
- package/dist/config/validate.d.ts +12 -0
- package/dist/config/validate.d.ts.map +1 -0
- package/dist/config/validate.js +140 -0
- package/dist/config/validate.js.map +1 -0
- package/dist/content-types/registry.d.ts +75 -0
- package/dist/content-types/registry.d.ts.map +1 -0
- package/dist/content-types/registry.js +171 -0
- package/dist/content-types/registry.js.map +1 -0
- package/dist/datastore/db.d.ts +9 -0
- package/dist/datastore/db.d.ts.map +1 -0
- package/dist/datastore/db.js +27 -0
- package/dist/datastore/db.js.map +1 -0
- package/dist/datastore/encrypt.d.ts +9 -0
- package/dist/datastore/encrypt.d.ts.map +1 -0
- package/dist/datastore/encrypt.js +84 -0
- package/dist/datastore/encrypt.js.map +1 -0
- package/dist/datastore/migrations.d.ts +10 -0
- package/dist/datastore/migrations.d.ts.map +1 -0
- package/dist/datastore/migrations.js +340 -0
- package/dist/datastore/migrations.js.map +1 -0
- package/dist/datastore/storage.d.ts +4 -0
- package/dist/datastore/storage.d.ts.map +1 -0
- package/dist/datastore/storage.js +60 -0
- package/dist/datastore/storage.js.map +1 -0
- package/dist/events/event-bus.d.ts +15 -0
- package/dist/events/event-bus.d.ts.map +1 -0
- package/dist/events/event-bus.js +140 -0
- package/dist/events/event-bus.js.map +1 -0
- package/dist/events/spine.d.ts +31 -0
- package/dist/events/spine.d.ts.map +1 -0
- package/dist/events/spine.js +68 -0
- package/dist/events/spine.js.map +1 -0
- package/dist/events/trace.d.ts +24 -0
- package/dist/events/trace.d.ts.map +1 -0
- package/dist/events/trace.js +33 -0
- package/dist/events/trace.js.map +1 -0
- package/dist/host.d.ts +65 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +94 -0
- package/dist/host.js.map +1 -0
- package/dist/library/importer.d.ts +29 -0
- package/dist/library/importer.d.ts.map +1 -0
- package/dist/library/importer.js +145 -0
- package/dist/library/importer.js.map +1 -0
- package/dist/library/library-cache.d.ts +46 -0
- package/dist/library/library-cache.d.ts.map +1 -0
- package/dist/library/library-cache.js +152 -0
- package/dist/library/library-cache.js.map +1 -0
- package/dist/library/library-config.d.ts +12 -0
- package/dist/library/library-config.d.ts.map +1 -0
- package/dist/library/library-config.js +10 -0
- package/dist/library/library-config.js.map +1 -0
- package/dist/library/library-scanner.d.ts +62 -0
- package/dist/library/library-scanner.d.ts.map +1 -0
- package/dist/library/library-scanner.js +336 -0
- package/dist/library/library-scanner.js.map +1 -0
- package/dist/library/library-service.d.ts +65 -0
- package/dist/library/library-service.d.ts.map +1 -0
- package/dist/library/library-service.js +197 -0
- package/dist/library/library-service.js.map +1 -0
- package/dist/library/metadata-matcher.d.ts +56 -0
- package/dist/library/metadata-matcher.d.ts.map +1 -0
- package/dist/library/metadata-matcher.js +207 -0
- package/dist/library/metadata-matcher.js.map +1 -0
- package/dist/library/naming.d.ts +8 -0
- package/dist/library/naming.d.ts.map +1 -0
- package/dist/library/naming.js +47 -0
- package/dist/library/naming.js.map +1 -0
- package/dist/library/scan-controller.d.ts +95 -0
- package/dist/library/scan-controller.d.ts.map +1 -0
- package/dist/library/scan-controller.js +379 -0
- package/dist/library/scan-controller.js.map +1 -0
- package/dist/library/source-resolver.d.ts +17 -0
- package/dist/library/source-resolver.d.ts.map +1 -0
- package/dist/library/source-resolver.js +75 -0
- package/dist/library/source-resolver.js.map +1 -0
- package/dist/logging/logger.d.ts +3 -0
- package/dist/logging/logger.d.ts.map +1 -0
- package/dist/logging/logger.js +107 -0
- package/dist/logging/logger.js.map +1 -0
- package/dist/main.d.ts +36 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +229 -0
- package/dist/main.js.map +1 -0
- package/dist/plugins/context.d.ts +10 -0
- package/dist/plugins/context.d.ts.map +1 -0
- package/dist/plugins/context.js +110 -0
- package/dist/plugins/context.js.map +1 -0
- package/dist/plugins/discovery.d.ts +2 -0
- package/dist/plugins/discovery.d.ts.map +1 -0
- package/dist/plugins/discovery.js +32 -0
- package/dist/plugins/discovery.js.map +1 -0
- package/dist/plugins/loader.d.ts +9 -0
- package/dist/plugins/loader.d.ts.map +1 -0
- package/dist/plugins/loader.js +245 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/state/request-intake.d.ts +26 -0
- package/dist/state/request-intake.d.ts.map +1 -0
- package/dist/state/request-intake.js +74 -0
- package/dist/state/request-intake.js.map +1 -0
- package/dist/state/state-store.d.ts +146 -0
- package/dist/state/state-store.d.ts.map +1 -0
- package/dist/state/state-store.js +465 -0
- package/dist/state/state-store.js.map +1 -0
- package/dist/state/trace-store.d.ts +12 -0
- package/dist/state/trace-store.d.ts.map +1 -0
- package/dist/state/trace-store.js +24 -0
- package/dist/state/trace-store.js.map +1 -0
- package/dist/types/plugin.d.ts +93 -0
- package/dist/types/plugin.d.ts.map +1 -0
- package/dist/types/plugin.js +4 -0
- package/dist/types/plugin.js.map +1 -0
- package/dist/web/index.d.ts +10 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +153 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/routes/activity.d.ts +6 -0
- package/dist/web/routes/activity.d.ts.map +1 -0
- package/dist/web/routes/activity.js +31 -0
- package/dist/web/routes/activity.js.map +1 -0
- package/dist/web/routes/auth.d.ts +6 -0
- package/dist/web/routes/auth.d.ts.map +1 -0
- package/dist/web/routes/auth.js +142 -0
- package/dist/web/routes/auth.js.map +1 -0
- package/dist/web/routes/config.d.ts +7 -0
- package/dist/web/routes/config.d.ts.map +1 -0
- package/dist/web/routes/config.js +204 -0
- package/dist/web/routes/config.js.map +1 -0
- package/dist/web/routes/content-types.d.ts +6 -0
- package/dist/web/routes/content-types.d.ts.map +1 -0
- package/dist/web/routes/content-types.js +29 -0
- package/dist/web/routes/content-types.js.map +1 -0
- package/dist/web/routes/discovery.d.ts +6 -0
- package/dist/web/routes/discovery.d.ts.map +1 -0
- package/dist/web/routes/discovery.js +111 -0
- package/dist/web/routes/discovery.js.map +1 -0
- package/dist/web/routes/library-import.d.ts +6 -0
- package/dist/web/routes/library-import.d.ts.map +1 -0
- package/dist/web/routes/library-import.js +120 -0
- package/dist/web/routes/library-import.js.map +1 -0
- package/dist/web/routes/library-management.d.ts +6 -0
- package/dist/web/routes/library-management.d.ts.map +1 -0
- package/dist/web/routes/library-management.js +120 -0
- package/dist/web/routes/library-management.js.map +1 -0
- package/dist/web/routes/library.d.ts +6 -0
- package/dist/web/routes/library.d.ts.map +1 -0
- package/dist/web/routes/library.js +28 -0
- package/dist/web/routes/library.js.map +1 -0
- package/dist/web/routes/oidc.d.ts +9 -0
- package/dist/web/routes/oidc.d.ts.map +1 -0
- package/dist/web/routes/oidc.js +203 -0
- package/dist/web/routes/oidc.js.map +1 -0
- package/dist/web/routes/requests.d.ts +6 -0
- package/dist/web/routes/requests.d.ts.map +1 -0
- package/dist/web/routes/requests.js +183 -0
- package/dist/web/routes/requests.js.map +1 -0
- package/dist/web/routes/sse.d.ts +6 -0
- package/dist/web/routes/sse.d.ts.map +1 -0
- package/dist/web/routes/sse.js +54 -0
- package/dist/web/routes/sse.js.map +1 -0
- package/package.json +33 -0
- package/package.json.license +2 -0
- package/src/auth/auth-service.ts +333 -0
- package/src/auth/http-server.ts +249 -0
- package/src/auth/jwt-secret.ts +65 -0
- package/src/auth/oidc/oidc-flow.ts +266 -0
- package/src/auth/oidc/oidc-provider-service.ts +174 -0
- package/src/auth/oidc/user-service.ts +227 -0
- package/src/auth/rate-limiter.ts +85 -0
- package/src/config/config-service.ts +434 -0
- package/src/config/validate.ts +153 -0
- package/src/content-types/registry.ts +194 -0
- package/src/datastore/db.ts +38 -0
- package/src/datastore/encrypt.ts +107 -0
- package/src/datastore/migrations.ts +367 -0
- package/src/datastore/storage.ts +83 -0
- package/src/events/event-bus.ts +180 -0
- package/src/events/spine.ts +87 -0
- package/src/events/trace.ts +38 -0
- package/src/host.ts +130 -0
- package/src/library/importer.ts +174 -0
- package/src/library/library-cache.ts +212 -0
- package/src/library/library-config.ts +22 -0
- package/src/library/library-scanner.ts +404 -0
- package/src/library/library-service.ts +282 -0
- package/src/library/metadata-matcher.ts +285 -0
- package/src/library/naming.ts +59 -0
- package/src/library/scan-controller.ts +490 -0
- package/src/library/source-resolver.ts +92 -0
- package/src/logging/logger.ts +115 -0
- package/src/main.ts +364 -0
- package/src/plugins/context.ts +138 -0
- package/src/plugins/discovery.ts +29 -0
- package/src/plugins/loader.ts +309 -0
- package/src/state/request-intake.ts +101 -0
- package/src/state/state-store.ts +621 -0
- package/src/state/trace-store.ts +27 -0
- package/src/types/plugin.ts +110 -0
- package/src/web/index.ts +182 -0
- package/src/web/routes/activity.ts +41 -0
- package/src/web/routes/auth.ts +170 -0
- package/src/web/routes/config.ts +282 -0
- package/src/web/routes/content-types.ts +42 -0
- package/src/web/routes/discovery.ts +156 -0
- package/src/web/routes/library-import.ts +196 -0
- package/src/web/routes/library-management.ts +140 -0
- package/src/web/routes/library.ts +46 -0
- package/src/web/routes/oidc.ts +260 -0
- package/src/web/routes/requests.ts +250 -0
- package/src/web/routes/sse.ts +92 -0
- package/tests/auth.test.ts +510 -0
- package/tests/config-route.test.ts +345 -0
- package/tests/config.test.ts +612 -0
- package/tests/content-types.test.ts +376 -0
- package/tests/datastore.test.ts +345 -0
- package/tests/e2e.test.ts +430 -0
- package/tests/event-bus.test.ts +925 -0
- package/tests/host.test.ts +449 -0
- package/tests/library-cache.test.ts +171 -0
- package/tests/library-scanner.test.ts +177 -0
- package/tests/library.test.ts +210 -0
- package/tests/loader-context.test.ts +644 -0
- package/tests/logger-jsonl.test.ts +154 -0
- package/tests/metadata-matcher.test.ts +198 -0
- package/tests/oidc.test.ts +218 -0
- package/tests/scan-controller.test.ts +328 -0
- package/tests/source-resolver.test.ts +100 -0
- package/tests/starr-video-integration.test.ts +153 -0
- package/tests/state-store.test.ts +700 -0
- package/tests/trace-history.test.ts +356 -0
- package/tsconfig.json +8 -0
- package/tsconfig.json.license +2 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
// SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
|
|
3
|
+
|
|
4
|
+
import type { FastifyPluginAsync } from "fastify";
|
|
5
|
+
import type { EventBus, Unsubscribe } from "../../types/plugin.js";
|
|
6
|
+
|
|
7
|
+
let eventBus: EventBus | null = null;
|
|
8
|
+
|
|
9
|
+
export function initSseRoutes(bus: EventBus): void {
|
|
10
|
+
eventBus = bus;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const sseRoutes: FastifyPluginAsync = async (server) => {
|
|
14
|
+
server.get("/api/events", async (request, reply) => {
|
|
15
|
+
if (!eventBus) {
|
|
16
|
+
reply.status(500).send({ error: "event bus not available" });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Set SSE headers
|
|
21
|
+
reply.raw.writeHead(200, {
|
|
22
|
+
"Content-Type": "text/event-stream",
|
|
23
|
+
"Cache-Control": "no-cache",
|
|
24
|
+
Connection: "keep-alive",
|
|
25
|
+
"X-Accel-Buffering": "no", // disable nginx buffering
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Send an initial comment to establish the connection
|
|
29
|
+
reply.raw.write(": connected\n\n");
|
|
30
|
+
|
|
31
|
+
// Subscribe to item.updated events
|
|
32
|
+
const unsubItemUpdated: Unsubscribe = eventBus.on(
|
|
33
|
+
"item.updated",
|
|
34
|
+
(event) => {
|
|
35
|
+
const payload = event.payload as {
|
|
36
|
+
requestId: string;
|
|
37
|
+
status: string;
|
|
38
|
+
detail?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const data = JSON.stringify(payload);
|
|
42
|
+
reply.raw.write(`event: item.updated\ndata: ${data}\n\n`);
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Subscribe to library.file_added events
|
|
47
|
+
const unsubLibraryFileAdded: Unsubscribe = eventBus.on(
|
|
48
|
+
"library.file_added",
|
|
49
|
+
(event) => {
|
|
50
|
+
const payload = event.payload as {
|
|
51
|
+
requestId: string;
|
|
52
|
+
libraryPath: string;
|
|
53
|
+
contentTypeId: string;
|
|
54
|
+
contentIdentity: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const data = JSON.stringify(payload);
|
|
58
|
+
reply.raw.write(`event: library.file_added\ndata: ${data}\n\n`);
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Subscribe to library.scan.progress events
|
|
63
|
+
const unsubScanProgress: Unsubscribe = eventBus.on(
|
|
64
|
+
"library.scan.progress",
|
|
65
|
+
(event) => {
|
|
66
|
+
const payload = event.payload as {
|
|
67
|
+
scanId: string;
|
|
68
|
+
status: string;
|
|
69
|
+
progress: Record<string, unknown>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const data = JSON.stringify(payload);
|
|
73
|
+
reply.raw.write(`event: library.scan.progress\ndata: ${data}\n\n`);
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Keep alive: send a comment every 30 seconds
|
|
78
|
+
const keepAlive = setInterval(() => {
|
|
79
|
+
reply.raw.write(": keepalive\n\n");
|
|
80
|
+
}, 30_000);
|
|
81
|
+
|
|
82
|
+
// Clean up on connection close
|
|
83
|
+
request.raw.on("close", () => {
|
|
84
|
+
unsubItemUpdated();
|
|
85
|
+
unsubLibraryFileAdded();
|
|
86
|
+
unsubScanProgress();
|
|
87
|
+
clearInterval(keepAlive);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export default sseRoutes;
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
// SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
|
5
|
+
import Database from "better-sqlite3";
|
|
6
|
+
import { runMigrations, CORE_MIGRATIONS } from "../src/datastore/migrations.js";
|
|
7
|
+
import { createLogger } from "../src/logging/logger.js";
|
|
8
|
+
import { loadJwtSecret, resetJwtSecret, getJwtSecret } from "../src/auth/jwt-secret.js";
|
|
9
|
+
import { AuthService } from "../src/auth/auth-service.js";
|
|
10
|
+
import { RateLimiter } from "../src/auth/rate-limiter.js";
|
|
11
|
+
import { createAuthServer, COOKIE_NAME } from "../src/auth/http-server.js";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { mkdtempSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import http from "node:http";
|
|
17
|
+
|
|
18
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const logger = createLogger("test", "silent");
|
|
21
|
+
|
|
22
|
+
/** Create a fresh in-memory database with all core migrations applied. */
|
|
23
|
+
function createDb(): Database.Database {
|
|
24
|
+
const db = new Database(":memory:");
|
|
25
|
+
db.pragma("journal_mode = WAL");
|
|
26
|
+
db.pragma("foreign_keys = ON");
|
|
27
|
+
runMigrations(db, CORE_MIGRATIONS, logger);
|
|
28
|
+
return db;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Generate a valid JWT secret and set it up for testing. */
|
|
32
|
+
function setupJwtSecret(): void {
|
|
33
|
+
resetJwtSecret();
|
|
34
|
+
const tmpDir = mkdtempSync(path.join(tmpdir(), "starr-auth-test-"));
|
|
35
|
+
const key = randomBytes(32);
|
|
36
|
+
const keyPath = path.join(tmpDir, "starr.jwt");
|
|
37
|
+
writeFileSync(keyPath, key.toString("base64") + "\n", { mode: 0o600 });
|
|
38
|
+
loadJwtSecret(tmpDir, logger);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Create a fresh AuthService on an in-memory DB. */
|
|
42
|
+
function createAuthService(): AuthService {
|
|
43
|
+
const db = createDb();
|
|
44
|
+
return new AuthService(db, logger);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Make an HTTP request to the test server. */
|
|
48
|
+
function fetch(
|
|
49
|
+
server: http.Server,
|
|
50
|
+
method: string,
|
|
51
|
+
path: string,
|
|
52
|
+
body?: Record<string, unknown>,
|
|
53
|
+
cookie?: string,
|
|
54
|
+
): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: unknown }> {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const addr = server.address();
|
|
57
|
+
if (!addr || typeof addr === "string") {
|
|
58
|
+
reject(new Error("server not listening"));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const { port } = addr;
|
|
62
|
+
const options: http.RequestOptions = {
|
|
63
|
+
hostname: "127.0.0.1",
|
|
64
|
+
port,
|
|
65
|
+
path,
|
|
66
|
+
method,
|
|
67
|
+
headers: {
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
...(cookie ? { Cookie: cookie } : {}),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
const req = http.request(options, (res) => {
|
|
73
|
+
let data = "";
|
|
74
|
+
res.on("data", (chunk) => (data += chunk));
|
|
75
|
+
res.on("end", () => {
|
|
76
|
+
let parsed: unknown;
|
|
77
|
+
try {
|
|
78
|
+
parsed = JSON.parse(data);
|
|
79
|
+
} catch {
|
|
80
|
+
parsed = data;
|
|
81
|
+
}
|
|
82
|
+
resolve({
|
|
83
|
+
status: res.statusCode ?? 500,
|
|
84
|
+
headers: res.headers,
|
|
85
|
+
body: parsed,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
req.on("error", reject);
|
|
90
|
+
if (body) {
|
|
91
|
+
req.write(JSON.stringify(body));
|
|
92
|
+
}
|
|
93
|
+
req.end();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Extract the starr_sid cookie value from a Set-Cookie header. */
|
|
98
|
+
function extractCookie(setCookie: string | string[] | undefined): string | null {
|
|
99
|
+
if (!setCookie) return null;
|
|
100
|
+
const header = Array.isArray(setCookie) ? setCookie[0] : setCookie;
|
|
101
|
+
const match = header.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
|
|
102
|
+
return match ? match[1] : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── JWT sign/verify round-trip ───────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
describe("JWT sign/verify", () => {
|
|
108
|
+
beforeAll(() => setupJwtSecret());
|
|
109
|
+
|
|
110
|
+
it("round-trip: sign and verify a valid payload", () => {
|
|
111
|
+
const auth = createAuthService();
|
|
112
|
+
// Create an account and login to get a real JWT
|
|
113
|
+
// Or we can test via the internal functions by accessing the DB directly
|
|
114
|
+
// Since the functions are not exported directly, test via AuthService
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("tampered token returns null from validateToken", async () => {
|
|
118
|
+
const auth = createAuthService();
|
|
119
|
+
// Need an account and session first
|
|
120
|
+
await auth.setup("admin", "secret123");
|
|
121
|
+
const jwt = await auth.login("admin", "secret123");
|
|
122
|
+
expect(jwt).toBeTruthy();
|
|
123
|
+
expect(typeof jwt).toBe("string");
|
|
124
|
+
|
|
125
|
+
// Tamper the signature part
|
|
126
|
+
const tampered = (jwt as string).split(".").slice(0, 2).join(".") + ".invalidsig";
|
|
127
|
+
const result = auth.validateToken(tampered);
|
|
128
|
+
expect(result).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("expired token returns null from validateToken", async () => {
|
|
132
|
+
const auth = createAuthService();
|
|
133
|
+
await auth.setup("admin", "secret123");
|
|
134
|
+
// We can't easily create an expired JWT via the public API,
|
|
135
|
+
// but we can verify that garbage strings return null
|
|
136
|
+
expect(auth.validateToken("garbage")).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── First-run setup ─────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe("first-run setup", () => {
|
|
143
|
+
beforeAll(() => setupJwtSecret());
|
|
144
|
+
|
|
145
|
+
it("fresh DB: needsSetup() returns true", () => {
|
|
146
|
+
const auth = createAuthService();
|
|
147
|
+
expect(auth.needsSetup()).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("setup succeeds with valid credentials", async () => {
|
|
151
|
+
const auth = createAuthService();
|
|
152
|
+
await auth.setup("admin", "secret123");
|
|
153
|
+
expect(auth.needsSetup()).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("setup throws if account already exists", async () => {
|
|
157
|
+
const auth = createAuthService();
|
|
158
|
+
await auth.setup("admin", "secret123");
|
|
159
|
+
await expect(auth.setup("admin2", "secret456")).rejects.toThrow("already exists");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("setup validates empty username", async () => {
|
|
163
|
+
const auth = createAuthService();
|
|
164
|
+
await expect(auth.setup("", "secret123")).rejects.toThrow("Username must not be empty");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("setup validates short password", async () => {
|
|
168
|
+
const auth = createAuthService();
|
|
169
|
+
await expect(auth.setup("admin", "short")).rejects.toThrow("Password must be at least 8 characters");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ── Login flow ──────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
describe("login flow", () => {
|
|
176
|
+
beforeAll(() => setupJwtSecret());
|
|
177
|
+
|
|
178
|
+
it("wrong password returns null", async () => {
|
|
179
|
+
const auth = createAuthService();
|
|
180
|
+
await auth.setup("admin", "secret123");
|
|
181
|
+
const jwt = await auth.login("admin", "wrongpassword");
|
|
182
|
+
expect(jwt).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("correct password returns JWT string", async () => {
|
|
186
|
+
const auth = createAuthService();
|
|
187
|
+
await auth.setup("admin", "secret123");
|
|
188
|
+
const jwt = await auth.login("admin", "secret123");
|
|
189
|
+
expect(jwt).toBeTruthy();
|
|
190
|
+
expect(typeof jwt).toBe("string");
|
|
191
|
+
expect((jwt as string).split(".").length).toBe(3);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("validateToken returns correct userId", async () => {
|
|
195
|
+
const auth = createAuthService();
|
|
196
|
+
await auth.setup("admin", "secret123");
|
|
197
|
+
const jwt = (await auth.login("admin", "secret123")) as string;
|
|
198
|
+
const result = auth.validateToken(jwt);
|
|
199
|
+
expect(result).not.toBeNull();
|
|
200
|
+
expect(result!.userId).toBe("admin");
|
|
201
|
+
expect(result!.sessionId).toBeTruthy();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("validateToken('garbage') returns null", () => {
|
|
205
|
+
const auth = createAuthService();
|
|
206
|
+
expect(auth.validateToken("garbage")).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ── Session invalidation ────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
describe("session invalidation", () => {
|
|
213
|
+
beforeAll(() => setupJwtSecret());
|
|
214
|
+
|
|
215
|
+
it("logout invalidates session", async () => {
|
|
216
|
+
const auth = createAuthService();
|
|
217
|
+
await auth.setup("admin", "secret123");
|
|
218
|
+
const jwt = (await auth.login("admin", "secret123")) as string;
|
|
219
|
+
// Validate before logout
|
|
220
|
+
expect(auth.validateToken(jwt)).not.toBeNull();
|
|
221
|
+
// Extract sessionId — we need to peek at the token payload
|
|
222
|
+
// Use the validateToken result to get sessionId
|
|
223
|
+
const result = auth.validateToken(jwt);
|
|
224
|
+
expect(result).not.toBeNull();
|
|
225
|
+
auth.logout(result!.sessionId);
|
|
226
|
+
// Should be invalid now
|
|
227
|
+
expect(auth.validateToken(jwt)).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ── Password change ─────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
describe("password change", () => {
|
|
234
|
+
beforeAll(() => setupJwtSecret());
|
|
235
|
+
|
|
236
|
+
it("password change invalidates old sessions", async () => {
|
|
237
|
+
const auth = createAuthService();
|
|
238
|
+
await auth.setup("admin", "secret123");
|
|
239
|
+
const jwt = (await auth.login("admin", "secret123")) as string;
|
|
240
|
+
expect(auth.validateToken(jwt)).not.toBeNull();
|
|
241
|
+
|
|
242
|
+
await auth.changePassword("admin", "secret123", "newpass456");
|
|
243
|
+
|
|
244
|
+
// Old session should be invalidated
|
|
245
|
+
expect(auth.validateToken(jwt)).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("login with new password works after change", async () => {
|
|
249
|
+
const auth = createAuthService();
|
|
250
|
+
await auth.setup("admin", "secret123");
|
|
251
|
+
await auth.changePassword("admin", "secret123", "newpass456");
|
|
252
|
+
|
|
253
|
+
const jwt = await auth.login("admin", "newpass456");
|
|
254
|
+
expect(jwt).toBeTruthy();
|
|
255
|
+
expect(auth.validateToken(jwt as string)).not.toBeNull();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("wrong current password throws", async () => {
|
|
259
|
+
const auth = createAuthService();
|
|
260
|
+
await auth.setup("admin", "secret123");
|
|
261
|
+
await expect(auth.changePassword("admin", "wrong", "newpass456")).rejects.toThrow("incorrect");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("short new password throws", async () => {
|
|
265
|
+
const auth = createAuthService();
|
|
266
|
+
await auth.setup("admin", "secret123");
|
|
267
|
+
await expect(auth.changePassword("admin", "secret123", "short")).rejects.toThrow("at least 8 characters");
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ── Rate limiter ────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
describe("RateLimiter", () => {
|
|
274
|
+
it("allows up to 4 attempts, blocks the 5th", () => {
|
|
275
|
+
const limiter = new RateLimiter(4, 15);
|
|
276
|
+
expect(limiter.check("127.0.0.1")).toBe(true);
|
|
277
|
+
expect(limiter.check("127.0.0.1")).toBe(true);
|
|
278
|
+
expect(limiter.check("127.0.0.1")).toBe(true);
|
|
279
|
+
expect(limiter.check("127.0.0.1")).toBe(true);
|
|
280
|
+
expect(limiter.check("127.0.0.1")).toBe(false);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("reset clears the block", () => {
|
|
284
|
+
const limiter = new RateLimiter(4, 15);
|
|
285
|
+
for (let i = 0; i < 4; i++) limiter.check("1.2.3.4");
|
|
286
|
+
expect(limiter.check("1.2.3.4")).toBe(false);
|
|
287
|
+
limiter.reset("1.2.3.4");
|
|
288
|
+
expect(limiter.check("1.2.3.4")).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("retryAfterSeconds returns >0 when blocked", () => {
|
|
292
|
+
const limiter = new RateLimiter(4, 15);
|
|
293
|
+
for (let i = 0; i < 5; i++) limiter.check("5.6.7.8");
|
|
294
|
+
expect(limiter.retryAfterSeconds("5.6.7.8")).toBeGreaterThan(0);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("different IPs are independent", () => {
|
|
298
|
+
const limiter = new RateLimiter(4, 15);
|
|
299
|
+
for (let i = 0; i < 5; i++) {
|
|
300
|
+
limiter.check("ip-a");
|
|
301
|
+
limiter.check("ip-b");
|
|
302
|
+
}
|
|
303
|
+
// Both should be blocked
|
|
304
|
+
expect(limiter.check("ip-a")).toBe(false);
|
|
305
|
+
expect(limiter.check("ip-b")).toBe(false);
|
|
306
|
+
|
|
307
|
+
// Different IP should be fine
|
|
308
|
+
expect(limiter.check("ip-c")).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ── HTTP endpoint smoke tests ───────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
describe("HTTP endpoint smoke tests", () => {
|
|
315
|
+
let server: http.Server;
|
|
316
|
+
let auth: AuthService;
|
|
317
|
+
let db: Database.Database;
|
|
318
|
+
let adminJwt: string;
|
|
319
|
+
let adminCookie: string;
|
|
320
|
+
|
|
321
|
+
beforeAll(async () => {
|
|
322
|
+
setupJwtSecret();
|
|
323
|
+
db = createDb();
|
|
324
|
+
auth = new AuthService(db, logger);
|
|
325
|
+
// Use a dynamic port by passing 0
|
|
326
|
+
server = createAuthServer(auth, logger, 0);
|
|
327
|
+
// Wait for server to be listening by polling for the address
|
|
328
|
+
await new Promise<void>((resolve) => {
|
|
329
|
+
const check = () => {
|
|
330
|
+
if (server.address()) {
|
|
331
|
+
resolve();
|
|
332
|
+
} else {
|
|
333
|
+
setTimeout(check, 10);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
server.on("listening", () => resolve());
|
|
337
|
+
check();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
afterAll(() => {
|
|
342
|
+
server.close();
|
|
343
|
+
db.close();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("GET /api/auth/needs-setup returns { needsSetup: true }", async () => {
|
|
347
|
+
const res = await fetch(server, "GET", "/api/auth/needs-setup");
|
|
348
|
+
expect(res.status).toBe(200);
|
|
349
|
+
expect((res.body as any).needsSetup).toBe(true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("POST /api/auth/setup creates account and sets cookie", async () => {
|
|
353
|
+
const res = await fetch(server, "POST", "/api/auth/setup", {
|
|
354
|
+
username: "admin",
|
|
355
|
+
password: "mypassword123",
|
|
356
|
+
});
|
|
357
|
+
expect(res.status).toBe(201);
|
|
358
|
+
expect((res.body as any).ok).toBe(true);
|
|
359
|
+
const cookie = extractCookie(res.headers["set-cookie"]);
|
|
360
|
+
expect(cookie).toBeTruthy();
|
|
361
|
+
adminCookie = cookie!;
|
|
362
|
+
adminJwt = cookie!;
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("POST /api/auth/setup again returns 403", async () => {
|
|
366
|
+
const res = await fetch(server, "POST", "/api/auth/setup", {
|
|
367
|
+
username: "admin2",
|
|
368
|
+
password: "testpass123",
|
|
369
|
+
});
|
|
370
|
+
expect(res.status).toBe(403);
|
|
371
|
+
expect((res.body as any).error).toContain("already exists");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("GET /api/auth/status with cookie returns 200 with user", async () => {
|
|
375
|
+
const res = await fetch(server, "GET", "/api/auth/status", undefined, `${COOKIE_NAME}=${adminCookie}`);
|
|
376
|
+
expect(res.status).toBe(200);
|
|
377
|
+
expect((res.body as any).user).toBe("admin");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("GET /api/auth/status without cookie returns 401", async () => {
|
|
381
|
+
const res = await fetch(server, "GET", "/api/auth/status");
|
|
382
|
+
expect(res.status).toBe(401);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("POST /api/auth/logout clears cookie", async () => {
|
|
386
|
+
const res = await fetch(server, "POST", "/api/auth/logout", undefined, `${COOKIE_NAME}=${adminCookie}`);
|
|
387
|
+
expect(res.status).toBe(200);
|
|
388
|
+
const setCookie = res.headers["set-cookie"];
|
|
389
|
+
expect(setCookie).toBeTruthy();
|
|
390
|
+
const statusRes = await fetch(server, "GET", "/api/auth/status", undefined, `${COOKIE_NAME}=${adminCookie}`);
|
|
391
|
+
expect(statusRes.status).toBe(401);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("POST /api/auth/login with wrong password returns 401", async () => {
|
|
395
|
+
const res = await fetch(server, "POST", "/api/auth/login", {
|
|
396
|
+
username: "admin",
|
|
397
|
+
password: "wrongpassword",
|
|
398
|
+
});
|
|
399
|
+
expect(res.status).toBe(401);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("POST /api/auth/login with correct password returns 200 and sets cookie", async () => {
|
|
403
|
+
const res = await fetch(server, "POST", "/api/auth/login", {
|
|
404
|
+
username: "admin",
|
|
405
|
+
password: "mypassword123",
|
|
406
|
+
});
|
|
407
|
+
expect(res.status).toBe(200);
|
|
408
|
+
expect((res.body as any).ok).toBe(true);
|
|
409
|
+
const cookie = extractCookie(res.headers["set-cookie"]);
|
|
410
|
+
expect(cookie).toBeTruthy();
|
|
411
|
+
adminCookie = cookie!;
|
|
412
|
+
adminJwt = cookie!;
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("POST /api/auth/change-password works", async () => {
|
|
416
|
+
const res = await fetch(server, "POST", "/api/auth/change-password", {
|
|
417
|
+
currentPassword: "mypassword123",
|
|
418
|
+
newPassword: "newpass456",
|
|
419
|
+
}, `${COOKIE_NAME}=${adminCookie}`);
|
|
420
|
+
expect(res.status).toBe(200);
|
|
421
|
+
expect((res.body as any).ok).toBe(true);
|
|
422
|
+
// Old cookie should be invalid now
|
|
423
|
+
const statusRes = await fetch(server, "GET", "/api/auth/status", undefined, `${COOKIE_NAME}=${adminCookie}`);
|
|
424
|
+
expect(statusRes.status).toBe(401);
|
|
425
|
+
// Login with new password
|
|
426
|
+
const loginRes2 = await fetch(server, "POST", "/api/auth/login", {
|
|
427
|
+
username: "admin",
|
|
428
|
+
password: "newpass456",
|
|
429
|
+
});
|
|
430
|
+
expect(loginRes2.status).toBe(200);
|
|
431
|
+
adminCookie = extractCookie(loginRes2.headers["set-cookie"])!;
|
|
432
|
+
adminJwt = adminCookie;
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("unknown route returns 404", async () => {
|
|
436
|
+
const res = await fetch(server, "GET", "/api/auth/unknown");
|
|
437
|
+
expect(res.status).toBe(404);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// ── HTTP rate limiting (separate server to avoid state bleed) ────────
|
|
442
|
+
|
|
443
|
+
describe("HTTP rate limiting", () => {
|
|
444
|
+
let server: http.Server;
|
|
445
|
+
let auth: AuthService;
|
|
446
|
+
let db: Database.Database;
|
|
447
|
+
|
|
448
|
+
beforeAll(async () => {
|
|
449
|
+
setupJwtSecret();
|
|
450
|
+
db = createDb();
|
|
451
|
+
auth = new AuthService(db, logger);
|
|
452
|
+
server = createAuthServer(auth, logger, 0);
|
|
453
|
+
await new Promise<void>((resolve) => {
|
|
454
|
+
const check = () => {
|
|
455
|
+
if (server.address()) {
|
|
456
|
+
resolve();
|
|
457
|
+
} else {
|
|
458
|
+
setTimeout(check, 10);
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
server.on("listening", () => resolve());
|
|
462
|
+
check();
|
|
463
|
+
});
|
|
464
|
+
// Create an account first
|
|
465
|
+
await auth.setup("admin", "mypassword123");
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
afterAll(() => {
|
|
469
|
+
server.close();
|
|
470
|
+
db.close();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("allows 4 wrong password attempts, blocks the 5th", async () => {
|
|
474
|
+
for (let i = 0; i < 4; i++) {
|
|
475
|
+
const res = await fetch(server, "POST", "/api/auth/login", {
|
|
476
|
+
username: "admin",
|
|
477
|
+
password: "wrongpassword",
|
|
478
|
+
});
|
|
479
|
+
expect(res.status).toBe(401);
|
|
480
|
+
}
|
|
481
|
+
// 5th attempt is rate-limited
|
|
482
|
+
const res = await fetch(server, "POST", "/api/auth/login", {
|
|
483
|
+
username: "admin",
|
|
484
|
+
password: "wrongpassword",
|
|
485
|
+
});
|
|
486
|
+
expect(res.status).toBe(429);
|
|
487
|
+
expect((res.body as any).error).toContain("Too many");
|
|
488
|
+
// Even a correct password is blocked
|
|
489
|
+
const blockedCorrect = await fetch(server, "POST", "/api/auth/login", {
|
|
490
|
+
username: "admin",
|
|
491
|
+
password: "mypassword123",
|
|
492
|
+
});
|
|
493
|
+
expect(blockedCorrect.status).toBe(429);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("setup endpoint is also rate-limited", async () => {
|
|
497
|
+
// Already rate-limited from previous test on same IP
|
|
498
|
+
const res = await fetch(server, "POST", "/api/auth/setup", {
|
|
499
|
+
username: "admin2",
|
|
500
|
+
password: "testpass123",
|
|
501
|
+
});
|
|
502
|
+
expect(res.status).toBe(429);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("unauthenticated endpoints are not rate-limited", async () => {
|
|
506
|
+
const res = await fetch(server, "GET", "/api/auth/needs-setup");
|
|
507
|
+
expect(res.status).toBe(200);
|
|
508
|
+
expect((res.body as any).needsSetup).toBe(false);
|
|
509
|
+
});
|
|
510
|
+
});
|