forked from EduCraft/curriculum-project-hub
feat: add deployable alpha silo
This commit is contained in:
@@ -9,12 +9,14 @@ import {
|
||||
sessionCookieHeader,
|
||||
} from "../../src/admin/routes/authRoutes.js";
|
||||
import { OAUTH_STATE_COOKIE_NAME, signOAuthState } from "../../src/admin/auth/session.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization, testSecretEnvelope } from "./helpers.js";
|
||||
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
|
||||
import { upsertScopedFeishuIdentity } from "../../src/feishu/identityNamespace.js";
|
||||
|
||||
const SESSION_SECRET = "integration-test-session-secret";
|
||||
const PUBLIC_BASE = "http://127.0.0.1:8788";
|
||||
|
||||
async function buildApp(fetchImpl?: typeof fetch) {
|
||||
async function buildApp(fetchImpl?: typeof fetch, allowLegacyFeishuOAuth = true) {
|
||||
const app = Fastify({ logger: false });
|
||||
await registerAdminPlugin(app, {
|
||||
prisma,
|
||||
@@ -24,6 +26,7 @@ async function buildApp(fetchImpl?: typeof fetch) {
|
||||
feishuAppSecret: "secret_test",
|
||||
projectWorkspaceRoot: "/tmp/cph-test-workspaces",
|
||||
secretEnvelope: testSecretEnvelope,
|
||||
allowLegacyFeishuOAuth,
|
||||
cookieSecure: false,
|
||||
...(fetchImpl !== undefined ? { fetchImpl } : {}),
|
||||
});
|
||||
@@ -186,6 +189,25 @@ describe("admin auth + org API guards", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not expose unscoped OAuth when the explicit compatibility switch is off", async () => {
|
||||
const app = await buildApp(undefined, false);
|
||||
try {
|
||||
const start = await app.inject({ method: "GET", url: "/auth/feishu" });
|
||||
expect(start.statusCode).toBe(404);
|
||||
const nonce = "legacy-disabled";
|
||||
const state = signOAuthState({ nonce, returnTo: "/admin" }, SESSION_SECRET);
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state)}`,
|
||||
headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` },
|
||||
});
|
||||
expect(callback.statusCode).toBe(302);
|
||||
expect(callback.headers.location).toContain("oauth_failed");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("OAuth callback upserts user and sets session cookie", async () => {
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
@@ -230,6 +252,94 @@ describe("admin auth + org API guards", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("binds Organization OAuth state, identity, and session to the intended connection", async () => {
|
||||
await seedUser("scoped-owner", "legacy_scoped_owner", "OWNER");
|
||||
const connections = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
|
||||
const connection = await connections.rotateCustomerApplication({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "scoped-owner",
|
||||
appId: "cli_customer_oauth",
|
||||
appSecret: "customer-oauth-secret",
|
||||
botOpenId: "ou_customer_bot",
|
||||
});
|
||||
const identity = await upsertScopedFeishuIdentity(prisma, {
|
||||
connectionId: connection.id,
|
||||
openId: "ou_scoped_user",
|
||||
displayName: "Invited User",
|
||||
});
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, userId: identity.userId, role: "ADMIN" },
|
||||
});
|
||||
await seedTestOrganization("org_scoped_other", "scoped-other");
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId: "org_scoped_other", userId: identity.userId, role: "ADMIN" },
|
||||
});
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/oauth/token")) {
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({ client_id: "cli_customer_oauth" });
|
||||
return Response.json({ code: 0, access_token: "scoped-user-token" });
|
||||
}
|
||||
if (url.includes("/user_info")) {
|
||||
return Response.json({
|
||||
code: 0,
|
||||
data: {
|
||||
open_id: "ou_scoped_user",
|
||||
union_id: "on_scoped_union",
|
||||
name: "Scoped User",
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected ${url}`);
|
||||
});
|
||||
const app = await buildApp(fetchImpl as unknown as typeof fetch);
|
||||
try {
|
||||
const start = await app.inject({
|
||||
method: "GET",
|
||||
url: "/auth/feishu/test-default?returnTo=/admin/org/test-default/settings",
|
||||
});
|
||||
expect(start.statusCode).toBe(302);
|
||||
const authorize = new URL(String(start.headers.location));
|
||||
expect(authorize.searchParams.get("client_id")).toBe("cli_customer_oauth");
|
||||
const state = authorize.searchParams.get("state");
|
||||
expect(state).not.toBeNull();
|
||||
const nonceCookie = cookiePair(start.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME);
|
||||
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state!)}`,
|
||||
headers: { cookie: nonceCookie },
|
||||
});
|
||||
expect(callback.statusCode).toBe(302);
|
||||
expect(callback.headers.location).toBe("/admin/org/test-default/settings");
|
||||
const sessionCookie = cookiePair(callback.headers["set-cookie"], "cph_session");
|
||||
const me = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
|
||||
expect(me.statusCode).toBe(200);
|
||||
expect(me.json()).toMatchObject({
|
||||
user: { id: identity.userId, displayName: "Scoped User" },
|
||||
organizations: [expect.objectContaining({ slug: "test-default", role: "ADMIN" })],
|
||||
});
|
||||
expect((me.json() as { organizations: unknown[] }).organizations).toHaveLength(1);
|
||||
const crossOrganization = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/org/scoped-other",
|
||||
headers: { cookie: sessionCookie },
|
||||
});
|
||||
expect(crossOrganization.statusCode).toBe(403);
|
||||
expect(crossOrganization.json()).toMatchObject({ error: { code: "forbidden" } });
|
||||
await expect(prisma.feishuUserIdentity.findUniqueOrThrow({
|
||||
where: { id: identity.identityId },
|
||||
select: { unionId: true },
|
||||
})).resolves.toEqual({ unionId: "on_scoped_union" });
|
||||
|
||||
await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" });
|
||||
const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
|
||||
expect(revoked.statusCode).toBe(401);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("unknown org slug returns 404 for admin", async () => {
|
||||
await seedUser("u-admin", "ou_admin", "ADMIN");
|
||||
const app = await buildApp();
|
||||
@@ -249,3 +359,10 @@ describe("admin auth + org API guards", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function cookiePair(header: string | string[] | undefined, name: string): string {
|
||||
const values = Array.isArray(header) ? header : [header ?? ""];
|
||||
const cookie = values.find((value) => value.startsWith(`${name}=`));
|
||||
if (cookie === undefined) throw new Error(`missing cookie: ${name}`);
|
||||
return cookie.split(";", 1)[0]!;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js";
|
||||
import { addOrgMember } from "../../src/org/members.js";
|
||||
import { addTeamMember, archiveTeam, createTeam, updateTeam } from "../../src/org/teams.js";
|
||||
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
|
||||
|
||||
const SESSION_SECRET = "integration-test-session-secret";
|
||||
|
||||
@@ -51,6 +52,14 @@ describe("admin members + teams API", () => {
|
||||
|
||||
it("adds members and enforces last-OWNER protection", async () => {
|
||||
const token = await seedOwner();
|
||||
const connection = await new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {})
|
||||
.rotateCustomerApplication({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "u-owner",
|
||||
appId: "cli_members",
|
||||
appSecret: "members-secret",
|
||||
botOpenId: "ou_members_bot",
|
||||
});
|
||||
const app = await buildApp();
|
||||
try {
|
||||
const cookie = sessionCookieHeader(token);
|
||||
@@ -64,6 +73,14 @@ describe("admin members + teams API", () => {
|
||||
expect(add.statusCode).toBe(201);
|
||||
const teacher = add.json() as { userId: string; role: string };
|
||||
expect(teacher.role).toBe("ADMIN");
|
||||
await expect(prisma.feishuUserIdentity.findUnique({
|
||||
where: {
|
||||
connectionId_openId: {
|
||||
connectionId: connection.id,
|
||||
openId: "ou_teacher",
|
||||
},
|
||||
},
|
||||
})).resolves.not.toBeNull();
|
||||
|
||||
const list = await app.inject({
|
||||
method: "GET",
|
||||
@@ -71,7 +88,18 @@ describe("admin members + teams API", () => {
|
||||
headers: { cookie },
|
||||
});
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect((list.json() as { members: unknown[] }).members).toHaveLength(2);
|
||||
const members = (list.json() as {
|
||||
members: Array<{ userId: string; feishuOpenId: string | null; identityStatus: string }>;
|
||||
}).members;
|
||||
expect(members).toHaveLength(2);
|
||||
expect(members.find((member) => member.userId === "u-owner")).toMatchObject({
|
||||
feishuOpenId: null,
|
||||
identityStatus: "UNLINKED",
|
||||
});
|
||||
expect(members.find((member) => member.userId === teacher.userId)).toMatchObject({
|
||||
feishuOpenId: "ou_teacher",
|
||||
identityStatus: "SCOPED",
|
||||
});
|
||||
|
||||
const refuse = await app.inject({
|
||||
method: "POST",
|
||||
|
||||
@@ -18,7 +18,7 @@ import { FeishuApplicationConnectionService } from "../../src/connections/feishu
|
||||
const execFileAsync = promisify(execFile);
|
||||
const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
||||
|
||||
describe("deployment preflight CLI", () => {
|
||||
describe("deployment preflight CLI", { timeout: 20_000 }, () => {
|
||||
let root: string;
|
||||
let baseDir: string;
|
||||
let hubDir: string;
|
||||
@@ -224,15 +224,21 @@ describe("deployment preflight CLI", () => {
|
||||
[
|
||||
"NODE_ENV=production",
|
||||
`DATABASE_URL=${options.databaseUrl ?? TEST_DATABASE_URL}`,
|
||||
"FEISHU_APP_ID=cli_app_id",
|
||||
"FEISHU_APP_SECRET=feishu-app-secret",
|
||||
"FEISHU_BOT_OPEN_ID=ou_bot",
|
||||
"HUB_SILO_ORGANIZATION_ID=org_test",
|
||||
"HUB_SYSTEMD_UNIT=cph-hub-test.service",
|
||||
`CPH_BIN=${cphBin}`,
|
||||
"HOST=127.0.0.1",
|
||||
"PORT=8788",
|
||||
`HUB_PROJECT_WORKSPACE_ROOT=${options.workspaceRoot}`,
|
||||
"HUB_PUBLIC_BASE_URL=https://hub.example.com",
|
||||
"HUB_SESSION_SECRET=a-production-session-secret-with-32-bytes",
|
||||
"HUB_AGENT_MAX_CONCURRENT_RUNS=1",
|
||||
"HUB_AGENT_MAX_RUN_SECONDS=900",
|
||||
"HUB_HTTP_BODY_LIMIT_BYTES=1048576",
|
||||
"HUB_MAX_FILES_PER_MESSAGE=8",
|
||||
"HUB_MAX_FILE_BYTES=26214400",
|
||||
"HUB_HTTP_REQUESTS_PER_MINUTE=120",
|
||||
"HUB_FEISHU_EVENTS_PER_MINUTE=120",
|
||||
"",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
@@ -264,6 +270,8 @@ describe("deployment preflight CLI", () => {
|
||||
join(persistentDir, "cache"),
|
||||
"--workspace-root",
|
||||
options.workspaceRoot,
|
||||
"--service-unit",
|
||||
"cph-hub-test.service",
|
||||
"--bwrap-bin",
|
||||
bwrapBin,
|
||||
"--socat-bin",
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
|
||||
import {
|
||||
scopedFeishuPrincipalId,
|
||||
upsertScopedFeishuIdentity,
|
||||
resolveScopedFeishuIdentity,
|
||||
} from "../../src/feishu/identityNamespace.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization, testSecretEnvelope } from "./helpers.js";
|
||||
|
||||
describe("Feishu connection identity namespace", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("keeps colliding provider-local user and chat ids isolated by connection", async () => {
|
||||
const first = await seedConnection(DEFAULT_ORG_ID, "identity-admin-a", "cli_identity_a");
|
||||
await seedTestOrganization("org_identity_other", "identity-other");
|
||||
const second = await seedConnection("org_identity_other", "identity-admin-b", "cli_identity_b");
|
||||
|
||||
const [identityA, identityB] = await Promise.all([
|
||||
upsertScopedFeishuIdentity(prisma, {
|
||||
connectionId: first.id,
|
||||
openId: "ou_collision",
|
||||
displayName: "Teacher A",
|
||||
}),
|
||||
upsertScopedFeishuIdentity(prisma, {
|
||||
connectionId: second.id,
|
||||
openId: "ou_collision",
|
||||
displayName: "Teacher B",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(identityA.userId).not.toBe(identityB.userId);
|
||||
expect(identityA.principalId).not.toBe(identityB.principalId);
|
||||
expect(scopedFeishuPrincipalId("CHAT", first.id, "oc_collision"))
|
||||
.not.toBe(scopedFeishuPrincipalId("CHAT", second.id, "oc_collision"));
|
||||
await expect(resolveScopedFeishuIdentity(prisma, {
|
||||
connectionId: first.id,
|
||||
openId: "ou_collision",
|
||||
})).resolves.toMatchObject({ organizationId: DEFAULT_ORG_ID, userId: identityA.userId });
|
||||
await expect(resolveScopedFeishuIdentity(prisma, {
|
||||
connectionId: second.id,
|
||||
openId: "ou_collision",
|
||||
})).resolves.toMatchObject({ organizationId: "org_identity_other", userId: identityB.userId });
|
||||
});
|
||||
|
||||
it("fails closed for an Organization mismatch or disabled connection", async () => {
|
||||
const connection = await seedConnection(DEFAULT_ORG_ID, "identity-owner", "cli_identity");
|
||||
await upsertScopedFeishuIdentity(prisma, {
|
||||
connectionId: connection.id,
|
||||
openId: "ou_teacher",
|
||||
displayName: "Teacher",
|
||||
});
|
||||
|
||||
await expect(resolveScopedFeishuIdentity(prisma, {
|
||||
connectionId: connection.id,
|
||||
expectedOrganizationId: "org_wrong",
|
||||
openId: "ou_teacher",
|
||||
})).rejects.toThrow("scope mismatch");
|
||||
await new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {}).disable({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "identity-owner",
|
||||
});
|
||||
await expect(resolveScopedFeishuIdentity(prisma, {
|
||||
connectionId: connection.id,
|
||||
openId: "ou_teacher",
|
||||
})).rejects.toThrow("active Feishu Application Connection not found");
|
||||
});
|
||||
|
||||
it("serializes identity creation behind a concurrent connection disable", async () => {
|
||||
const connection = await seedConnection(DEFAULT_ORG_ID, "identity-owner", "cli_race");
|
||||
let release!: () => void;
|
||||
const releasePromise = new Promise<void>((resolve) => { release = resolve; });
|
||||
let locked!: () => void;
|
||||
const lockedPromise = new Promise<void>((resolve) => { locked = resolve; });
|
||||
const disabler = prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`
|
||||
SELECT "id" FROM "OrganizationFeishuApplicationConnection"
|
||||
WHERE "id" = ${connection.id} FOR UPDATE
|
||||
`;
|
||||
locked();
|
||||
await releasePromise;
|
||||
await tx.organizationFeishuApplicationConnection.update({
|
||||
where: { id: connection.id },
|
||||
data: { status: "DISABLED", disabledAt: new Date() },
|
||||
});
|
||||
}, { timeout: 10_000 });
|
||||
await lockedPromise;
|
||||
|
||||
const identityAttempt = upsertScopedFeishuIdentity(prisma, {
|
||||
connectionId: connection.id,
|
||||
openId: "ou_racing_user",
|
||||
displayName: "Racing User",
|
||||
});
|
||||
const stateBeforeRelease = await Promise.race([
|
||||
identityAttempt.then(() => "settled", () => "settled"),
|
||||
new Promise<"blocked">((resolve) => setTimeout(() => resolve("blocked"), 50)),
|
||||
]);
|
||||
expect(stateBeforeRelease).toBe("blocked");
|
||||
release();
|
||||
await disabler;
|
||||
await expect(identityAttempt).rejects.toThrow("active Feishu Application Connection not found");
|
||||
await expect(prisma.feishuUserIdentity.count({
|
||||
where: { connectionId: connection.id, openId: "ou_racing_user" },
|
||||
})).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
async function seedConnection(
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
appId: string,
|
||||
): Promise<{ readonly id: string }> {
|
||||
await prisma.user.create({
|
||||
data: { id: actorUserId, feishuOpenId: `legacy_${actorUserId}`, displayName: actorUserId },
|
||||
});
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId, userId: actorUserId, role: "OWNER" },
|
||||
});
|
||||
return new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {})
|
||||
.rotateCustomerApplication({
|
||||
organizationId,
|
||||
actorUserId,
|
||||
appId,
|
||||
appSecret: `secret_${appId}`,
|
||||
botOpenId: `ou_bot_${appId}`,
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export const prisma = new PrismaClient({
|
||||
export async function resetDb(): Promise<void> {
|
||||
const tables = [
|
||||
"FeishuEventReceipt",
|
||||
"FeishuUserIdentity",
|
||||
"FeishuApplicationCredentialVersion",
|
||||
"OrganizationFeishuApplicationConnection",
|
||||
"ProviderCredentialVersion",
|
||||
|
||||
@@ -4,15 +4,15 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resetDb, TEST_DATABASE_URL, TEST_SECRET_KEY, TEST_SECRET_KEY_ID } from "./helpers.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, TEST_DATABASE_URL, TEST_SECRET_KEY, TEST_SECRET_KEY_ID, testSecretEnvelope } from "./helpers.js";
|
||||
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
|
||||
import { ProviderConnectionService } from "../../src/connections/providerConnections.js";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"DATABASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"FEISHU_APP_ID",
|
||||
"FEISHU_APP_SECRET",
|
||||
"FEISHU_BOT_OPEN_ID",
|
||||
"HUB_SILO_ORGANIZATION_ID",
|
||||
"HUB_PROJECT_WORKSPACE_ROOT",
|
||||
"HUB_SESSION_SECRET",
|
||||
"HUB_PUBLIC_BASE_URL",
|
||||
@@ -20,6 +20,13 @@ const ENV_KEYS = [
|
||||
"HUB_SECRET_KEYRING_FILE",
|
||||
"HOST",
|
||||
"PORT",
|
||||
"HUB_HTTP_BODY_LIMIT_BYTES",
|
||||
"HUB_MAX_FILES_PER_MESSAGE",
|
||||
"HUB_MAX_FILE_BYTES",
|
||||
"HUB_AGENT_MAX_CONCURRENT_RUNS",
|
||||
"HUB_AGENT_MAX_RUN_SECONDS",
|
||||
"HUB_HTTP_REQUESTS_PER_MINUTE",
|
||||
"HUB_FEISHU_EVENTS_PER_MINUTE",
|
||||
] as const;
|
||||
|
||||
describe("Hub startup", () => {
|
||||
@@ -34,6 +41,27 @@ describe("Hub startup", () => {
|
||||
|
||||
it("rejects startup before the Feishu listener when the HTTP bind fails", async () => {
|
||||
await resetDb();
|
||||
const owner = await prisma.user.create({
|
||||
data: {
|
||||
feishuOpenId: "legacy-server-start-owner",
|
||||
displayName: "Server Start Owner",
|
||||
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "OWNER" } },
|
||||
},
|
||||
});
|
||||
await new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {}).rotateCustomerApplication({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: owner.id,
|
||||
appId: "test-app",
|
||||
appSecret: "test-secret",
|
||||
botOpenId: "ou_test_bot",
|
||||
});
|
||||
await new ProviderConnectionService(prisma, testSecretEnvelope, async () => {}).rotateByok({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: owner.id,
|
||||
providerId: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api",
|
||||
authToken: "test-provider-token",
|
||||
});
|
||||
const blocker = createServer();
|
||||
blocker.listen(0, "127.0.0.1");
|
||||
await once(blocker, "listening");
|
||||
@@ -50,9 +78,7 @@ describe("Hub startup", () => {
|
||||
|
||||
Object.assign(process.env, {
|
||||
DATABASE_URL: TEST_DATABASE_URL,
|
||||
FEISHU_APP_ID: "test-app",
|
||||
FEISHU_APP_SECRET: "test-secret",
|
||||
FEISHU_BOT_OPEN_ID: "ou_test_bot",
|
||||
HUB_SILO_ORGANIZATION_ID: DEFAULT_ORG_ID,
|
||||
HUB_PROJECT_WORKSPACE_ROOT: "/tmp/cph-hub-server-start-test",
|
||||
HUB_SESSION_SECRET: "integration-session-secret-with-32-bytes",
|
||||
HUB_PUBLIC_BASE_URL: "https://hub.example.test",
|
||||
@@ -60,6 +86,13 @@ describe("Hub startup", () => {
|
||||
HUB_SECRET_KEYRING_FILE: keyringFile,
|
||||
HOST: "127.0.0.1",
|
||||
PORT: String(address.port),
|
||||
HUB_HTTP_BODY_LIMIT_BYTES: "1048576",
|
||||
HUB_MAX_FILES_PER_MESSAGE: "8",
|
||||
HUB_MAX_FILE_BYTES: "26214400",
|
||||
HUB_AGENT_MAX_CONCURRENT_RUNS: "1",
|
||||
HUB_AGENT_MAX_RUN_SECONDS: "900",
|
||||
HUB_HTTP_REQUESTS_PER_MINUTE: "120",
|
||||
HUB_FEISHU_EVENTS_PER_MINUTE: "120",
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { bootstrapAlphaSilo } from "../../src/deployment/bootstrap-silo.js";
|
||||
import { requireSiloOrganization } from "../../src/deployment/silo.js";
|
||||
import { prisma, testSecretEnvelope } from "./helpers.js";
|
||||
|
||||
const input = {
|
||||
organization: { id: "org_alpha", slug: "alpha", name: "Alpha School" },
|
||||
owner: { openId: "ou_owner", displayName: "Owner" },
|
||||
feishu: { appId: "cli_alpha", appSecret: "feishu-secret", botOpenId: "ou_bot" },
|
||||
provider: {
|
||||
providerId: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api",
|
||||
authToken: "provider-secret",
|
||||
},
|
||||
teams: [{ slug: "teachers", name: "Teachers" }],
|
||||
} as const;
|
||||
|
||||
describe("Alpha Silo bootstrap", () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "Organization" RESTART IDENTITY CASCADE`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("creates one scoped Organization and is idempotent", async () => {
|
||||
const probes = { feishu: async () => {}, provider: async () => {} };
|
||||
const first = await bootstrapAlphaSilo(prisma, testSecretEnvelope, input, probes);
|
||||
const second = await bootstrapAlphaSilo(prisma, testSecretEnvelope, input, probes);
|
||||
|
||||
expect(first).toMatchObject({ organizationId: "org_alpha", initialized: true, providerConfigured: true });
|
||||
expect(second).toMatchObject({ organizationId: "org_alpha", initialized: false, providerConfigured: true });
|
||||
await expect(requireSiloOrganization(prisma, "org_alpha")).resolves.toMatchObject({ slug: "alpha" });
|
||||
expect(await prisma.organization.count()).toBe(1);
|
||||
expect(await prisma.organizationMembership.count({ where: { role: "OWNER", revokedAt: null } })).toBe(1);
|
||||
expect(await prisma.feishuUserIdentity.count()).toBe(1);
|
||||
expect(await prisma.team.count({ where: { slug: "teachers", archivedAt: null } })).toBe(1);
|
||||
expect(await prisma.teamMembership.count({ where: { revokedAt: null } })).toBe(1);
|
||||
expect(await prisma.organizationProviderConnection.count({ where: { status: "ACTIVE" } })).toBe(1);
|
||||
|
||||
const persisted = JSON.stringify({
|
||||
feishu: await prisma.feishuApplicationCredentialVersion.findMany(),
|
||||
provider: await prisma.providerCredentialVersion.findMany(),
|
||||
});
|
||||
expect(persisted).not.toContain("feishu-secret");
|
||||
expect(persisted).not.toContain("provider-secret");
|
||||
});
|
||||
|
||||
it("rejects a configured Organization that does not match the database", async () => {
|
||||
await bootstrapAlphaSilo(prisma, testSecretEnvelope, input, {
|
||||
feishu: async () => {},
|
||||
provider: async () => {},
|
||||
});
|
||||
await expect(requireSiloOrganization(prisma, "org_other")).rejects.toThrow("Silo Organization mismatch");
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,11 @@ type TestTriggerDeps = Omit<Parameters<typeof makeProductionTriggerHandler>[0],
|
||||
};
|
||||
|
||||
function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProductionTriggerHandler> {
|
||||
return makeProductionTriggerHandler({ projectWorkspaceRoot: "/tmp", ...deps });
|
||||
return makeProductionTriggerHandler({
|
||||
projectWorkspaceRoot: "/tmp",
|
||||
allowLegacyFeishuIdentity: true,
|
||||
...deps,
|
||||
});
|
||||
}
|
||||
|
||||
function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
|
||||
@@ -47,7 +51,7 @@ function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
|
||||
return models;
|
||||
},
|
||||
async runPolicy() {
|
||||
return { maxTurns: 7 };
|
||||
return { maxTurns: 7, maxConcurrentRuns: 4, maxRunSeconds: 300 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,15 +8,21 @@ import {
|
||||
const VALID_ENV = {
|
||||
NODE_ENV: "production",
|
||||
DATABASE_URL: "postgresql://hub:secret@127.0.0.1:5432/hub",
|
||||
FEISHU_APP_ID: "cli_app_id",
|
||||
FEISHU_APP_SECRET: "feishu-app-secret",
|
||||
FEISHU_BOT_OPEN_ID: "ou_bot",
|
||||
HUB_SILO_ORGANIZATION_ID: "org_test",
|
||||
HUB_SYSTEMD_UNIT: "cph-hub-test.service",
|
||||
CPH_BIN: "/srv/curriculum-project-hub/current/bin/cph",
|
||||
HOST: "127.0.0.1",
|
||||
PORT: "8788",
|
||||
HUB_PROJECT_WORKSPACE_ROOT: "/var/lib/cph-hub/workspaces",
|
||||
HUB_PUBLIC_BASE_URL: "https://hub.example.com",
|
||||
HUB_SESSION_SECRET: "a-production-session-secret-with-32-bytes",
|
||||
HUB_AGENT_MAX_CONCURRENT_RUNS: "1",
|
||||
HUB_AGENT_MAX_RUN_SECONDS: "900",
|
||||
HUB_HTTP_BODY_LIMIT_BYTES: "1048576",
|
||||
HUB_MAX_FILES_PER_MESSAGE: "8",
|
||||
HUB_MAX_FILE_BYTES: "26214400",
|
||||
HUB_HTTP_REQUESTS_PER_MINUTE: "120",
|
||||
HUB_FEISHU_EVENTS_PER_MINUTE: "120",
|
||||
} as const;
|
||||
|
||||
function input(overrides: Partial<DeploymentPreflightInput> = {}): DeploymentPreflightInput {
|
||||
@@ -27,6 +33,7 @@ function input(overrides: Partial<DeploymentPreflightInput> = {}): DeploymentPre
|
||||
stateDir: "/var/lib/cph-hub/state",
|
||||
cacheDir: "/var/cache/cph-hub",
|
||||
workspaceRoot: "/var/lib/cph-hub/workspaces",
|
||||
expectedServiceUnit: "cph-hub-test.service",
|
||||
env: VALID_ENV,
|
||||
...overrides,
|
||||
};
|
||||
@@ -79,7 +86,7 @@ describe("validateDeploymentPreflight", () => {
|
||||
input({
|
||||
env: {
|
||||
...VALID_ENV,
|
||||
FEISHU_APP_SECRET: "",
|
||||
HUB_SILO_ORGANIZATION_ID: "",
|
||||
HUB_PUBLIC_BASE_URL: "http://hub.example.com",
|
||||
HUB_SESSION_SECRET: "short",
|
||||
},
|
||||
@@ -89,7 +96,7 @@ describe("validateDeploymentPreflight", () => {
|
||||
expect(error).toBeInstanceOf(DeploymentPreflightError);
|
||||
expect((error as Error).message).toMatchInlineSnapshot(`
|
||||
"deployment preflight failed:
|
||||
- FEISHU_APP_SECRET is required
|
||||
- HUB_SILO_ORGANIZATION_ID is required
|
||||
- HUB_PUBLIC_BASE_URL must use https
|
||||
- HUB_SESSION_SECRET must contain at least 32 characters"
|
||||
`);
|
||||
|
||||
@@ -14,8 +14,10 @@ const larkMock = vi.hoisted(() => {
|
||||
}
|
||||
}
|
||||
class MockWSClient {
|
||||
constructor(private readonly params: { readonly onReady?: () => void }) {}
|
||||
start(params: { readonly eventDispatcher?: unknown }): void {
|
||||
starts.push(params);
|
||||
this.params.onReady?.();
|
||||
}
|
||||
}
|
||||
return { starts, MockEventDispatcher, MockWSClient };
|
||||
@@ -40,7 +42,7 @@ describe("Feishu card action callbacks", () => {
|
||||
const onCardAction = vi.fn(async () => action.promise);
|
||||
const logger = silentLogger();
|
||||
|
||||
startFeishuListenerWithClient(
|
||||
await startFeishuListenerWithClient(
|
||||
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
|
||||
fakeClient(),
|
||||
logger,
|
||||
@@ -63,7 +65,7 @@ describe("Feishu card action callbacks", () => {
|
||||
const logger = silentLogger();
|
||||
const err = new Error("handler failed");
|
||||
|
||||
startFeishuListenerWithClient(
|
||||
await startFeishuListenerWithClient(
|
||||
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
|
||||
fakeClient(),
|
||||
logger,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
exchangeCodeForUser,
|
||||
FeishuOAuthError,
|
||||
type FeishuOAuthConfig,
|
||||
} from "../../src/admin/auth/feishuOAuth.js";
|
||||
|
||||
@@ -75,13 +76,18 @@ describe("exchangeCodeForUser", () => {
|
||||
|
||||
it("throws on token exchange failure", async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ code: 20003, error: "invalid_grant", error_description: "bad code" }), {
|
||||
new Response(JSON.stringify({
|
||||
code: 20003,
|
||||
error: "invalid_grant",
|
||||
error_description: "echoed app secret s and authorization code bad",
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
exchangeCodeForUser(
|
||||
let failure: unknown;
|
||||
try {
|
||||
await exchangeCodeForUser(
|
||||
{
|
||||
appId: "cli",
|
||||
appSecret: "s",
|
||||
@@ -90,7 +96,17 @@ describe("exchangeCodeForUser", () => {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
},
|
||||
"bad",
|
||||
),
|
||||
).rejects.toThrow(/token exchange failed/);
|
||||
);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(FeishuOAuthError);
|
||||
expect(failure).toMatchObject({
|
||||
code: "feishu_oauth_rejected",
|
||||
category: "provider_rejection",
|
||||
upstreamStatus: 200,
|
||||
});
|
||||
expect(String(failure)).not.toContain("secret s");
|
||||
expect(String(failure)).not.toContain("authorization code bad");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,7 +225,7 @@ function mockSettings(): RuntimeSettings {
|
||||
return registry;
|
||||
},
|
||||
async runPolicy() {
|
||||
return { maxTurns: 1 };
|
||||
return { maxTurns: 1, maxConcurrentRuns: 1, maxRunSeconds: 300 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -270,6 +270,7 @@ function mockPrisma(): PrismaClient {
|
||||
update: vi.fn(async () => session),
|
||||
},
|
||||
agentRun: {
|
||||
count: vi.fn(async () => 0),
|
||||
create: vi.fn(async () => ({ id: "run-1" })),
|
||||
findUnique: vi.fn(async () => null),
|
||||
update: vi.fn(async () => ({ id: "run-1" })),
|
||||
@@ -292,6 +293,7 @@ function mockPrisma(): PrismaClient {
|
||||
Object.assign(client, {
|
||||
$transaction: vi.fn(async (callback: (tx: PrismaClient) => Promise<unknown>) => callback(client)),
|
||||
$queryRaw: vi.fn(async () => [{ id: "org_test_default", status: "ACTIVE" }]),
|
||||
$executeRaw: vi.fn(async () => 1),
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from "node:fs/promise
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { removeAbandonedMessageResourceStages } from "../../src/feishu/resourceStaging.js";
|
||||
import { removeAbandonedMessageResourceStages, stageMessageResources } from "../../src/feishu/resourceStaging.js";
|
||||
import type { FeishuRuntime } from "../../src/feishu/client.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
@@ -30,6 +31,20 @@ describe("Feishu resource staging recovery", () => {
|
||||
/staging base is not a real directory/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects too many resources before contacting Feishu", async () => {
|
||||
const root = await tempRoot();
|
||||
await expect(stageMessageResources(
|
||||
{} as FeishuRuntime,
|
||||
"message-1",
|
||||
[
|
||||
{ fileKey: "a", resourceType: "file", workspaceRelativePath: "a" },
|
||||
{ fileKey: "b", resourceType: "file", workspaceRelativePath: "b" },
|
||||
],
|
||||
root,
|
||||
{ maxFiles: 1, maxBytesPerFile: 10 },
|
||||
)).rejects.toThrow("2 resources; limit is 1");
|
||||
});
|
||||
});
|
||||
|
||||
async function tempRoot(): Promise<string> {
|
||||
|
||||
@@ -30,8 +30,14 @@ describe("runtime settings", () => {
|
||||
it("honors HUB_AGENT_MAX_TURNS as a runtime run policy", async () => {
|
||||
const settings = createEnvRuntimeSettings({
|
||||
HUB_AGENT_MAX_TURNS: "9",
|
||||
HUB_AGENT_MAX_CONCURRENT_RUNS: "2",
|
||||
HUB_AGENT_MAX_RUN_SECONDS: "600",
|
||||
});
|
||||
|
||||
await expect(settings.runPolicy({ projectId: "p", roleId: "draft" })).resolves.toEqual({ maxTurns: 9 });
|
||||
await expect(settings.runPolicy({ projectId: "p", roleId: "draft" })).resolves.toEqual({
|
||||
maxTurns: 9,
|
||||
maxConcurrentRuns: 2,
|
||||
maxRunSeconds: 600,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,23 @@ describe("session cookie signing", () => {
|
||||
const token = signSession({ userId: "u1", feishuOpenId: "ou_1" }, SECRET);
|
||||
expect(verifySession(token, "other-secret")).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips a connection-scoped identity", () => {
|
||||
const token = signSession({
|
||||
userId: "u-scoped",
|
||||
feishuIdentityId: "identity-1",
|
||||
feishuConnectionId: "connection-1",
|
||||
feishuOrganizationId: "organization-1",
|
||||
}, SECRET, 3600, 1_700_000_000);
|
||||
expect(verifySession(token, SECRET, 1_700_000_100)).toEqual({
|
||||
userId: "u-scoped",
|
||||
feishuIdentityId: "identity-1",
|
||||
feishuConnectionId: "connection-1",
|
||||
feishuOrganizationId: "organization-1",
|
||||
iat: 1_700_000_000,
|
||||
exp: 1_700_003_600,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauth state signing", () => {
|
||||
@@ -62,6 +79,22 @@ describe("oauth state signing", () => {
|
||||
exp: 1_700_000_600,
|
||||
});
|
||||
});
|
||||
|
||||
it("binds state to an Organization and connection as one inseparable scope", () => {
|
||||
const token = signOAuthState({
|
||||
nonce: "scoped",
|
||||
returnTo: "/admin/org/acme",
|
||||
organizationId: "org-acme",
|
||||
connectionId: "connection-acme",
|
||||
}, SECRET, 600, 1_700_000_000);
|
||||
expect(verifyOAuthState(token, SECRET, 1_700_000_100)).toEqual({
|
||||
nonce: "scoped",
|
||||
returnTo: "/admin/org/acme",
|
||||
organizationId: "org-acme",
|
||||
connectionId: "connection-acme",
|
||||
exp: 1_700_000_600,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeReturnTo", () => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SiloFixedWindowRateLimiter } from "../../src/deployment/siloRateLimit.js";
|
||||
|
||||
describe("Silo fixed-window rate limiter", () => {
|
||||
it("returns an explicit retry interval and resets at the next window", () => {
|
||||
const limiter = new SiloFixedWindowRateLimiter(2, 60_000, 1_000);
|
||||
expect(limiter.consume(1_000)).toEqual({ allowed: true });
|
||||
expect(limiter.consume(2_000)).toEqual({ allowed: true });
|
||||
expect(limiter.consume(3_000)).toEqual({ allowed: false, retryAfterSeconds: 58 });
|
||||
expect(limiter.consume(61_000)).toEqual({ allowed: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { access, mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { writeNewWorkspaceFileNoFollow } from "../../src/security/workspaceFiles.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
describe("inbound workspace file limits", () => {
|
||||
const itOnLinux = process.platform === "linux" ? it : it.skip;
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
itOnLinux("fails and removes the partial file when the byte limit is exceeded", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-workspace-limit-"));
|
||||
roots.push(root);
|
||||
const workspace = join(root, "project");
|
||||
await mkdir(workspace);
|
||||
|
||||
await expect(writeNewWorkspaceFileNoFollow(
|
||||
root,
|
||||
workspace,
|
||||
"too-large.bin",
|
||||
Readable.from([Buffer.from("1234")]),
|
||||
3,
|
||||
)).rejects.toMatchObject({ reason: "limit" });
|
||||
await expect(access(join(workspace, "too-large.bin"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user