forked from EduCraft/curriculum-project-hub
feat: add deployable alpha silo
This commit is contained in:
@@ -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