mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
Preserve governed Board CLI authority and strict verification fixtures on the Muse stack.
Keep approval/cancel ownership, current membership/key authority and durable first-change audit atomic. Bound Retry-After scheduling within the original challenge; never automatically resubmit consent. Retain descriptor ownership until identity-based cleanup and close it on success and error. Keep strict Git-origin and synchronized provider-entry fixture assertions.
Related public work: LukeAbell's existing PR #13919 proposes the same production descriptor-lifetime fix. This independently measured local integration retains the original tests and adds real-descriptor coverage. Do not treat this local integration as ownership of that contribution.
Carry actual merged UI source 896f3c7f3b through a normal forward merge, preserving canonical workspace recovery, Muse authority and existing migration bytes. Current-head CI and substantive review remain merge gates. Complete qualification of the final immutable canonical source and actual provider acceptance remain separate.
Refs #16482, #13919.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
462 lines
33 KiB
TypeScript
462 lines
33 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { randomUUID } from "node:crypto";
|
|
import { spawn } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { createInviteRateLimiter } from "./invite-rate-limit.js";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { activityLog, authUsers, boardApiKeys, cliAuthChallenges, companies, companyMemberships, createDb, getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { boardAuthService } from "./board-auth.js";
|
|
import { subscribeCompanyLiveEvents } from "./live-events.js";
|
|
|
|
describe("boardAuthService touchBoardApiKey", () => {
|
|
it("retries the audit write after a transient failure", async () => {
|
|
const writes = [Promise.reject(new Error("transient")), Promise.resolve([])];
|
|
const update = vi.fn(() => ({
|
|
set: () => ({
|
|
where: () => writes.shift(),
|
|
}),
|
|
}));
|
|
const service = boardAuthService({ update } as unknown as Db);
|
|
|
|
await expect(service.touchBoardApiKey("key-1")).rejects.toThrow("transient");
|
|
await expect(service.touchBoardApiKey("key-1")).resolves.toBeUndefined();
|
|
|
|
expect(update).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("shares one in-flight audit write across concurrent touches", async () => {
|
|
let releaseWrite: (() => void) | undefined;
|
|
const write = new Promise<void>((resolve) => {
|
|
releaseWrite = resolve;
|
|
});
|
|
const update = vi.fn(() => ({
|
|
set: () => ({
|
|
where: () => write,
|
|
}),
|
|
}));
|
|
const service = boardAuthService({ update } as unknown as Db);
|
|
|
|
const first = service.touchBoardApiKey("key-1");
|
|
const second = service.touchBoardApiKey("key-1");
|
|
releaseWrite?.();
|
|
await Promise.all([first, second]);
|
|
|
|
expect(update).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
|
|
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
const databaseSuite = support.supported ? describe : describe.skip;
|
|
|
|
databaseSuite("CLI challenge authority and transitions (real PostgreSQL)", () => {
|
|
let db: ReturnType<typeof createDb>;
|
|
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
|
beforeAll(async () => {
|
|
if (external) db = createDb(external);
|
|
else {
|
|
temporary = await startEmbeddedPostgresTestDatabase("board-cli-challenge-");
|
|
db = createDb(temporary.connectionString);
|
|
}
|
|
}, 60_000);
|
|
afterAll(async () => {
|
|
await db?.$client.end();
|
|
await temporary?.cleanup();
|
|
});
|
|
|
|
async function fixture() {
|
|
const companyId = randomUUID(), userId = randomUUID(), otherUserId = randomUUID();
|
|
const now = new Date();
|
|
await db.insert(authUsers).values([userId, otherUserId].map(id => ({
|
|
id, name: "CLI approver", email: `${id}@example.invalid`, createdAt: now, updatedAt: now,
|
|
})));
|
|
await db.insert(companies).values({ id: companyId, name: "Private CLI organization", issuePrefix: `C${companyId.slice(0, 7)}` });
|
|
await db.insert(companyMemberships).values([userId, otherUserId].map(principalId => ({
|
|
companyId, principalType: "user", principalId, status: "active", membershipRole: "owner",
|
|
})));
|
|
const service = boardAuthService(db);
|
|
const created = await service.createCliAuthChallenge({ command: "paperclipai auth login", requestedAccess: "board", requestedCompanyId: companyId });
|
|
return { companyId, userId, otherUserId, service, ...created };
|
|
}
|
|
|
|
async function waitForChallengeLock() {
|
|
const deadline = Date.now() + 5_000;
|
|
while (Date.now() < deadline) {
|
|
const rows = await db.execute<{ waiting: boolean }>(sql`
|
|
select exists(select 1 from pg_stat_activity where datname = current_database()
|
|
and pid <> pg_backend_pid() and wait_event_type = 'Lock'
|
|
and query ilike '%cli_auth_challenges%') as waiting
|
|
`);
|
|
if (rows[0]?.waiting) return;
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
}
|
|
throw new Error("CLI challenge contender did not reach the held database lock");
|
|
}
|
|
|
|
it("keeps pending tokens unusable and stores only independent secret digests", async () => {
|
|
const f = await fixture();
|
|
expect(f.challenge.secretHash).not.toBe(f.challenge.pendingKeyHash);
|
|
expect(f.challenge.secretHash).not.toContain(f.challengeSecret);
|
|
expect(f.challenge.pendingKeyHash).not.toContain(f.pendingBoardToken);
|
|
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toBeNull();
|
|
expect(await f.service.getCliAuthChallengeBySecret(f.challenge.id, "wrong-secret")).toBeNull();
|
|
await db.update(cliAuthChallenges).set({ expiresAt: new Date(Date.now() - 1) }).where(eq(cliAuthChallenges.id, f.challenge.id));
|
|
expect((await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId)).status).toBe("expired");
|
|
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toBeNull();
|
|
});
|
|
|
|
it("retains the first key owner and approver on same-user and competing-user replay", async () => {
|
|
const f = await fixture();
|
|
const first = await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
|
|
await expect(f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.otherUserId)).rejects.toThrow("another user");
|
|
const repeat = await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
|
|
expect(repeat.challenge).toEqual(first.challenge);
|
|
const [stored] = await db.select().from(cliAuthChallenges).where(eq(cliAuthChallenges.id, f.challenge.id));
|
|
expect(stored).toEqual(first.challenge);
|
|
const key = await f.service.findBoardApiKeyByToken(f.pendingBoardToken);
|
|
expect(key?.userId).toBe(f.userId);
|
|
expect(stored.approvedByUserId).toBe(key?.userId);
|
|
expect(await db.select().from(boardApiKeys).where(eq(boardApiKeys.keyHash, f.challenge.pendingKeyHash))).toHaveLength(1);
|
|
});
|
|
|
|
it("denies an inaccessible requested company before key creation or audit fallback", async () => {
|
|
const f = await fixture();
|
|
await db.delete(companyMemberships).where(eq(companyMemberships.principalId, f.otherUserId));
|
|
await expect(f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.otherUserId)).rejects.toThrow("requested organization");
|
|
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toBeNull();
|
|
expect(await f.service.resolveBoardActivityCompanyIds({ userId: f.otherUserId, requestedCompanyId: f.companyId })).toEqual([]);
|
|
});
|
|
|
|
it("rechecks company membership after waiting for the approval lock", async () => {
|
|
const f = await fixture();
|
|
let release!: () => void, locked!: () => void;
|
|
const held = new Promise<void>(resolve => { locked = resolve; });
|
|
const unlock = new Promise<void>(resolve => { release = resolve; });
|
|
const holder = db.transaction(async tx => {
|
|
await tx.execute(sql`select id from cli_auth_challenges where id = ${f.challenge.id} for update`);
|
|
locked(); await unlock;
|
|
});
|
|
await held;
|
|
const approval = f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
|
|
const outcome = approval.then(value => ({ value }), error => ({ error }));
|
|
try {
|
|
await waitForChallengeLock();
|
|
await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.principalId, f.userId));
|
|
} finally { release(); await holder; }
|
|
const result = await outcome;
|
|
expect(result).toHaveProperty("error");
|
|
expect("error" in result ? String(result.error) : "").toContain("requested organization");
|
|
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toBeNull();
|
|
});
|
|
|
|
it("serializes cancellation behind an actual approval transaction without cancelling its live key", async () => {
|
|
const f = await fixture();
|
|
let release!: () => void, prepared!: () => void;
|
|
const ready = new Promise<void>(resolve => { prepared = resolve; });
|
|
const unlock = new Promise<void>(resolve => { release = resolve; });
|
|
const gated = Object.create(db) as Db;
|
|
gated.transaction = ((callback: Parameters<Db["transaction"]>[0]) => db.transaction(async tx => {
|
|
const result = await callback(tx);
|
|
prepared(); await unlock;
|
|
return result;
|
|
})) as Db["transaction"];
|
|
const approval = boardAuthService(gated).approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
|
|
await ready;
|
|
const cancellation = f.service.cancelCliAuthChallenge(f.challenge.id, f.challengeSecret);
|
|
const outcome = cancellation.then(value => ({ value }), error => ({ error }));
|
|
try { await waitForChallengeLock(); }
|
|
finally { release(); await approval; }
|
|
const result = await outcome;
|
|
expect(result).toHaveProperty("value.status", "approved");
|
|
const [stored] = await db.select().from(cliAuthChallenges).where(eq(cliAuthChallenges.id, f.challenge.id));
|
|
expect(stored.cancelledAt).toBeNull();
|
|
expect(stored.approvedByUserId).toBe(f.userId);
|
|
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toMatchObject({ userId: f.userId });
|
|
});
|
|
|
|
it("publishes approval activity only after key, challenge and audit rows commit", async () => {
|
|
const f = await fixture();
|
|
const events: string[] = [];
|
|
const unsubscribe = subscribeCompanyLiveEvents(f.companyId, event => {
|
|
if (event.payload.action === "board_api_key.created") events.push(event.payload.actorId as string);
|
|
});
|
|
let release!: () => void, prepared!: () => void;
|
|
const ready = new Promise<void>(resolve => { prepared = resolve; });
|
|
const unlock = new Promise<void>(resolve => { release = resolve; });
|
|
const gated = Object.create(db) as Db;
|
|
gated.transaction = ((callback: Parameters<Db["transaction"]>[0]) => db.transaction(async tx => {
|
|
const result = await callback(tx); prepared(); await unlock; return result;
|
|
})) as Db["transaction"];
|
|
const service = boardAuthService(gated);
|
|
const approval = service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
|
|
try {
|
|
await ready;
|
|
expect(events).toEqual([]);
|
|
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toBeNull();
|
|
expect(await db.select().from(activityLog).where(eq(activityLog.actorId, f.userId))).toHaveLength(0);
|
|
release(); await approval;
|
|
expect(events).toEqual([f.userId]);
|
|
expect(await db.select().from(activityLog).where(eq(activityLog.actorId, f.userId))).toHaveLength(1);
|
|
await service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
|
|
expect(events).toEqual([f.userId]);
|
|
} finally { release(); await approval; unsubscribe(); }
|
|
});
|
|
|
|
it("never grants a key after cancellation and keeps duplicate cancellation stable", async () => {
|
|
const f = await fixture();
|
|
const first = await f.service.cancelCliAuthChallenge(f.challenge.id, f.challengeSecret);
|
|
expect((await f.service.cancelCliAuthChallenge(f.challenge.id, f.challengeSecret)).challenge).toEqual(first.challenge);
|
|
expect((await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId)).status).toBe("cancelled");
|
|
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toBeNull();
|
|
});
|
|
|
|
it("shows the requested company name only to a currently authorized viewer", async () => {
|
|
const f = await fixture();
|
|
expect(await f.service.describeCliAuthChallenge(f.challenge.id, f.challengeSecret, { viewerUserId: f.userId }))
|
|
.toMatchObject({ requestedCompanyName: "Private CLI organization", approvedByUser: null });
|
|
await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.principalId, f.otherUserId));
|
|
expect(await f.service.describeCliAuthChallenge(f.challenge.id, f.challengeSecret, { viewerUserId: f.otherUserId }))
|
|
.toMatchObject({ requestedCompanyName: null, approvedByUser: null });
|
|
});
|
|
|
|
it("keeps instance-admin approval and current full-Board key authentication compatible", async () => {
|
|
const f = await fixture();
|
|
const { instanceUserRoles } = await import("@paperclipai/db");
|
|
await db.delete(companyMemberships).where(eq(companyMemberships.principalId, f.userId));
|
|
await db.insert(instanceUserRoles).values({ userId: f.userId, role: "instance_admin" });
|
|
const created = await f.service.createCliAuthChallenge({ command: "paperclipai auth login", requestedAccess: "instance_admin_required", requestedCompanyId: f.companyId });
|
|
await expect(f.service.approveCliAuthChallenge(created.challenge.id, created.challengeSecret, f.otherUserId)).rejects.toThrow("Instance admin required");
|
|
const approved = await f.service.approveCliAuthChallenge(created.challenge.id, created.challengeSecret, f.userId);
|
|
expect(approved.status).toBe("approved");
|
|
expect(await f.service.findBoardApiKeyByToken(created.pendingBoardToken)).toMatchObject({ userId: f.userId });
|
|
expect((await f.service.resolveBoardAccess(f.userId)).isInstanceAdmin).toBe(true);
|
|
});
|
|
|
|
it("authenticates the real pending, approved and revoked HTTP handshake without synthesizing an anonymous actor", async () => {
|
|
const f = await fixture();
|
|
const [{ default: express }, { default: request }, { actorMiddleware }, { accessRoutes }, { errorHandler }] = await Promise.all([
|
|
import("express"), import("supertest"), import("../middleware/auth.js"), import("../routes/access.js"), import("../middleware/error-handler.js"),
|
|
]);
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use(actorMiddleware(db, { deploymentMode: "authenticated", resolveSession: async req => (req.header("X-Test-Session") === f.userId || req.header("X-Test-Session") === f.otherUserId)
|
|
? { session: { id: "isolated-cli-session" }, user: { id: req.header("X-Test-Session"), name: "CLI approver", email: "synthetic-session@example.invalid" } } as any
|
|
: null }));
|
|
app.use("/api", accessRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "private", bindHost: "127.0.0.1", allowedHostnames: [] }));
|
|
app.use(errorHandler);
|
|
const created = await request(app).post("/api/cli-auth/challenges")
|
|
.send({ command: "paperclipai auth login", requestedCompanyId: f.companyId });
|
|
expect(created.status).toBe(201);
|
|
const { id, token, boardApiToken } = created.body;
|
|
const secondCompanyId = randomUUID();
|
|
await db.insert(companies).values({ id: secondCompanyId, name: "Second CLI organization", issuePrefix: `S${secondCompanyId.slice(0, 7)}` });
|
|
await db.insert(companyMemberships).values({ companyId: secondCompanyId, principalType: "user", principalId: f.userId, status: "active", membershipRole: "owner" });
|
|
expect((await request(app).get("/api/cli-auth/me").set("Authorization", `Bearer ${boardApiToken}`)).status).toBe(401);
|
|
const anonymous = await request(app).get(`/api/cli-auth/challenges/${id}?token=${token}`);
|
|
expect(anonymous.body).toMatchObject({ status: "pending", canApprove: false, requestedCompanyName: null, approvedByUser: null });
|
|
expect((await request(app).post(`/api/cli-auth/challenges/${id}/approve`).send({ token })).status).toBe(401);
|
|
await db.delete(companyMemberships).where(eq(companyMemberships.principalId, f.otherUserId));
|
|
expect((await request(app).post(`/api/cli-auth/challenges/${id}/approve`).set("X-Test-Session", f.otherUserId).send({ token })).status).toBe(403);
|
|
expect(await f.service.findBoardApiKeyByToken(boardApiToken)).toBeNull();
|
|
expect(await db.select().from(activityLog).where(eq(activityLog.actorId, f.otherUserId))).toHaveLength(0);
|
|
const browser = await request(app).get(`/api/cli-auth/challenges/${id}?token=${token}`).set("X-Test-Session", f.userId);
|
|
expect(browser.body).toMatchObject({ canApprove: true, requestedCompanyName: "Private CLI organization" });
|
|
await db.execute(sql.raw(`alter table activity_log add constraint board_cli_audit_failure check (action != 'board_api_key.created' or company_id != '${secondCompanyId}')`));
|
|
try {
|
|
const failed = await request(app).post(`/api/cli-auth/challenges/${id}/approve`).set("X-Test-Session", f.userId).send({ token });
|
|
expect(failed.status).toBe(500);
|
|
expect(await f.service.findBoardApiKeyByToken(boardApiToken)).toBeNull();
|
|
const [pending] = await db.select().from(cliAuthChallenges).where(eq(cliAuthChallenges.id, id));
|
|
expect(pending.approvedAt).toBeNull();
|
|
expect(pending.boardApiKeyId).toBeNull();
|
|
expect(await db.select().from(activityLog).where(eq(activityLog.actorId, f.userId))).toHaveLength(0);
|
|
} finally { await db.execute(sql`alter table activity_log drop constraint board_cli_audit_failure`); }
|
|
const unsubscribeFailure = subscribeCompanyLiveEvents(f.companyId, event => {
|
|
if (event.payload.action === "board_api_key.created") throw new Error("Synthetic publication failure");
|
|
});
|
|
const secondPublications: string[] = [];
|
|
const unsubscribeSecond = subscribeCompanyLiveEvents(secondCompanyId, event => {
|
|
if (event.payload.action === "board_api_key.created") secondPublications.push(event.payload.actorId as string);
|
|
});
|
|
try {
|
|
expect((await request(app).post(`/api/cli-auth/challenges/${id}/approve`).set("X-Test-Session", f.userId).send({ token })).status).toBe(200);
|
|
expect(secondPublications).toEqual([f.userId]);
|
|
} finally { unsubscribeFailure(); unsubscribeSecond(); }
|
|
|
|
expect((await request(app).post(`/api/cli-auth/challenges/${id}/approve`).set("X-Test-Session", f.userId).send({ token })).status).toBe(200);
|
|
const audit = await db.select().from(activityLog).where(eq(activityLog.actorId, f.userId));
|
|
expect(audit.filter(row => row.action === "board_api_key.created")).toHaveLength(2);
|
|
expect(new Set(audit.map(row => row.companyId))).toEqual(new Set([f.companyId, secondCompanyId]));
|
|
expect(JSON.stringify(audit)).not.toContain(boardApiToken);
|
|
expect(JSON.stringify(audit)).not.toContain(token);
|
|
const identity = await request(app).get("/api/cli-auth/me").set("Authorization", `Bearer ${boardApiToken}`);
|
|
expect(identity.body).toMatchObject({ userId: f.userId, source: "board_key" });
|
|
expect(new Set(identity.body.companyIds)).toEqual(new Set([f.companyId, secondCompanyId]));
|
|
expect((await request(app).post("/api/cli-auth/revoke-current").set("Authorization", `Bearer ${boardApiToken}`)).status).toBe(200);
|
|
expect((await request(app).get("/api/cli-auth/me").set("Authorization", `Bearer ${boardApiToken}`)).status).toBe(401);
|
|
});
|
|
|
|
it.each(["cli_auth_logout", "board_api_key_lifecycle"])("keeps owner-bound historical revocation audit after membership loss through %s", async revokedVia => {
|
|
const f = await fixture();
|
|
const approved = await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
|
|
const keyId = approved.challenge.boardApiKeyId!;
|
|
const foreign = await fixture();
|
|
await db.update(cliAuthChallenges).set({ boardApiKeyId: keyId, approvedByUserId: foreign.userId, approvedAt: new Date() })
|
|
.where(eq(cliAuthChallenges.id, foreign.challenge.id));
|
|
await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.principalId, f.userId));
|
|
expect(await f.service.resolveBoardActivityCompanyIds({ userId: f.userId, requestedCompanyId: foreign.companyId })).toEqual([]);
|
|
const [{ default: express }, { default: request }, { actorMiddleware }, { accessRoutes }, { errorHandler }, { activityRoutes }] = await Promise.all([
|
|
import("express"), import("supertest"), import("../middleware/auth.js"), import("../routes/access.js"), import("../middleware/error-handler.js"), import("../routes/activity.js"),
|
|
]);
|
|
const app = express(); app.use(express.json());
|
|
app.use(actorMiddleware(db, { deploymentMode: "authenticated", resolveSession: async req => (req.header("X-Test-Session") === f.userId || req.header("X-Test-Session") === foreign.userId)
|
|
? { session: { id: "synthetic-session" }, user: { id: req.header("X-Test-Session"), name: "CLI owner", email: "owner@example.invalid" } } as any : null }));
|
|
app.use("/api", accessRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "private", bindHost: "127.0.0.1", allowedHostnames: [] })); app.use("/api", activityRoutes(db)); app.use(errorHandler);
|
|
const foreignKey = await f.service.createNamedBoardApiKey({ userId: foreign.userId, name: "Synthetic foreign operator" });
|
|
const forged = await request(app).post(`/api/companies/${foreign.companyId}/activity`).set("Authorization", `Bearer ${foreignKey.token}`).send({
|
|
actorType: "user", actorId: f.userId, action: "board_api_key.created", entityType: "user", entityId: f.userId,
|
|
responsibleUserId: f.userId, responsible_user_id: f.userId, details: { boardApiKeyId: keyId, responsibleUserId: f.userId },
|
|
});
|
|
expect(forged.status).toBe(201);
|
|
expect(forged.body.responsibleUserId).toBeNull();
|
|
expect(await f.service.revokeBoardApiKey(keyId, { userId: foreign.userId, revokedVia: "board_api_key_lifecycle" })).toBeNull();
|
|
const denied = await request(app).delete(`/api/board-api-keys/${keyId}`).set("X-Test-Session", foreign.userId);
|
|
expect(denied.status).toBe(404);
|
|
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).not.toBeNull();
|
|
const response = revokedVia === "cli_auth_logout"
|
|
? await request(app).post("/api/cli-auth/revoke-current").set("Authorization", `Bearer ${f.pendingBoardToken}`).send({ requestedCompanyId: foreign.companyId })
|
|
: await request(app).delete(`/api/board-api-keys/${keyId}`).set("X-Test-Session", f.userId);
|
|
expect(response.status).toBe(200);
|
|
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toBeNull();
|
|
const rows = await db.select().from(activityLog).where(eq(activityLog.action, "board_api_key.revoked"));
|
|
const matching = rows.filter(row => row.details?.boardApiKeyId === keyId);
|
|
expect(matching).toHaveLength(1);
|
|
expect(matching[0]).toMatchObject({ companyId: f.companyId, actorId: f.userId, details: { boardApiKeyId: keyId, revokedVia } });
|
|
});
|
|
|
|
it("retains server-derived named-key creation context while rejecting public same-owner audit forgeries", async () => {
|
|
const f = await fixture(), foreign = await fixture();
|
|
await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
|
|
const [{ default: express }, { default: request }, { actorMiddleware }, { accessRoutes }, { activityRoutes }, { errorHandler }] = await Promise.all([
|
|
import("express"), import("supertest"), import("../middleware/auth.js"), import("../routes/access.js"), import("../routes/activity.js"), import("../middleware/error-handler.js"),
|
|
]);
|
|
const app = express(); app.use(express.json()); app.use(actorMiddleware(db, { deploymentMode: "authenticated", resolveSession: async () => null }));
|
|
app.use("/api", accessRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "private", bindHost: "127.0.0.1", allowedHostnames: [] }));
|
|
app.use("/api", activityRoutes(db)); app.use(errorHandler);
|
|
const created = await request(app).post("/api/board-api-keys").set("Authorization", `Bearer ${f.pendingBoardToken}`)
|
|
.send({ name: "Synthetic named key", requestedCompanyId: f.companyId });
|
|
expect(created.status).toBe(201);
|
|
expect(await db.select().from(cliAuthChallenges).where(eq(cliAuthChallenges.boardApiKeyId, created.body.id))).toHaveLength(0);
|
|
const foreignKey = await f.service.createNamedBoardApiKey({ userId: foreign.userId, name: "Synthetic foreign operator" });
|
|
const forged = await request(app).post(`/api/companies/${foreign.companyId}/activity`).set("Authorization", `Bearer ${foreignKey.token}`).send({
|
|
actorType: "user", actorId: f.userId, action: "board_api_key.created", entityType: "user", entityId: f.userId,
|
|
responsibleUserId: f.userId, details: { boardApiKeyId: created.body.id, responsibleUserId: f.userId },
|
|
});
|
|
expect(forged.status).toBe(201); expect(forged.body.responsibleUserId).toBeNull();
|
|
await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.principalId, f.userId));
|
|
expect((await request(app).post("/api/cli-auth/revoke-current").set("Authorization", `Bearer ${created.body.token}`)).status).toBe(200);
|
|
const rows = (await db.select().from(activityLog).where(eq(activityLog.action, "board_api_key.revoked")))
|
|
.filter(row => row.details?.boardApiKeyId === created.body.id);
|
|
expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ companyId: f.companyId, actorId: f.userId });
|
|
});
|
|
|
|
it("rolls back revocation when a second company audit fails and retries first-only", async () => {
|
|
const f = await fixture();
|
|
const secondCompanyId = randomUUID();
|
|
await db.insert(companies).values({ id: secondCompanyId, name: "Second revocation context", issuePrefix: `R${secondCompanyId.slice(0, 7)}` });
|
|
await db.insert(companyMemberships).values({ companyId: secondCompanyId, principalType: "user", principalId: f.userId, status: "active", membershipRole: "owner" });
|
|
const approved = await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
|
|
const keyId = approved.challenge.boardApiKeyId!;
|
|
const [{ default: express }, { default: request }, { actorMiddleware }, { accessRoutes }, { errorHandler }] = await Promise.all([
|
|
import("express"), import("supertest"), import("../middleware/auth.js"), import("../routes/access.js"), import("../middleware/error-handler.js"),
|
|
]);
|
|
const app = express(); app.use(express.json()); app.use(actorMiddleware(db, { deploymentMode: "authenticated", resolveSession: async () => null }));
|
|
app.use("/api", accessRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "private", bindHost: "127.0.0.1", allowedHostnames: [] })); app.use(errorHandler);
|
|
await db.execute(sql.raw(`alter table activity_log add constraint board_cli_revoke_failure check (action != 'board_api_key.revoked' or company_id != '${secondCompanyId}')`));
|
|
try {
|
|
expect((await request(app).post("/api/cli-auth/revoke-current").set("Authorization", `Bearer ${f.pendingBoardToken}`)).status).toBe(500);
|
|
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).not.toBeNull();
|
|
expect((await db.select().from(activityLog).where(eq(activityLog.actorId, f.userId))).filter(row => row.action === "board_api_key.revoked")).toHaveLength(0);
|
|
} finally { await db.execute(sql`alter table activity_log drop constraint board_cli_revoke_failure`); }
|
|
expect((await request(app).post("/api/cli-auth/revoke-current").set("Authorization", `Bearer ${f.pendingBoardToken}`)).status).toBe(200);
|
|
const revoked = (await db.select().from(activityLog).where(eq(activityLog.actorId, f.userId))).filter(row => row.action === "board_api_key.revoked");
|
|
expect(new Set(revoked.map(row => row.companyId))).toEqual(new Set([f.companyId, secondCompanyId]));
|
|
expect(revoked).toHaveLength(2);
|
|
expect((await request(app).post("/api/cli-auth/revoke-current").set("Authorization", `Bearer ${f.pendingBoardToken}`)).status).toBe(401);
|
|
expect((await db.select().from(activityLog).where(eq(activityLog.action, "board_api_key.revoked"))).filter(row => row.details?.boardApiKeyId === keyId)).toHaveLength(2);
|
|
});
|
|
|
|
it("finishes three real CLI logins sharing one limited IP without changing their challenges", async () => {
|
|
const f = await fixture();
|
|
const [{ default: express }, { default: request }, { actorMiddleware }, { accessRoutes }, { errorHandler }] = await Promise.all([
|
|
import("express"), import("supertest"), import("../middleware/auth.js"), import("../routes/access.js"), import("../middleware/error-handler.js"),
|
|
]);
|
|
const clockStart = Date.now(); let clock = clockStart;
|
|
const limiter = createInviteRateLimiter({ maxRequests: 120, now: () => clock });
|
|
const created: Array<{ id: string; token: string; boardApiToken: string; expiresAt: string }> = [];
|
|
let throttled = 0, releaseThrottles!: () => void;
|
|
const allThrottled = new Promise<void>(resolve => { releaseThrottles = resolve; });
|
|
const app = express(); app.use(express.json());
|
|
app.use(actorMiddleware(db, { deploymentMode: "authenticated", resolveSession: async req => req.header("X-Test-Session") === f.userId
|
|
? { session: { id: "synthetic-shared-session" }, user: { id: f.userId, name: "CLI owner", email: "owner@example.invalid" } } as any : null }));
|
|
app.use((req, res, next) => {
|
|
const original = res.json.bind(res);
|
|
res.json = body => { if (req.method === "POST" && req.originalUrl.split("?", 1)[0] === "/api/cli-auth/challenges" && res.statusCode === 201) created.push(body); return original(body); };
|
|
res.on("finish", () => { if (req.method === "GET" && res.statusCode === 429) { throttled++; if (throttled === 3) releaseThrottles(); } }); next();
|
|
});
|
|
app.use("/api", accessRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "private", bindHost: "127.0.0.1", allowedHostnames: [], cliAuthPollRateLimiter: limiter })); app.use(errorHandler);
|
|
const server = app.listen(0, "127.0.0.1"); await new Promise<void>(resolve => server.once("listening", resolve));
|
|
const address = server.address(); if (!address || typeof address === "string") throw new Error("Synthetic CLI server has no port");
|
|
const apiBase = `http://127.0.0.1:${address.port}`;
|
|
const folder = fs.mkdtempSync(path.join(os.tmpdir(), "board-cli-shared-ip-"));
|
|
const children: Array<ReturnType<typeof spawn>> = [];
|
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
for (let i = 0; i < 120; i++) expect((await fetch(`${apiBase}/api/cli-auth/challenges/${f.challenge.id}?token=${f.challengeSecret}`)).status).toBe(200);
|
|
clock = clockStart + 59_900;
|
|
const module = new URL("../../../cli/src/client/board-auth.ts", import.meta.url).href;
|
|
const loader = new URL("../../node_modules/tsx/dist/loader.mjs", import.meta.url).href;
|
|
const outcomes = [0, 1, 2].map(i => {
|
|
const worker = path.join(folder, `client-${i}.mjs`);
|
|
fs.writeFileSync(worker, `import { loginBoardCli } from ${JSON.stringify(module)}; await loginBoardCli(${JSON.stringify({ apiBase, requestedAccess: "board", requestedCompanyId: f.companyId, storePath: path.join(folder, `auth-${i}.json`), openBrowser: false, print: false })});`, { mode: 0o600 });
|
|
const child = spawn(process.execPath, ["--import", loader, worker], { cwd: folder, stdio: ["ignore", "ignore", "pipe"] }); children.push(child);
|
|
let errors = ""; child.stderr?.on("data", chunk => { errors += chunk.toString(); });
|
|
return new Promise<{ code: number | null; errors: string }>(resolve => child.once("exit", code => resolve({ code, errors })));
|
|
});
|
|
await Promise.race([allThrottled, new Promise<never>((_, reject) => { timeout = setTimeout(() => reject(new Error("Three CLI workers did not reach the real shared-IP limit")), 5000); })]);
|
|
clearTimeout(timeout); expect(created).toHaveLength(3);
|
|
for (const challenge of created) expect((await request(app).post(`/api/cli-auth/challenges/${challenge.id}/approve`).set("X-Test-Session", f.userId).send({ token: challenge.token })).status).toBe(200);
|
|
clock = clockStart + 60_001;
|
|
expect(await Promise.all(outcomes)).toEqual([0, 1, 2].map(() => ({ code: 0, errors: "" })));
|
|
expect(created).toHaveLength(3); expect(throttled).toBe(3);
|
|
for (let i = 0; i < 3; i++) {
|
|
const store = JSON.parse(fs.readFileSync(path.join(folder, `auth-${i}.json`), "utf8"));
|
|
const saved = store.credentials[apiBase];
|
|
expect(created.some(challenge => challenge.boardApiToken === saved.token)).toBe(true);
|
|
expect(saved.userId).toBe(f.userId);
|
|
}
|
|
for (const challenge of created) {
|
|
const [stored] = await db.select().from(cliAuthChallenges).where(eq(cliAuthChallenges.id, challenge.id));
|
|
expect(stored.expiresAt.toISOString()).toBe(challenge.expiresAt); expect(stored.approvedByUserId).toBe(f.userId);
|
|
}
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
for (const child of children) if (child.exitCode === null) { child.kill("SIGTERM"); await new Promise(resolve => child.once("exit", resolve)); }
|
|
server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); fs.rmSync(folder, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("omits private organization and approver identity from anonymous capability polling", async () => {
|
|
const f = await fixture();
|
|
const pending = await f.service.describeCliAuthChallenge(f.challenge.id, f.challengeSecret);
|
|
expect(pending?.requestedCompanyName).toBeNull();
|
|
await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
|
|
const approved = await f.service.describeCliAuthChallenge(f.challenge.id, f.challengeSecret);
|
|
expect(approved).toMatchObject({ status: "approved", requestedCompanyName: null, approvedByUser: null });
|
|
expect(JSON.stringify(approved)).not.toContain(`${f.userId}@example.invalid`);
|
|
});
|
|
});
|