@vellumai/vellum-gateway 0.6.5 → 0.6.6
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/package.json +1 -1
- package/src/__tests__/auto-approve-conversation-thresholds.test.ts +190 -0
- package/src/__tests__/auto-approve-thresholds.test.ts +238 -0
- package/src/__tests__/cloud-oauth-token.test.ts +81 -63
- package/src/__tests__/guardian-init-lockfile.test.ts +141 -0
- package/src/__tests__/ipc-socket-path.test.ts +8 -45
- package/src/__tests__/remote-feature-flag-sync.test.ts +7 -27
- package/src/db/schema.ts +26 -0
- package/src/feature-flag-registry.json +17 -1
- package/src/http/routes/auto-approve-thresholds.ts +295 -0
- package/src/http/routes/channel-verification-session-proxy.ts +56 -1
- package/src/http/routes/cloud-oauth-token.ts +37 -22
- package/src/http/routes/mailgun-webhook.ts +403 -0
- package/src/http/routes/resend-webhook.ts +492 -0
- package/src/index.ts +123 -1
- package/src/ipc/socket-path.ts +1 -25
- package/src/ipc/threshold-handlers.ts +66 -0
- package/src/remote-feature-flag-sync.ts +3 -11
- package/src/schema.ts +539 -0
package/package.json
CHANGED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { initGatewayDb, resetGatewayDb } from "../db/connection.js";
|
|
3
|
+
import {
|
|
4
|
+
createConversationThresholdGetHandler,
|
|
5
|
+
createConversationThresholdPutHandler,
|
|
6
|
+
createConversationThresholdDeleteHandler,
|
|
7
|
+
} from "../http/routes/auto-approve-thresholds.js";
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
resetGatewayDb();
|
|
11
|
+
await initGatewayDb();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
resetGatewayDb();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const BASE_URL = "http://gateway.test";
|
|
23
|
+
|
|
24
|
+
function makeGet(conversationId: string): [Request, string[]] {
|
|
25
|
+
return [
|
|
26
|
+
new Request(
|
|
27
|
+
`${BASE_URL}/v1/permissions/thresholds/conversations/${conversationId}`,
|
|
28
|
+
{ method: "GET" },
|
|
29
|
+
),
|
|
30
|
+
[conversationId],
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makePut(conversationId: string, body: unknown): [Request, string[]] {
|
|
35
|
+
return [
|
|
36
|
+
new Request(
|
|
37
|
+
`${BASE_URL}/v1/permissions/thresholds/conversations/${conversationId}`,
|
|
38
|
+
{
|
|
39
|
+
method: "PUT",
|
|
40
|
+
headers: { "Content-Type": "application/json" },
|
|
41
|
+
body: JSON.stringify(body),
|
|
42
|
+
},
|
|
43
|
+
),
|
|
44
|
+
[conversationId],
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeDelete(conversationId: string): [Request, string[]] {
|
|
49
|
+
return [
|
|
50
|
+
new Request(
|
|
51
|
+
`${BASE_URL}/v1/permissions/thresholds/conversations/${conversationId}`,
|
|
52
|
+
{ method: "DELETE" },
|
|
53
|
+
),
|
|
54
|
+
[conversationId],
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Tests
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
describe("GET /v1/permissions/thresholds/conversations/:conversationId", () => {
|
|
63
|
+
test("returns 404 for nonexistent conversation", async () => {
|
|
64
|
+
const handler = createConversationThresholdGetHandler();
|
|
65
|
+
const [req, params] = makeGet("conv-xyz");
|
|
66
|
+
|
|
67
|
+
const res = await handler(req, params);
|
|
68
|
+
expect(res.status).toBe(404);
|
|
69
|
+
|
|
70
|
+
const body = await res.json();
|
|
71
|
+
expect(body.error).toBe("No override for this conversation");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("returns threshold after PUT creates it", async () => {
|
|
75
|
+
const putHandler = createConversationThresholdPutHandler();
|
|
76
|
+
const getHandler = createConversationThresholdGetHandler();
|
|
77
|
+
|
|
78
|
+
// Create the override
|
|
79
|
+
const [putReq, putParams] = makePut("conv-xyz", { threshold: "medium" });
|
|
80
|
+
const putRes = await putHandler(putReq, putParams);
|
|
81
|
+
expect(putRes.status).toBe(200);
|
|
82
|
+
|
|
83
|
+
// Read it back
|
|
84
|
+
const [getReq, getParams] = makeGet("conv-xyz");
|
|
85
|
+
const getRes = await getHandler(getReq, getParams);
|
|
86
|
+
expect(getRes.status).toBe(200);
|
|
87
|
+
|
|
88
|
+
const body = await getRes.json();
|
|
89
|
+
expect(body.threshold).toBe("medium");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("PUT /v1/permissions/thresholds/conversations/:conversationId", () => {
|
|
94
|
+
test("creates override and returns conversationId + threshold", async () => {
|
|
95
|
+
const handler = createConversationThresholdPutHandler();
|
|
96
|
+
const [req, params] = makePut("conv-abc", { threshold: "low" });
|
|
97
|
+
|
|
98
|
+
const res = await handler(req, params);
|
|
99
|
+
expect(res.status).toBe(200);
|
|
100
|
+
|
|
101
|
+
const body = await res.json();
|
|
102
|
+
expect(body).toEqual({ conversationId: "conv-abc", threshold: "low" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("updates existing override with new value", async () => {
|
|
106
|
+
const handler = createConversationThresholdPutHandler();
|
|
107
|
+
const getHandler = createConversationThresholdGetHandler();
|
|
108
|
+
|
|
109
|
+
// Create initial
|
|
110
|
+
const [req1, params1] = makePut("conv-abc", { threshold: "low" });
|
|
111
|
+
await handler(req1, params1);
|
|
112
|
+
|
|
113
|
+
// Update
|
|
114
|
+
const [req2, params2] = makePut("conv-abc", { threshold: "none" });
|
|
115
|
+
const res = await handler(req2, params2);
|
|
116
|
+
expect(res.status).toBe(200);
|
|
117
|
+
|
|
118
|
+
const body = await res.json();
|
|
119
|
+
expect(body).toEqual({ conversationId: "conv-abc", threshold: "none" });
|
|
120
|
+
|
|
121
|
+
// Verify via GET
|
|
122
|
+
const [getReq, getParams] = makeGet("conv-abc");
|
|
123
|
+
const getRes = await getHandler(getReq, getParams);
|
|
124
|
+
const getBody = await getRes.json();
|
|
125
|
+
expect(getBody.threshold).toBe("none");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("returns 400 for invalid threshold", async () => {
|
|
129
|
+
const handler = createConversationThresholdPutHandler();
|
|
130
|
+
const [req, params] = makePut("conv-abc", { threshold: "invalid" });
|
|
131
|
+
|
|
132
|
+
const res = await handler(req, params);
|
|
133
|
+
expect(res.status).toBe(400);
|
|
134
|
+
|
|
135
|
+
const body = await res.json();
|
|
136
|
+
expect(body.error).toContain("threshold");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("returns 400 for missing threshold", async () => {
|
|
140
|
+
const handler = createConversationThresholdPutHandler();
|
|
141
|
+
const [req, params] = makePut("conv-abc", {});
|
|
142
|
+
|
|
143
|
+
const res = await handler(req, params);
|
|
144
|
+
expect(res.status).toBe(400);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("returns 400 for non-JSON body", async () => {
|
|
148
|
+
const handler = createConversationThresholdPutHandler();
|
|
149
|
+
const req = new Request(
|
|
150
|
+
`${BASE_URL}/v1/permissions/thresholds/conversations/conv-abc`,
|
|
151
|
+
{
|
|
152
|
+
method: "PUT",
|
|
153
|
+
body: "not json",
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const res = await handler(req, ["conv-abc"]);
|
|
158
|
+
expect(res.status).toBe(400);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("DELETE /v1/permissions/thresholds/conversations/:conversationId", () => {
|
|
163
|
+
test("removes existing override, subsequent GET returns 404", async () => {
|
|
164
|
+
const putHandler = createConversationThresholdPutHandler();
|
|
165
|
+
const getHandler = createConversationThresholdGetHandler();
|
|
166
|
+
const deleteHandler = createConversationThresholdDeleteHandler();
|
|
167
|
+
|
|
168
|
+
// Create
|
|
169
|
+
const [putReq, putParams] = makePut("conv-del", { threshold: "medium" });
|
|
170
|
+
await putHandler(putReq, putParams);
|
|
171
|
+
|
|
172
|
+
// Delete
|
|
173
|
+
const [delReq, delParams] = makeDelete("conv-del");
|
|
174
|
+
const delRes = await deleteHandler(delReq, delParams);
|
|
175
|
+
expect(delRes.status).toBe(204);
|
|
176
|
+
|
|
177
|
+
// Verify gone
|
|
178
|
+
const [getReq, getParams] = makeGet("conv-del");
|
|
179
|
+
const getRes = await getHandler(getReq, getParams);
|
|
180
|
+
expect(getRes.status).toBe(404);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("returns 204 on nonexistent conversation (idempotent)", async () => {
|
|
184
|
+
const handler = createConversationThresholdDeleteHandler();
|
|
185
|
+
const [req, params] = makeDelete("conv-nonexistent");
|
|
186
|
+
|
|
187
|
+
const res = await handler(req, params);
|
|
188
|
+
expect(res.status).toBe(204);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { initGatewayDb, resetGatewayDb } from "../db/connection.js";
|
|
3
|
+
import "./test-preload.js";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createGlobalThresholdGetHandler,
|
|
7
|
+
createGlobalThresholdPutHandler,
|
|
8
|
+
} from "../http/routes/auto-approve-thresholds.js";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Setup / teardown
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
resetGatewayDb();
|
|
16
|
+
await initGatewayDb();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
resetGatewayDb();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Helpers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
function makeRequest(body?: unknown, method = "PUT"): Request {
|
|
28
|
+
if (body !== undefined) {
|
|
29
|
+
return new Request("http://localhost/v1/permissions/thresholds", {
|
|
30
|
+
method,
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
body: JSON.stringify(body),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return new Request("http://localhost/v1/permissions/thresholds", {
|
|
36
|
+
method,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Tests
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
describe("auto-approve thresholds", () => {
|
|
45
|
+
describe("GET handler", () => {
|
|
46
|
+
test("returns defaults when no row exists", async () => {
|
|
47
|
+
const handler = createGlobalThresholdGetHandler();
|
|
48
|
+
const res = await handler(makeRequest(undefined, "GET"));
|
|
49
|
+
|
|
50
|
+
expect(res.status).toBe(200);
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
expect(data).toEqual({
|
|
53
|
+
interactive: "low",
|
|
54
|
+
background: "medium",
|
|
55
|
+
headless: "none",
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("returns updated values after PUT", async () => {
|
|
60
|
+
const putHandler = createGlobalThresholdPutHandler();
|
|
61
|
+
const getHandler = createGlobalThresholdGetHandler();
|
|
62
|
+
|
|
63
|
+
// First PUT to set values
|
|
64
|
+
await putHandler(makeRequest({ interactive: "medium" }));
|
|
65
|
+
|
|
66
|
+
// GET should reflect the update
|
|
67
|
+
const res = await getHandler(makeRequest(undefined, "GET"));
|
|
68
|
+
expect(res.status).toBe(200);
|
|
69
|
+
const data = await res.json();
|
|
70
|
+
expect(data).toEqual({
|
|
71
|
+
interactive: "medium",
|
|
72
|
+
background: "medium",
|
|
73
|
+
headless: "none",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("PUT handler", () => {
|
|
79
|
+
test("partial update only changes provided fields", async () => {
|
|
80
|
+
const handler = createGlobalThresholdPutHandler();
|
|
81
|
+
|
|
82
|
+
const res = await handler(makeRequest({ interactive: "medium" }));
|
|
83
|
+
expect(res.status).toBe(200);
|
|
84
|
+
const data = await res.json();
|
|
85
|
+
expect(data).toEqual({
|
|
86
|
+
interactive: "medium",
|
|
87
|
+
background: "medium",
|
|
88
|
+
headless: "none",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("returns 400 for invalid threshold value", async () => {
|
|
93
|
+
const handler = createGlobalThresholdPutHandler();
|
|
94
|
+
|
|
95
|
+
const res = await handler(makeRequest({ interactive: "extreme" }));
|
|
96
|
+
expect(res.status).toBe(400);
|
|
97
|
+
const data = await res.json();
|
|
98
|
+
expect(data.error).toContain("interactive");
|
|
99
|
+
expect(data.error).toContain("none, low, medium, high");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("accepts high as a valid threshold value", async () => {
|
|
103
|
+
const handler = createGlobalThresholdPutHandler();
|
|
104
|
+
|
|
105
|
+
const res = await handler(makeRequest({ interactive: "high" }));
|
|
106
|
+
expect(res.status).toBe(200);
|
|
107
|
+
const data = await res.json();
|
|
108
|
+
expect(data).toEqual({
|
|
109
|
+
interactive: "high",
|
|
110
|
+
background: "medium",
|
|
111
|
+
headless: "none",
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("returns 400 for invalid body (non-JSON)", async () => {
|
|
116
|
+
const handler = createGlobalThresholdPutHandler();
|
|
117
|
+
|
|
118
|
+
const req = new Request("http://localhost/v1/permissions/thresholds", {
|
|
119
|
+
method: "PUT",
|
|
120
|
+
body: "not json",
|
|
121
|
+
});
|
|
122
|
+
const res = await handler(req);
|
|
123
|
+
expect(res.status).toBe(400);
|
|
124
|
+
const data = await res.json();
|
|
125
|
+
expect(data.error).toContain("valid JSON");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("returns 400 for non-object body", async () => {
|
|
129
|
+
const handler = createGlobalThresholdPutHandler();
|
|
130
|
+
|
|
131
|
+
const res = await handler(makeRequest("just a string"));
|
|
132
|
+
expect(res.status).toBe(400);
|
|
133
|
+
const data = await res.json();
|
|
134
|
+
expect(data.error).toContain("JSON object");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("returns 400 for array body", async () => {
|
|
138
|
+
const handler = createGlobalThresholdPutHandler();
|
|
139
|
+
|
|
140
|
+
const res = await handler(makeRequest([1, 2, 3]));
|
|
141
|
+
expect(res.status).toBe(400);
|
|
142
|
+
const data = await res.json();
|
|
143
|
+
expect(data.error).toContain("JSON object");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("returns 400 for invalid background value", async () => {
|
|
147
|
+
const handler = createGlobalThresholdPutHandler();
|
|
148
|
+
|
|
149
|
+
const res = await handler(makeRequest({ background: "invalid" }));
|
|
150
|
+
expect(res.status).toBe(400);
|
|
151
|
+
const data = await res.json();
|
|
152
|
+
expect(data.error).toContain("background");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("returns 400 for invalid headless value", async () => {
|
|
156
|
+
const handler = createGlobalThresholdPutHandler();
|
|
157
|
+
|
|
158
|
+
const res = await handler(makeRequest({ headless: 42 }));
|
|
159
|
+
expect(res.status).toBe(400);
|
|
160
|
+
const data = await res.json();
|
|
161
|
+
expect(data.error).toContain("headless");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("upserts correctly — first write creates, second write updates", async () => {
|
|
165
|
+
const handler = createGlobalThresholdPutHandler();
|
|
166
|
+
|
|
167
|
+
// First write — creates the row
|
|
168
|
+
const res1 = await handler(
|
|
169
|
+
makeRequest({ interactive: "none", background: "low" }),
|
|
170
|
+
);
|
|
171
|
+
expect(res1.status).toBe(200);
|
|
172
|
+
const data1 = await res1.json();
|
|
173
|
+
expect(data1).toEqual({
|
|
174
|
+
interactive: "none",
|
|
175
|
+
background: "low",
|
|
176
|
+
headless: "none",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Second write — updates the existing row
|
|
180
|
+
const res2 = await handler(
|
|
181
|
+
makeRequest({ background: "medium", headless: "low" }),
|
|
182
|
+
);
|
|
183
|
+
expect(res2.status).toBe(200);
|
|
184
|
+
const data2 = await res2.json();
|
|
185
|
+
expect(data2).toEqual({
|
|
186
|
+
interactive: "none",
|
|
187
|
+
background: "medium",
|
|
188
|
+
headless: "low",
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("updates all fields at once", async () => {
|
|
193
|
+
const handler = createGlobalThresholdPutHandler();
|
|
194
|
+
|
|
195
|
+
const res = await handler(
|
|
196
|
+
makeRequest({
|
|
197
|
+
interactive: "medium",
|
|
198
|
+
background: "none",
|
|
199
|
+
headless: "low",
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
expect(res.status).toBe(200);
|
|
203
|
+
const data = await res.json();
|
|
204
|
+
expect(data).toEqual({
|
|
205
|
+
interactive: "medium",
|
|
206
|
+
background: "none",
|
|
207
|
+
headless: "low",
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("empty object preserves existing values when row exists", async () => {
|
|
212
|
+
const putHandler = createGlobalThresholdPutHandler();
|
|
213
|
+
|
|
214
|
+
// First: set non-default values
|
|
215
|
+
await putHandler(
|
|
216
|
+
makeRequest({
|
|
217
|
+
interactive: "medium",
|
|
218
|
+
background: "none",
|
|
219
|
+
headless: "low",
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Then PUT empty object — existing values should be preserved
|
|
224
|
+
const res = await putHandler(makeRequest({}));
|
|
225
|
+
expect(res.status).toBe(200);
|
|
226
|
+
const data = await res.json();
|
|
227
|
+
expect(data).toEqual({
|
|
228
|
+
interactive: "medium",
|
|
229
|
+
background: "none",
|
|
230
|
+
headless: "low",
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Note: "empty PUT inserts schema defaults when no row" is covered by
|
|
235
|
+
// the GET handler test suite. The PUT tests run after prior PUTs leave
|
|
236
|
+
// a row in the DB (bun test reuse), so we test preserve-existing above.
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -1,41 +1,56 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
test,
|
|
4
|
+
expect,
|
|
5
|
+
beforeAll,
|
|
6
|
+
beforeEach,
|
|
7
|
+
afterEach,
|
|
8
|
+
} from "bun:test";
|
|
2
9
|
import "./test-preload.js";
|
|
3
10
|
|
|
4
11
|
import {
|
|
5
12
|
initSigningKey,
|
|
6
13
|
loadOrCreateSigningKey,
|
|
7
|
-
mintToken,
|
|
8
14
|
verifyToken,
|
|
9
15
|
} from "../auth/token-service.js";
|
|
10
|
-
import { CURRENT_POLICY_EPOCH } from "../auth/policy.js";
|
|
11
16
|
import { createCloudOAuthTokenHandler } from "../http/routes/cloud-oauth-token.js";
|
|
12
17
|
|
|
13
|
-
let serviceToken: string;
|
|
14
|
-
|
|
15
18
|
beforeAll(() => {
|
|
16
19
|
initSigningKey(loadOrCreateSigningKey());
|
|
17
|
-
// Mint a service token (svc:gateway:self) that the handler accepts.
|
|
18
|
-
serviceToken = mintToken({
|
|
19
|
-
aud: "vellum-gateway",
|
|
20
|
-
sub: "svc:gateway:self",
|
|
21
|
-
scope_profile: "gateway_service_v1",
|
|
22
|
-
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
23
|
-
ttlSeconds: 60,
|
|
24
|
-
});
|
|
25
20
|
});
|
|
26
21
|
|
|
27
22
|
const handler = createCloudOAuthTokenHandler();
|
|
23
|
+
const ASSISTANT_ID = "asst-123";
|
|
24
|
+
const ACTOR_PRINCIPAL_ID = "user-456";
|
|
25
|
+
const PLATFORM_INTERNAL_API_KEY = "platform-internal-key";
|
|
26
|
+
const ORIGINAL_PLATFORM_INTERNAL_API_KEY =
|
|
27
|
+
process.env.PLATFORM_INTERNAL_API_KEY;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
process.env.PLATFORM_INTERNAL_API_KEY = PLATFORM_INTERNAL_API_KEY;
|
|
31
|
+
});
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
if (ORIGINAL_PLATFORM_INTERNAL_API_KEY === undefined) {
|
|
35
|
+
delete process.env.PLATFORM_INTERNAL_API_KEY;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
process.env.PLATFORM_INTERNAL_API_KEY = ORIGINAL_PLATFORM_INTERNAL_API_KEY;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Build a POST request to the cloud OAuth token endpoint. */
|
|
42
|
+
function makeRequest(body: unknown, authorization?: string): Request {
|
|
43
|
+
const headers: Record<string, string> = {
|
|
44
|
+
"content-type": "application/json",
|
|
45
|
+
};
|
|
46
|
+
if (authorization) {
|
|
47
|
+
headers.authorization = authorization;
|
|
48
|
+
}
|
|
31
49
|
return new Request(
|
|
32
50
|
"http://gateway.test/v1/internal/oauth/chrome-extension/token",
|
|
33
51
|
{
|
|
34
52
|
method: "POST",
|
|
35
|
-
headers
|
|
36
|
-
"content-type": "application/json",
|
|
37
|
-
authorization: `Bearer ${serviceToken}`,
|
|
38
|
-
},
|
|
53
|
+
headers,
|
|
39
54
|
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
40
55
|
},
|
|
41
56
|
);
|
|
@@ -44,7 +59,10 @@ function makeRequest(body: unknown): Request {
|
|
|
44
59
|
describe("POST /v1/internal/oauth/chrome-extension/token", () => {
|
|
45
60
|
test("happy path: valid body returns 200 with token, expiresIn, and guardianId", async () => {
|
|
46
61
|
const res = await handler.handleMintToken(
|
|
47
|
-
makeRequest(
|
|
62
|
+
makeRequest(
|
|
63
|
+
{ assistantId: ASSISTANT_ID, actorPrincipalId: ACTOR_PRINCIPAL_ID },
|
|
64
|
+
`Bearer ${PLATFORM_INTERNAL_API_KEY}`,
|
|
65
|
+
),
|
|
48
66
|
);
|
|
49
67
|
|
|
50
68
|
expect(res.status).toBe(200);
|
|
@@ -57,13 +75,15 @@ describe("POST /v1/internal/oauth/chrome-extension/token", () => {
|
|
|
57
75
|
expect(typeof body.token).toBe("string");
|
|
58
76
|
expect(body.token.split(".").length).toBe(3); // JWT format
|
|
59
77
|
expect(body.expiresIn).toBe(3600);
|
|
60
|
-
expect(body.guardianId).toBe(
|
|
78
|
+
expect(body.guardianId).toBe(ACTOR_PRINCIPAL_ID);
|
|
61
79
|
|
|
62
80
|
// Verify the minted token has the correct claims
|
|
63
81
|
const result = verifyToken(body.token, "vellum-gateway");
|
|
64
82
|
expect(result.ok).toBe(true);
|
|
65
83
|
if (result.ok) {
|
|
66
|
-
expect(result.claims.sub).toBe(
|
|
84
|
+
expect(result.claims.sub).toBe(
|
|
85
|
+
`actor:${ASSISTANT_ID}:${ACTOR_PRINCIPAL_ID}`,
|
|
86
|
+
);
|
|
67
87
|
expect(result.claims.aud).toBe("vellum-gateway");
|
|
68
88
|
expect(result.claims.scope_profile).toBe("actor_client_v1");
|
|
69
89
|
}
|
|
@@ -80,7 +100,7 @@ describe("POST /v1/internal/oauth/chrome-extension/token", () => {
|
|
|
80
100
|
|
|
81
101
|
test("missing actorPrincipalId returns 400", async () => {
|
|
82
102
|
const res = await handler.handleMintToken(
|
|
83
|
-
makeRequest({ assistantId:
|
|
103
|
+
makeRequest({ assistantId: ASSISTANT_ID }),
|
|
84
104
|
);
|
|
85
105
|
expect(res.status).toBe(400);
|
|
86
106
|
const body = (await res.json()) as { error: string };
|
|
@@ -98,7 +118,7 @@ describe("POST /v1/internal/oauth/chrome-extension/token", () => {
|
|
|
98
118
|
|
|
99
119
|
test("empty actorPrincipalId string returns 400", async () => {
|
|
100
120
|
const res = await handler.handleMintToken(
|
|
101
|
-
makeRequest({ assistantId:
|
|
121
|
+
makeRequest({ assistantId: ASSISTANT_ID, actorPrincipalId: "" }),
|
|
102
122
|
);
|
|
103
123
|
expect(res.status).toBe(400);
|
|
104
124
|
const body = (await res.json()) as { error: string };
|
|
@@ -107,7 +127,7 @@ describe("POST /v1/internal/oauth/chrome-extension/token", () => {
|
|
|
107
127
|
|
|
108
128
|
test("whitespace-only strings return 400", async () => {
|
|
109
129
|
const res = await handler.handleMintToken(
|
|
110
|
-
makeRequest({ assistantId: " ", actorPrincipalId:
|
|
130
|
+
makeRequest({ assistantId: " ", actorPrincipalId: ACTOR_PRINCIPAL_ID }),
|
|
111
131
|
);
|
|
112
132
|
expect(res.status).toBe(400);
|
|
113
133
|
const body = (await res.json()) as { error: string };
|
|
@@ -120,10 +140,7 @@ describe("POST /v1/internal/oauth/chrome-extension/token", () => {
|
|
|
120
140
|
"http://gateway.test/v1/internal/oauth/chrome-extension/token",
|
|
121
141
|
{
|
|
122
142
|
method: "POST",
|
|
123
|
-
headers: {
|
|
124
|
-
"content-type": "application/json",
|
|
125
|
-
authorization: `Bearer ${serviceToken}`,
|
|
126
|
-
},
|
|
143
|
+
headers: { "content-type": "application/json" },
|
|
127
144
|
body: "not-json",
|
|
128
145
|
},
|
|
129
146
|
),
|
|
@@ -135,7 +152,7 @@ describe("POST /v1/internal/oauth/chrome-extension/token", () => {
|
|
|
135
152
|
|
|
136
153
|
test("non-string assistantId returns 400", async () => {
|
|
137
154
|
const res = await handler.handleMintToken(
|
|
138
|
-
makeRequest({ assistantId: 123, actorPrincipalId:
|
|
155
|
+
makeRequest({ assistantId: 123, actorPrincipalId: ACTOR_PRINCIPAL_ID }),
|
|
139
156
|
);
|
|
140
157
|
expect(res.status).toBe(400);
|
|
141
158
|
const body = (await res.json()) as { error: string };
|
|
@@ -144,7 +161,10 @@ describe("POST /v1/internal/oauth/chrome-extension/token", () => {
|
|
|
144
161
|
|
|
145
162
|
test("colon in assistantId returns 400", async () => {
|
|
146
163
|
const res = await handler.handleMintToken(
|
|
147
|
-
makeRequest({
|
|
164
|
+
makeRequest({
|
|
165
|
+
assistantId: "asst:123",
|
|
166
|
+
actorPrincipalId: ACTOR_PRINCIPAL_ID,
|
|
167
|
+
}),
|
|
148
168
|
);
|
|
149
169
|
expect(res.status).toBe(400);
|
|
150
170
|
const body = (await res.json()) as { error: string };
|
|
@@ -153,7 +173,7 @@ describe("POST /v1/internal/oauth/chrome-extension/token", () => {
|
|
|
153
173
|
|
|
154
174
|
test("colon in actorPrincipalId returns 400", async () => {
|
|
155
175
|
const res = await handler.handleMintToken(
|
|
156
|
-
makeRequest({ assistantId:
|
|
176
|
+
makeRequest({ assistantId: ASSISTANT_ID, actorPrincipalId: "user:456" }),
|
|
157
177
|
);
|
|
158
178
|
expect(res.status).toBe(400);
|
|
159
179
|
const body = (await res.json()) as { error: string };
|
|
@@ -162,45 +182,43 @@ describe("POST /v1/internal/oauth/chrome-extension/token", () => {
|
|
|
162
182
|
|
|
163
183
|
test("request without authorization header returns 403", async () => {
|
|
164
184
|
const res = await handler.handleMintToken(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
185
|
+
makeRequest({
|
|
186
|
+
assistantId: ASSISTANT_ID,
|
|
187
|
+
actorPrincipalId: ACTOR_PRINCIPAL_ID,
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
expect(res.status).toBe(403);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("request with invalid bearer token returns 403", async () => {
|
|
194
|
+
const res = await handler.handleMintToken(
|
|
195
|
+
makeRequest(
|
|
196
|
+
{ assistantId: ASSISTANT_ID, actorPrincipalId: ACTOR_PRINCIPAL_ID },
|
|
197
|
+
"Bearer wrong-internal-key",
|
|
175
198
|
),
|
|
176
199
|
);
|
|
177
200
|
expect(res.status).toBe(403);
|
|
178
201
|
});
|
|
179
202
|
|
|
180
|
-
test("request with
|
|
181
|
-
const actorToken = mintToken({
|
|
182
|
-
aud: "vellum-gateway",
|
|
183
|
-
sub: "actor:asst-123:some-user",
|
|
184
|
-
scope_profile: "actor_client_v1",
|
|
185
|
-
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
186
|
-
ttlSeconds: 60,
|
|
187
|
-
});
|
|
203
|
+
test("request with non-bearer authorization header returns 403", async () => {
|
|
188
204
|
const res = await handler.handleMintToken(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
method: "POST",
|
|
193
|
-
headers: {
|
|
194
|
-
"content-type": "application/json",
|
|
195
|
-
authorization: `Bearer ${actorToken}`,
|
|
196
|
-
},
|
|
197
|
-
body: JSON.stringify({
|
|
198
|
-
assistantId: "asst-123",
|
|
199
|
-
actorPrincipalId: "user-456",
|
|
200
|
-
}),
|
|
201
|
-
},
|
|
205
|
+
makeRequest(
|
|
206
|
+
{ assistantId: ASSISTANT_ID, actorPrincipalId: ACTOR_PRINCIPAL_ID },
|
|
207
|
+
"Api-Key some-key",
|
|
202
208
|
),
|
|
203
209
|
);
|
|
204
210
|
expect(res.status).toBe(403);
|
|
205
211
|
});
|
|
212
|
+
|
|
213
|
+
test("request returns 503 when PLATFORM_INTERNAL_API_KEY is not configured", async () => {
|
|
214
|
+
delete process.env.PLATFORM_INTERNAL_API_KEY;
|
|
215
|
+
|
|
216
|
+
const res = await handler.handleMintToken(
|
|
217
|
+
makeRequest(
|
|
218
|
+
{ assistantId: ASSISTANT_ID, actorPrincipalId: ACTOR_PRINCIPAL_ID },
|
|
219
|
+
`Bearer ${PLATFORM_INTERNAL_API_KEY}`,
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
expect(res.status).toBe(503);
|
|
223
|
+
});
|
|
206
224
|
});
|