@blackbelt-technology/pi-agent-dashboard 0.5.1 → 0.5.2
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/AGENTS.md +26 -5
- package/README.md +30 -0
- package/docs/architecture.md +129 -1
- package/package.json +6 -6
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/bridge-context.ts +67 -3
- package/packages/extension/src/bridge.ts +20 -8
- package/packages/extension/src/command-handler.ts +36 -13
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +6 -5
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service.test.ts +1 -1
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +15 -7
- package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
- package/packages/server/src/cli.ts +61 -81
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +31 -1
- package/packages/server/src/headless-pid-registry.ts +299 -41
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/process-manager.ts +128 -0
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/provider-auth-routes.ts +3 -0
- package/packages/server/src/routes/provider-routes.ts +24 -1
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +178 -2
- package/packages/server/src/session-api.ts +9 -1
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +27 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +42 -5
- package/packages/shared/src/protocol.ts +19 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/types.ts +55 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
- package/packages/shared/src/resolve-jiti.ts +0 -155
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for model proxy API key management routes (task 4.3).
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - list: redaction, createdBy filter, admin sees all
|
|
6
|
+
* - create: cleartext once, hashed storage, default scopes, custom scopes, past expiresAt → 400
|
|
7
|
+
* - revoke: sets revokedAt, 404 on unknown, 403 non-owner non-admin, 204 admin on others
|
|
8
|
+
* - purge (DELETE): removes entry, list excludes purged
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
11
|
+
import Fastify from "fastify";
|
|
12
|
+
import { registerModelProxyApiKeyRoutes } from "../routes/model-proxy-api-key-routes.js";
|
|
13
|
+
import { generateKey, hashKey, verifyKey } from "../model-proxy/api-key-store.js";
|
|
14
|
+
import type { ProxyApiKey, ModelProxyConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
15
|
+
|
|
16
|
+
// ── Test fixture ────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function makeEntry(id: string, label: string, createdBy: string, overrides: Partial<ProxyApiKey> = {}): ProxyApiKey {
|
|
19
|
+
return {
|
|
20
|
+
id,
|
|
21
|
+
label,
|
|
22
|
+
createdAt: Date.now(),
|
|
23
|
+
hash: hashKey(generateKey()),
|
|
24
|
+
scopes: ["all"],
|
|
25
|
+
createdBy,
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function buildApp(
|
|
31
|
+
apiKeys: ProxyApiKey[] = [],
|
|
32
|
+
userEmail = "alice@test.com",
|
|
33
|
+
adminEmail?: string,
|
|
34
|
+
) {
|
|
35
|
+
const app = Fastify({ logger: false });
|
|
36
|
+
let config: ModelProxyConfig = {
|
|
37
|
+
enabled: true,
|
|
38
|
+
maxConcurrentStreams: 16,
|
|
39
|
+
perKeyConcurrentStreams: 4,
|
|
40
|
+
logRequests: false,
|
|
41
|
+
apiKeys: [...apiKeys],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const writes: ProxyApiKey[][] = [];
|
|
45
|
+
|
|
46
|
+
app.addHook("onRequest", async (req) => { (req as any).user = { email: userEmail }; });
|
|
47
|
+
const networkGuard = async () => {};
|
|
48
|
+
|
|
49
|
+
registerModelProxyApiKeyRoutes(app, {
|
|
50
|
+
networkGuard,
|
|
51
|
+
getModelProxyConfig: () => config,
|
|
52
|
+
writeModelProxyApiKeys: async (keys) => { config = { ...config, apiKeys: keys }; writes.push(keys); },
|
|
53
|
+
getAdminEmail: () => adminEmail,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await app.ready();
|
|
57
|
+
return { app, writes, getConfig: () => config };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
describe("list: GET /api/model-proxy/api-keys (task 4.3)", () => {
|
|
63
|
+
it("hashes are redacted (shown as ***)", async () => {
|
|
64
|
+
const key = makeEntry("k1", "Test", "alice@test.com");
|
|
65
|
+
const { app } = await buildApp([key]);
|
|
66
|
+
|
|
67
|
+
const res = await app.inject({ method: "GET", url: "/api/model-proxy/api-keys" });
|
|
68
|
+
const body = JSON.parse(res.body);
|
|
69
|
+
expect(body.data.keys[0].hash).toBe("***");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("filters list to createdBy === caller", async () => {
|
|
73
|
+
const aliceKey = makeEntry("k1", "Alice", "alice@test.com");
|
|
74
|
+
const bobKey = makeEntry("k2", "Bob", "bob@test.com");
|
|
75
|
+
const { app } = await buildApp([aliceKey, bobKey], "alice@test.com");
|
|
76
|
+
|
|
77
|
+
const res = await app.inject({ method: "GET", url: "/api/model-proxy/api-keys" });
|
|
78
|
+
const body = JSON.parse(res.body);
|
|
79
|
+
const ids = body.data.keys.map((k: any) => k.id);
|
|
80
|
+
expect(ids).toContain("k1");
|
|
81
|
+
expect(ids).not.toContain("k2");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("admin sees all keys", async () => {
|
|
85
|
+
const aliceKey = makeEntry("k1", "Alice", "alice@test.com");
|
|
86
|
+
const bobKey = makeEntry("k2", "Bob", "bob@test.com");
|
|
87
|
+
const { app } = await buildApp([aliceKey, bobKey], "admin@test.com", "admin@test.com");
|
|
88
|
+
|
|
89
|
+
const res = await app.inject({ method: "GET", url: "/api/model-proxy/api-keys" });
|
|
90
|
+
const body = JSON.parse(res.body);
|
|
91
|
+
const ids = body.data.keys.map((k: any) => k.id);
|
|
92
|
+
expect(ids).toContain("k1");
|
|
93
|
+
expect(ids).toContain("k2");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("revoked keys appear in revoked[] not keys[]", async () => {
|
|
97
|
+
const active = makeEntry("k1", "Active", "alice@test.com");
|
|
98
|
+
const revoked = makeEntry("k2", "Revoked", "alice@test.com", { revokedAt: Date.now() - 1000 });
|
|
99
|
+
const { app } = await buildApp([active, revoked]);
|
|
100
|
+
|
|
101
|
+
const res = await app.inject({ method: "GET", url: "/api/model-proxy/api-keys" });
|
|
102
|
+
const body = JSON.parse(res.body);
|
|
103
|
+
expect(body.data.keys.map((k: any) => k.id)).toContain("k1");
|
|
104
|
+
expect(body.data.revoked.map((k: any) => k.id)).toContain("k2");
|
|
105
|
+
expect(body.data.keys.map((k: any) => k.id)).not.toContain("k2");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("create: POST /api/model-proxy/api-keys (task 4.3)", () => {
|
|
110
|
+
it("returns cleartext key once", async () => {
|
|
111
|
+
const { app } = await buildApp();
|
|
112
|
+
|
|
113
|
+
const res = await app.inject({
|
|
114
|
+
method: "POST",
|
|
115
|
+
url: "/api/model-proxy/api-keys",
|
|
116
|
+
headers: { "content-type": "application/json" },
|
|
117
|
+
body: JSON.stringify({ label: "My Key" }),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(res.statusCode).toBe(201);
|
|
121
|
+
const body = JSON.parse(res.body);
|
|
122
|
+
expect(body.data.key).toMatch(/^pi-proxy-/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("persists hashed (not cleartext)", async () => {
|
|
126
|
+
const { app, getConfig } = await buildApp();
|
|
127
|
+
|
|
128
|
+
const res = await app.inject({
|
|
129
|
+
method: "POST",
|
|
130
|
+
url: "/api/model-proxy/api-keys",
|
|
131
|
+
headers: { "content-type": "application/json" },
|
|
132
|
+
body: JSON.stringify({ label: "My Key" }),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const created = JSON.parse(res.body).data;
|
|
136
|
+
const stored = getConfig().apiKeys.find((k) => k.id === created.id);
|
|
137
|
+
expect(stored).toBeDefined();
|
|
138
|
+
expect(stored!.hash).not.toBe(created.key);
|
|
139
|
+
// Verify the stored hash matches the cleartext key
|
|
140
|
+
expect(verifyKey(created.key, stored!.hash)).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("stamps createdBy from user email", async () => {
|
|
144
|
+
const { app, getConfig } = await buildApp([], "creator@test.com");
|
|
145
|
+
|
|
146
|
+
await app.inject({
|
|
147
|
+
method: "POST",
|
|
148
|
+
url: "/api/model-proxy/api-keys",
|
|
149
|
+
headers: { "content-type": "application/json" },
|
|
150
|
+
body: JSON.stringify({ label: "Key" }),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const stored = getConfig().apiKeys[0];
|
|
154
|
+
expect(stored.createdBy).toBe("creator@test.com");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("default scopes is [\"all\"]", async () => {
|
|
158
|
+
const { app, getConfig } = await buildApp();
|
|
159
|
+
|
|
160
|
+
await app.inject({
|
|
161
|
+
method: "POST",
|
|
162
|
+
url: "/api/model-proxy/api-keys",
|
|
163
|
+
headers: { "content-type": "application/json" },
|
|
164
|
+
body: JSON.stringify({ label: "Key" }),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(getConfig().apiKeys[0].scopes).toEqual(["all"]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("custom scopes are persisted", async () => {
|
|
171
|
+
const { app, getConfig } = await buildApp();
|
|
172
|
+
|
|
173
|
+
await app.inject({
|
|
174
|
+
method: "POST",
|
|
175
|
+
url: "/api/model-proxy/api-keys",
|
|
176
|
+
headers: { "content-type": "application/json" },
|
|
177
|
+
body: JSON.stringify({ label: "Key", scopes: ["models:list", "chat"] }),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(getConfig().apiKeys[0].scopes).toEqual(["models:list", "chat"]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("past expiresAt → 400", async () => {
|
|
184
|
+
const { app } = await buildApp();
|
|
185
|
+
|
|
186
|
+
const res = await app.inject({
|
|
187
|
+
method: "POST",
|
|
188
|
+
url: "/api/model-proxy/api-keys",
|
|
189
|
+
headers: { "content-type": "application/json" },
|
|
190
|
+
body: JSON.stringify({ label: "Key", expiresAt: Date.now() - 1000 }),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(res.statusCode).toBe(400);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("missing label → 400", async () => {
|
|
197
|
+
const { app } = await buildApp();
|
|
198
|
+
|
|
199
|
+
const res = await app.inject({
|
|
200
|
+
method: "POST",
|
|
201
|
+
url: "/api/model-proxy/api-keys",
|
|
202
|
+
headers: { "content-type": "application/json" },
|
|
203
|
+
body: JSON.stringify({}),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(res.statusCode).toBe(400);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("revoke: POST /api/model-proxy/api-keys/:id/revoke (task 4.3)", () => {
|
|
211
|
+
it("sets revokedAt", async () => {
|
|
212
|
+
const key = makeEntry("k1", "Key", "alice@test.com");
|
|
213
|
+
const { app, getConfig } = await buildApp([key]);
|
|
214
|
+
|
|
215
|
+
const res = await app.inject({ method: "POST", url: "/api/model-proxy/api-keys/k1/revoke" });
|
|
216
|
+
|
|
217
|
+
expect(res.statusCode).toBe(204);
|
|
218
|
+
expect(getConfig().apiKeys[0].revokedAt).toBeDefined();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("unknown id → 404", async () => {
|
|
222
|
+
const { app } = await buildApp();
|
|
223
|
+
|
|
224
|
+
const res = await app.inject({ method: "POST", url: "/api/model-proxy/api-keys/unknown/revoke" });
|
|
225
|
+
expect(res.statusCode).toBe(404);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("revoke other user's key as non-admin → 403", async () => {
|
|
229
|
+
const bobKey = makeEntry("k1", "Bob Key", "bob@test.com");
|
|
230
|
+
const { app } = await buildApp([bobKey], "alice@test.com");
|
|
231
|
+
|
|
232
|
+
const res = await app.inject({ method: "POST", url: "/api/model-proxy/api-keys/k1/revoke" });
|
|
233
|
+
expect(res.statusCode).toBe(403);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("admin can revoke other user's key → 204", async () => {
|
|
237
|
+
const bobKey = makeEntry("k1", "Bob Key", "bob@test.com");
|
|
238
|
+
const { app } = await buildApp([bobKey], "admin@test.com", "admin@test.com");
|
|
239
|
+
|
|
240
|
+
const res = await app.inject({ method: "POST", url: "/api/model-proxy/api-keys/k1/revoke" });
|
|
241
|
+
expect(res.statusCode).toBe(204);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("purge: DELETE /api/model-proxy/api-keys/:id (task 4.3)", () => {
|
|
246
|
+
it("purge after revoke removes entry", async () => {
|
|
247
|
+
const key = makeEntry("k1", "Key", "alice@test.com", { revokedAt: Date.now() });
|
|
248
|
+
const { app, getConfig } = await buildApp([key]);
|
|
249
|
+
|
|
250
|
+
const res = await app.inject({ method: "DELETE", url: "/api/model-proxy/api-keys/k1" });
|
|
251
|
+
expect(res.statusCode).toBe(204);
|
|
252
|
+
expect(getConfig().apiKeys.find((k) => k.id === "k1")).toBeUndefined();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("list excludes purged keys", async () => {
|
|
256
|
+
const key = makeEntry("k1", "Key", "alice@test.com");
|
|
257
|
+
const { app, getConfig } = await buildApp([key]);
|
|
258
|
+
|
|
259
|
+
await app.inject({ method: "DELETE", url: "/api/model-proxy/api-keys/k1" });
|
|
260
|
+
|
|
261
|
+
const listRes = await app.inject({ method: "GET", url: "/api/model-proxy/api-keys" });
|
|
262
|
+
const body = JSON.parse(listRes.body);
|
|
263
|
+
const allIds = [
|
|
264
|
+
...body.data.keys.map((k: any) => k.id),
|
|
265
|
+
...body.data.revoked.map((k: any) => k.id),
|
|
266
|
+
];
|
|
267
|
+
expect(allIds).not.toContain("k1");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("purge non-owner's key as non-admin → 403", async () => {
|
|
271
|
+
const bobKey = makeEntry("k1", "Bob", "bob@test.com");
|
|
272
|
+
const { app } = await buildApp([bobKey], "alice@test.com");
|
|
273
|
+
|
|
274
|
+
const res = await app.inject({ method: "DELETE", url: "/api/model-proxy/api-keys/k1" });
|
|
275
|
+
expect(res.statusCode).toBe(403);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the model proxy auth gate (task 3.9).
|
|
3
|
+
*
|
|
4
|
+
* Tests every auth scenario from spec.md:
|
|
5
|
+
* - valid key → 200 (models endpoint)
|
|
6
|
+
* - JWT rejected uniformly
|
|
7
|
+
* - no header → 401 AUTH_REQUIRED
|
|
8
|
+
* - scope insufficient → 403
|
|
9
|
+
* - expired → 401 AUTH_EXPIRED
|
|
10
|
+
* - revoked → 401 AUTH_REVOKED
|
|
11
|
+
* - missing → 401 AUTH_REQUIRED
|
|
12
|
+
* - malformed → 401 AUTH_MALFORMED
|
|
13
|
+
* - backoff increments and caps
|
|
14
|
+
* - backoff resets on success
|
|
15
|
+
* - per-IP isolation
|
|
16
|
+
*
|
|
17
|
+
* Uses Fastify directly without the full test server to keep tests fast.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
20
|
+
import Fastify from "fastify";
|
|
21
|
+
import { createModelProxyAuthGate } from "../model-proxy/auth-gate.js";
|
|
22
|
+
import { generateKey, hashKey } from "../model-proxy/api-key-store.js";
|
|
23
|
+
import type { ModelProxyConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
24
|
+
|
|
25
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function makeConfig(apiKeys: any[] = []): ModelProxyConfig {
|
|
28
|
+
return {
|
|
29
|
+
enabled: true,
|
|
30
|
+
maxConcurrentStreams: 16,
|
|
31
|
+
perKeyConcurrentStreams: 4,
|
|
32
|
+
logRequests: false,
|
|
33
|
+
apiKeys,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeKey(overrides: Partial<any> = {}) {
|
|
38
|
+
const cleartext = generateKey();
|
|
39
|
+
const entry = {
|
|
40
|
+
id: "key-1",
|
|
41
|
+
label: "test",
|
|
42
|
+
createdAt: Date.now(),
|
|
43
|
+
hash: hashKey(cleartext),
|
|
44
|
+
scopes: ["all"],
|
|
45
|
+
revokedAt: undefined,
|
|
46
|
+
expiresAt: undefined,
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
return { cleartext, entry };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function buildApp(config: ModelProxyConfig) {
|
|
53
|
+
const app = Fastify({ logger: false });
|
|
54
|
+
|
|
55
|
+
const gate = createModelProxyAuthGate({ getConfig: () => config });
|
|
56
|
+
app.addHook("onRequest", gate);
|
|
57
|
+
|
|
58
|
+
app.get("/v1/models", async () => ({ object: "list", data: [] }));
|
|
59
|
+
app.post("/v1/chat/completions", async () => ({ ok: true }));
|
|
60
|
+
app.post("/v1/messages", async () => ({ ok: true }));
|
|
61
|
+
app.get("/api/health", async () => ({ ok: true })); // non-proxied route
|
|
62
|
+
|
|
63
|
+
await app.ready();
|
|
64
|
+
return app;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
describe("model proxy auth gate (task 3.9)", () => {
|
|
70
|
+
it("valid key → 200 on /v1/models", async () => {
|
|
71
|
+
const { cleartext, entry } = makeKey();
|
|
72
|
+
const config = makeConfig([entry]);
|
|
73
|
+
const app = await buildApp(config);
|
|
74
|
+
|
|
75
|
+
const res = await app.inject({
|
|
76
|
+
method: "GET",
|
|
77
|
+
url: "/v1/models",
|
|
78
|
+
headers: { authorization: `Bearer ${cleartext}` },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(res.statusCode).toBe(200);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("valid key on /v1/chat/completions", async () => {
|
|
85
|
+
const { cleartext, entry } = makeKey();
|
|
86
|
+
const config = makeConfig([entry]);
|
|
87
|
+
const app = await buildApp(config);
|
|
88
|
+
|
|
89
|
+
const res = await app.inject({
|
|
90
|
+
method: "POST",
|
|
91
|
+
url: "/v1/chat/completions",
|
|
92
|
+
headers: { authorization: `Bearer ${cleartext}`, "content-type": "application/json" },
|
|
93
|
+
body: JSON.stringify({}),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(res.statusCode).not.toBe(401);
|
|
97
|
+
expect(res.statusCode).not.toBe(403);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("JWT-style token rejected with PROXY_KEY_REQUIRED", async () => {
|
|
101
|
+
const config = makeConfig([]);
|
|
102
|
+
const app = await buildApp(config);
|
|
103
|
+
|
|
104
|
+
const res = await app.inject({
|
|
105
|
+
method: "GET",
|
|
106
|
+
url: "/v1/models",
|
|
107
|
+
headers: { authorization: "Bearer eyJhbGciOiJIUzI1NiJ9.test.fake" },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(res.statusCode).toBe(401);
|
|
111
|
+
expect(JSON.parse(res.body).code).toBe("PROXY_KEY_REQUIRED");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("no authorization header → 401 AUTH_REQUIRED", async () => {
|
|
115
|
+
const config = makeConfig([]);
|
|
116
|
+
const app = await buildApp(config);
|
|
117
|
+
|
|
118
|
+
const res = await app.inject({ method: "GET", url: "/v1/models" });
|
|
119
|
+
|
|
120
|
+
expect(res.statusCode).toBe(401);
|
|
121
|
+
expect(JSON.parse(res.body).code).toBe("AUTH_REQUIRED");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("malformed Bearer (no token) → 401 AUTH_MALFORMED", async () => {
|
|
125
|
+
const config = makeConfig([]);
|
|
126
|
+
const app = await buildApp(config);
|
|
127
|
+
|
|
128
|
+
const res = await app.inject({
|
|
129
|
+
method: "GET",
|
|
130
|
+
url: "/v1/models",
|
|
131
|
+
headers: { authorization: "Bearer " },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(res.statusCode).toBe(401);
|
|
135
|
+
expect(JSON.parse(res.body).code).toBe("AUTH_MALFORMED");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("missing (unknown) proxy key → 401 AUTH_REQUIRED", async () => {
|
|
139
|
+
const config = makeConfig([]);
|
|
140
|
+
const app = await buildApp(config);
|
|
141
|
+
|
|
142
|
+
const res = await app.inject({
|
|
143
|
+
method: "GET",
|
|
144
|
+
url: "/v1/models",
|
|
145
|
+
headers: { authorization: `Bearer pi-proxy-unknownkeyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(res.statusCode).toBe(401);
|
|
149
|
+
expect(JSON.parse(res.body).code).toBe("AUTH_REQUIRED");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("revoked key → 401 AUTH_REVOKED", async () => {
|
|
153
|
+
const { cleartext, entry } = makeKey({ revokedAt: Date.now() - 1000 });
|
|
154
|
+
const config = makeConfig([entry]);
|
|
155
|
+
const app = await buildApp(config);
|
|
156
|
+
|
|
157
|
+
const res = await app.inject({
|
|
158
|
+
method: "GET",
|
|
159
|
+
url: "/v1/models",
|
|
160
|
+
headers: { authorization: `Bearer ${cleartext}` },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(res.statusCode).toBe(401);
|
|
164
|
+
expect(JSON.parse(res.body).code).toBe("AUTH_REVOKED");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("expired key → 401 AUTH_EXPIRED", async () => {
|
|
168
|
+
const { cleartext, entry } = makeKey({ expiresAt: Date.now() - 1000 });
|
|
169
|
+
const config = makeConfig([entry]);
|
|
170
|
+
const app = await buildApp(config);
|
|
171
|
+
|
|
172
|
+
const res = await app.inject({
|
|
173
|
+
method: "GET",
|
|
174
|
+
url: "/v1/models",
|
|
175
|
+
headers: { authorization: `Bearer ${cleartext}` },
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(res.statusCode).toBe(401);
|
|
179
|
+
expect(JSON.parse(res.body).code).toBe("AUTH_EXPIRED");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("scope insufficient → 403 SCOPE_INSUFFICIENT", async () => {
|
|
183
|
+
const { cleartext, entry } = makeKey({ scopes: ["models:list"] });
|
|
184
|
+
const config = makeConfig([entry]);
|
|
185
|
+
const app = await buildApp(config);
|
|
186
|
+
|
|
187
|
+
// /v1/chat/completions requires "chat" scope
|
|
188
|
+
const res = await app.inject({
|
|
189
|
+
method: "POST",
|
|
190
|
+
url: "/v1/chat/completions",
|
|
191
|
+
headers: { authorization: `Bearer ${cleartext}`, "content-type": "application/json" },
|
|
192
|
+
body: JSON.stringify({}),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(res.statusCode).toBe(403);
|
|
196
|
+
expect(JSON.parse(res.body).code).toBe("SCOPE_INSUFFICIENT");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("non-/v1/ routes NOT gated by auth gate", async () => {
|
|
200
|
+
const config = makeConfig([]);
|
|
201
|
+
const app = await buildApp(config);
|
|
202
|
+
|
|
203
|
+
// /api/health should pass through without auth
|
|
204
|
+
const res = await app.inject({ method: "GET", url: "/api/health" });
|
|
205
|
+
expect(res.statusCode).toBe(200);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("task 3.7: /v1/* does NOT inherit isLoopback bypass (loopback without key → 401)", async () => {
|
|
209
|
+
// In the auth gate, /v1/* always requires a proxy key — no loopback carve-out.
|
|
210
|
+
// We simulate this by simply not providing an Authorization header.
|
|
211
|
+
const config = makeConfig([]);
|
|
212
|
+
const app = await buildApp(config);
|
|
213
|
+
|
|
214
|
+
// Even from "loopback" (Fastify inject defaults to 127.0.0.1)
|
|
215
|
+
const res = await app.inject({ method: "GET", url: "/v1/models" });
|
|
216
|
+
expect(res.statusCode).toBe(401);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("task 3.7: /v1/* does NOT inherit bypassHosts bypass (no header → 401)", async () => {
|
|
220
|
+
// bypassHosts typically allows any LAN IP — /v1/* must not inherit this.
|
|
221
|
+
// The gate checks path prefix first; no Authorization → 401 regardless.
|
|
222
|
+
const config = makeConfig([]);
|
|
223
|
+
const app = await buildApp(config);
|
|
224
|
+
|
|
225
|
+
const res = await app.inject({
|
|
226
|
+
method: "GET",
|
|
227
|
+
url: "/v1/models",
|
|
228
|
+
remoteAddress: "192.168.1.50", // simulated LAN IP
|
|
229
|
+
});
|
|
230
|
+
expect(res.statusCode).toBe(401);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("task 3.7: /v1/* does NOT inherit bypassUrls (no header → 401 even if URL were in bypass list)", async () => {
|
|
234
|
+
const config = makeConfig([]);
|
|
235
|
+
const app = await buildApp(config);
|
|
236
|
+
|
|
237
|
+
const res = await app.inject({ method: "GET", url: "/v1/models" });
|
|
238
|
+
expect(res.statusCode).toBe(401);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("model proxy auth gate — backoff (task 3.9)", () => {
|
|
243
|
+
it("repeated failures from same IP accumulate (backoff state per instance)", async () => {
|
|
244
|
+
// We can only verify that the gate records failures by observing that
|
|
245
|
+
// a valid key immediately after failures still resets and returns 200.
|
|
246
|
+
const { cleartext, entry } = makeKey();
|
|
247
|
+
const config = makeConfig([entry]);
|
|
248
|
+
const app = await buildApp(config);
|
|
249
|
+
|
|
250
|
+
// 3 failed attempts
|
|
251
|
+
for (let i = 0; i < 3; i++) {
|
|
252
|
+
await app.inject({ method: "GET", url: "/v1/models" });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Success resets — valid key still works (though may be delayed by backoff)
|
|
256
|
+
const res = await app.inject({
|
|
257
|
+
method: "GET",
|
|
258
|
+
url: "/v1/models",
|
|
259
|
+
headers: { authorization: `Bearer ${cleartext}` },
|
|
260
|
+
});
|
|
261
|
+
expect(res.statusCode).toBe(200);
|
|
262
|
+
});
|
|
263
|
+
});
|