feat(backend): enforce user-owned sessions
This commit is contained in:
@@ -28,6 +28,91 @@ function deferred<T = void>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const aliceHeaders = {
|
||||
"x-thoth-principal-issuer": "portal",
|
||||
"x-thoth-principal-subject": "alice",
|
||||
"x-thoth-principal-display-name": "Alice",
|
||||
"x-thoth-is-admin": "0",
|
||||
};
|
||||
|
||||
test("upstream requests without a principal fail before a Pi runtime can be created", async () => {
|
||||
let created = false;
|
||||
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
||||
mgr: { createFor: () => { created = true; throw new Error("must not spawn"); } } as any,
|
||||
thtRunner: {} as any,
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(created).toBe(false);
|
||||
});
|
||||
|
||||
test("session routes conceal foreign or missing sessions and deny SSE before it subscribes", async () => {
|
||||
let subscribed = false;
|
||||
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
||||
thtRunner: {
|
||||
withPrincipal: () => ({ sessionShow: async () => null }),
|
||||
} as any,
|
||||
hub: { subscribe: () => { subscribed = true; return () => {}; } } as any,
|
||||
});
|
||||
|
||||
const document = await app.inject({ method: "GET", url: "/sessions/foreign/documents", headers: aliceHeaders });
|
||||
const events = await app.inject({ method: "GET", url: "/sessions/foreign/events", headers: aliceHeaders });
|
||||
|
||||
expect(document.statusCode).toBe(404);
|
||||
expect(events.statusCode).toBe(404);
|
||||
expect(subscribed).toBe(false);
|
||||
});
|
||||
|
||||
test("session listing permits all scope only to admins", async () => {
|
||||
const seen: boolean[] = [];
|
||||
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
||||
thtRunner: {
|
||||
withPrincipal: (principal: any) => ({
|
||||
sessionList: async () => { seen.push(principal.isAdmin); return [{ id: "s1" }]; },
|
||||
}),
|
||||
} as any,
|
||||
});
|
||||
const regularAll = await app.inject({ method: "GET", url: "/sessions?scope=all", headers: aliceHeaders });
|
||||
const mine = await app.inject({ method: "GET", url: "/sessions?scope=mine", headers: aliceHeaders });
|
||||
const adminAll = await app.inject({
|
||||
method: "GET", url: "/sessions?scope=all",
|
||||
headers: { ...aliceHeaders, "x-thoth-is-admin": "1" },
|
||||
});
|
||||
|
||||
expect(regularAll.statusCode).toBe(403);
|
||||
expect(mine.statusCode).toBe(200);
|
||||
expect(adminAll.statusCode).toBe(200);
|
||||
expect(seen).toEqual([false, true]);
|
||||
});
|
||||
|
||||
test("new sessions are created through the authenticated principal, not a client owner field", async () => {
|
||||
let principal: any;
|
||||
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
||||
thtRunner: {
|
||||
withPrincipal: (p: any) => {
|
||||
principal = p;
|
||||
return { sessionNew: async () => ({ id: "owned" }), searchPack: async () => {} };
|
||||
},
|
||||
} as any,
|
||||
readiness: { ensure: async () => ({ ok: true }) } as any,
|
||||
mgr: {
|
||||
createFor: () => ({ bridge: { onClientEvent: () => {} } }),
|
||||
configure: async () => {}, start: () => {}, get: () => undefined,
|
||||
} as any,
|
||||
getSettings: () => ({ workspace: "w" }) as any,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST", url: "/sessions", headers: aliceHeaders,
|
||||
payload: { question: "q", owner: "mallory" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(principal).toMatchObject({ issuer: "portal", subject: "alice" });
|
||||
});
|
||||
|
||||
test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+avvia", async () => {
|
||||
const modelKey = path.join(os.tmpdir(), `thoth-model-key-${process.pid}`);
|
||||
writeFileSync(modelKey, "test-model-key", { mode: 0o600 });
|
||||
@@ -455,7 +540,9 @@ test("concurrent cold Resume requests serialize and create one runtime", async (
|
||||
expect(firstResponse.json()).toEqual({ id: "s1", alreadyActive: false });
|
||||
expect(secondResponse.json()).toEqual({ id: "s1", alreadyActive: true });
|
||||
expect({ showCalls, readinessCalls, reopenCalls, createCalls, clearCalls }).toEqual({
|
||||
showCalls: 1,
|
||||
// Each caller is authorized against repository ownership, including the request which
|
||||
// finds the runtime already active after waiting on the lifecycle lock.
|
||||
showCalls: 2,
|
||||
readinessCalls: 1,
|
||||
reopenCalls: 1,
|
||||
createCalls: 1,
|
||||
@@ -942,7 +1029,7 @@ test("Delete followed by Resume cannot resurrect the deleted session", async ()
|
||||
await deleteResponse;
|
||||
const resumed = await resumeResponse;
|
||||
|
||||
expect(resumed.statusCode).toBe(500);
|
||||
expect(resumed.statusCode).toBe(503);
|
||||
expect(current).toBeUndefined();
|
||||
expect(createCalls).toBe(0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user