416 lines
12 KiB
TypeScript
416 lines
12 KiB
TypeScript
import { assert, assertEquals } from "jsr:@std/assert";
|
|
import { stub } from "jsr:@std/testing/mock";
|
|
import { app } from "../main.ts";
|
|
import { sqlWrapper } from "../db.ts";
|
|
import { valkey } from "../valkey.ts";
|
|
import { rateLimitWrapper } from "../ratelimit.ts";
|
|
import { auditWrapper } from "../audit.ts";
|
|
|
|
const originalSql = sqlWrapper.sql;
|
|
const originalAudit = auditWrapper.auditLog;
|
|
const auditEvents: Array<
|
|
{ action: string; resource?: string | null; details?: any }
|
|
> = [];
|
|
|
|
function setMockSql(mockImpl: () => Promise<any>) {
|
|
sqlWrapper.sql = mockImpl as any;
|
|
}
|
|
|
|
function restoreMockSql() {
|
|
sqlWrapper.sql = originalSql;
|
|
}
|
|
|
|
rateLimitWrapper.checkRateLimit = () => Promise.resolve(true);
|
|
auditWrapper.auditLog = (
|
|
_userId: string | null,
|
|
action: string,
|
|
resource: string | null,
|
|
details: Record<string, unknown> | null,
|
|
) => {
|
|
auditEvents.push({ action, resource, details });
|
|
};
|
|
|
|
Deno.test("Zero-Trust Scope Guards", async (t) => {
|
|
const mockUserId = "user-id-guards";
|
|
|
|
await t.step(
|
|
"Delegated session is blocked from mutating sessions without scope",
|
|
async () => {
|
|
const mockSessionId = "ay_sess_delegated_123";
|
|
|
|
setMockSql(
|
|
(async (strings: any, ..._values: any[]) => {
|
|
const q = Array.isArray(strings)
|
|
? strings.join("?")
|
|
: String(strings);
|
|
if (q.includes("SELECT id, is_agent FROM sessions")) {
|
|
return [{ id: "other_session", is_agent: false }];
|
|
}
|
|
return [];
|
|
}) as any,
|
|
);
|
|
|
|
if ((valkey as any).get.restore) {
|
|
(valkey as any).get.restore();
|
|
}
|
|
const valkeyStub = stub(
|
|
valkey,
|
|
"get",
|
|
() =>
|
|
Promise.resolve(
|
|
JSON.stringify({
|
|
uuid: mockUserId,
|
|
username: "agent-user",
|
|
isAgent: true,
|
|
customScopes: ["read:data"],
|
|
}),
|
|
),
|
|
);
|
|
|
|
try {
|
|
const req = new Request(
|
|
"http://localhost/api/sessions/other_session/extend",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Bearer ${mockSessionId}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ extendHours: 2 }),
|
|
},
|
|
);
|
|
const res = await app.fetch(req);
|
|
assertEquals(res.status, 403);
|
|
const body = await res.json();
|
|
assertEquals(body.error, "Forbidden: Insufficient scopes");
|
|
} finally {
|
|
valkeyStub.restore();
|
|
}
|
|
},
|
|
);
|
|
|
|
await t.step("Delegated session can self-revoke", async () => {
|
|
const mockSessionId = "ay_sess_delegated_123";
|
|
|
|
setMockSql(
|
|
(async (strings: any, ..._values: any[]) => {
|
|
const q = Array.isArray(strings) ? strings.join("?") : String(strings);
|
|
if (q.includes("SELECT id FROM sessions WHERE id =")) {
|
|
return [{ id: mockSessionId }];
|
|
}
|
|
if (q.includes("DELETE FROM sessions")) {
|
|
return [{ id: mockSessionId }];
|
|
}
|
|
return [];
|
|
}) as any,
|
|
);
|
|
|
|
if ((valkey as any).get.restore) {
|
|
(valkey as any).get.restore();
|
|
}
|
|
const valkeyStub = stub(
|
|
valkey,
|
|
"get",
|
|
() =>
|
|
Promise.resolve(
|
|
JSON.stringify({
|
|
uuid: mockUserId,
|
|
username: "agent-user",
|
|
isAgent: true,
|
|
customScopes: ["read:data"],
|
|
}),
|
|
),
|
|
);
|
|
|
|
const delStub = stub(valkey, "del", () => Promise.resolve(1 as any));
|
|
|
|
try {
|
|
const req = new Request(
|
|
`http://localhost/api/sessions/${mockSessionId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { "Authorization": `Bearer ${mockSessionId}` },
|
|
},
|
|
);
|
|
const res = await app.fetch(req);
|
|
assertEquals(res.status, 200);
|
|
const body = await res.json();
|
|
assertEquals(body.success, true);
|
|
} finally {
|
|
valkeyStub.restore();
|
|
delStub.restore();
|
|
}
|
|
});
|
|
|
|
await t.step(
|
|
"Delegated session blocked from UI /admin redirect",
|
|
async () => {
|
|
const mockSessionId = "ay_sess_delegated_123";
|
|
|
|
setMockSql(
|
|
(async (strings: any, ..._values: any[]) => {
|
|
const q = Array.isArray(strings)
|
|
? strings.join("?")
|
|
: String(strings);
|
|
if (q.includes("SELECT g.id")) {
|
|
return [{ id: "admin-grant-id" }];
|
|
}
|
|
return [];
|
|
}) as any,
|
|
);
|
|
|
|
if ((valkey as any).get.restore) {
|
|
(valkey as any).get.restore();
|
|
}
|
|
const valkeyStub = stub(
|
|
valkey,
|
|
"get",
|
|
() =>
|
|
Promise.resolve(
|
|
JSON.stringify({
|
|
uuid: mockUserId,
|
|
username: "agent-user",
|
|
isAgent: true,
|
|
customScopes: ["read:data"],
|
|
}),
|
|
),
|
|
);
|
|
|
|
try {
|
|
const req = new Request("http://localhost/admin/users", {
|
|
headers: { "Cookie": `session_id=${mockSessionId}` },
|
|
});
|
|
const res = await app.fetch(req);
|
|
assertEquals(res.status, 302);
|
|
assertEquals(res.headers.get("location"), "/dashboard");
|
|
} finally {
|
|
valkeyStub.restore();
|
|
}
|
|
},
|
|
);
|
|
|
|
await t.step(
|
|
"Delegated session without admin scope blocked from POST /api/sessions/delegate",
|
|
async () => {
|
|
const mockSessionId = "ay_sess_delegated_123";
|
|
|
|
if ((valkey as any).get.restore) {
|
|
(valkey as any).get.restore();
|
|
}
|
|
const valkeyStub = stub(
|
|
valkey,
|
|
"get",
|
|
() =>
|
|
Promise.resolve(
|
|
JSON.stringify({
|
|
uuid: mockUserId,
|
|
username: "agent-user",
|
|
isAgent: true,
|
|
customScopes: ["write:sessions"],
|
|
}),
|
|
),
|
|
);
|
|
|
|
try {
|
|
const req = new Request("http://localhost/api/sessions/delegate", {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Bearer ${mockSessionId}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ label: "Sub Agent" }),
|
|
});
|
|
const res = await app.fetch(req);
|
|
assertEquals(res.status, 403);
|
|
const body = await res.json();
|
|
assertEquals(body.error, "Forbidden: Insufficient scopes");
|
|
} finally {
|
|
valkeyStub.restore();
|
|
}
|
|
},
|
|
);
|
|
|
|
await t.step(
|
|
"Delegated session without write:events blocked from POST /api/events",
|
|
async () => {
|
|
const mockSessionId = "ay_sess_delegated_123";
|
|
|
|
if ((valkey as any).get.restore) {
|
|
(valkey as any).get.restore();
|
|
}
|
|
const valkeyStub = stub(
|
|
valkey,
|
|
"get",
|
|
() =>
|
|
Promise.resolve(
|
|
JSON.stringify({
|
|
uuid: mockUserId,
|
|
username: "agent-user",
|
|
isAgent: true,
|
|
customScopes: ["read:data"],
|
|
}),
|
|
),
|
|
);
|
|
|
|
try {
|
|
const req = new Request("http://localhost/api/events", {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Bearer ${mockSessionId}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ name: "Hacking Workshop" }),
|
|
});
|
|
const res = await app.fetch(req);
|
|
assertEquals(res.status, 403);
|
|
const body = await res.json();
|
|
assertEquals(body.error, "Forbidden: Insufficient scopes");
|
|
} finally {
|
|
valkeyStub.restore();
|
|
}
|
|
},
|
|
);
|
|
|
|
restoreMockSql();
|
|
});
|
|
|
|
Deno.test("Agent Session Delegation & Scoped Permissions", async (t) => {
|
|
await t.step(
|
|
"POST /api/sessions/delegate - Mints child session with scopes",
|
|
async () => {
|
|
const valkeyStub = stub(valkey, "get", (key: any) => {
|
|
if (String(key) === "admin-session") {
|
|
return Promise.resolve(
|
|
JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }),
|
|
);
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
const valkeySetStub = stub(valkey, "set", () => Promise.resolve("OK"));
|
|
|
|
const originalSql = sqlWrapper.sql;
|
|
sqlWrapper.sql = ((_query: any, ..._args: any[]) => {
|
|
return Promise.resolve([{ id: "mock-id" }]);
|
|
}) as any;
|
|
|
|
try {
|
|
const res = await app.request("/api/sessions/delegate", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: "Bearer admin-session",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
label: "Antigravity Assistant",
|
|
lifespanHours: 12,
|
|
mode: "read_only",
|
|
}),
|
|
});
|
|
|
|
assertEquals(res.status, 200);
|
|
const json = await res.json();
|
|
assert(json.success === true);
|
|
assert(json.sessionId.startsWith("ay_sess_"));
|
|
assertEquals(json.label, "Antigravity Assistant");
|
|
assert(json.scopes.includes("read:audit"));
|
|
} finally {
|
|
sqlWrapper.sql = originalSql;
|
|
valkeyStub.restore();
|
|
valkeySetStub.restore();
|
|
}
|
|
},
|
|
);
|
|
|
|
await t.step(
|
|
"PUT /api/sessions/:id/scopes - Updates custom scopes",
|
|
async () => {
|
|
const valkeyStub = stub(valkey, "get", (key: any) => {
|
|
if (String(key) === "admin-session") {
|
|
return Promise.resolve(
|
|
JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }),
|
|
);
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
|
|
const originalSql = sqlWrapper.sql;
|
|
sqlWrapper.sql = ((_query: any, ..._args: any[]) => {
|
|
return Promise.resolve([{ id: "ay_sess_123", is_agent: true }]);
|
|
}) as any;
|
|
|
|
try {
|
|
const res = await app.request("/api/sessions/ay_sess_123/scopes", {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: "Bearer admin-session",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
customScopes: ["read:audit", "app:ed-droid"],
|
|
}),
|
|
});
|
|
|
|
assertEquals(res.status, 200);
|
|
const json = await res.json();
|
|
assert(json.success === true);
|
|
assertEquals(json.scopes, ["read:audit", "app:ed-droid"]);
|
|
} finally {
|
|
sqlWrapper.sql = originalSql;
|
|
valkeyStub.restore();
|
|
}
|
|
},
|
|
);
|
|
|
|
await t.step(
|
|
"POST /api/sessions/:id/extend - Extends session TTL",
|
|
async () => {
|
|
const valkeyStub = stub(valkey, "get", (key: any) => {
|
|
if (String(key) === "admin-session") {
|
|
return Promise.resolve(
|
|
JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }),
|
|
);
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
const valkeyExpireStub = stub(valkey, "expire", () => Promise.resolve(1));
|
|
|
|
const originalSql = sqlWrapper.sql;
|
|
sqlWrapper.sql = ((_query: any, ..._args: any[]) => {
|
|
return Promise.resolve([{
|
|
id: "ay_sess_123",
|
|
expires_at: new Date().toISOString(),
|
|
}]);
|
|
}) as any;
|
|
|
|
try {
|
|
const res = await app.request("/api/sessions/ay_sess_123/extend", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: "Bearer admin-session",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
extendHours: 2,
|
|
}),
|
|
});
|
|
|
|
assertEquals(res.status, 200);
|
|
const json = await res.json();
|
|
assert(json.success === true);
|
|
assert(json.newExpiresAt);
|
|
assert(auditEvents.some((e) => e.action === "session_extended"));
|
|
} finally {
|
|
sqlWrapper.sql = originalSql;
|
|
valkeyStub.restore();
|
|
valkeyExpireStub.restore();
|
|
}
|
|
},
|
|
);
|
|
|
|
// Verify full audit coverage across delegation endpoints
|
|
assert(auditEvents.some((e) => e.action === "session_delegated"));
|
|
assert(auditEvents.some((e) => e.action === "session_scopes_updated"));
|
|
|
|
auditWrapper.auditLog = originalAudit;
|
|
});
|