forked from bai/curriculum-project-hub
feat: provision non-root Hub service
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DeploymentPreflightError,
|
||||
validateDeploymentPreflight,
|
||||
type DeploymentPreflightInput,
|
||||
} from "../../src/deployment/preflight.js";
|
||||
|
||||
const VALID_ENV = {
|
||||
NODE_ENV: "production",
|
||||
DATABASE_URL: "postgresql://hub:secret@127.0.0.1:5432/hub",
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "provider-token",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
FEISHU_APP_ID: "cli_app_id",
|
||||
FEISHU_APP_SECRET: "feishu-app-secret",
|
||||
FEISHU_BOT_OPEN_ID: "ou_bot",
|
||||
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",
|
||||
} as const;
|
||||
|
||||
function input(overrides: Partial<DeploymentPreflightInput> = {}): DeploymentPreflightInput {
|
||||
return {
|
||||
baseDir: "/srv/curriculum-project-hub",
|
||||
hubDir: "/srv/curriculum-project-hub/current/hub",
|
||||
serviceHome: "/var/lib/cph-hub/home",
|
||||
stateDir: "/var/lib/cph-hub/state",
|
||||
cacheDir: "/var/cache/cph-hub",
|
||||
workspaceRoot: "/var/lib/cph-hub/workspaces",
|
||||
env: VALID_ENV,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("validateDeploymentPreflight", () => {
|
||||
it("accepts a complete production configuration with persistent storage outside the deployment tree", () => {
|
||||
expect(validateDeploymentPreflight(input())).toEqual({
|
||||
bind: { host: "127.0.0.1", port: 8788 },
|
||||
databaseUrl: "postgresql://hub:secret@127.0.0.1:5432/hub",
|
||||
cphBin: "/srv/curriculum-project-hub/current/bin/cph",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["inside the live Hub", "/srv/curriculum-project-hub/current/hub/data/workspaces"],
|
||||
["inside future releases", "/srv/curriculum-project-hub/releases/workspaces"],
|
||||
["an ancestor of the deployment root", "/srv"],
|
||||
])("rejects a workspace %s", (_description, workspaceRoot) => {
|
||||
expect(() =>
|
||||
validateDeploymentPreflight(
|
||||
input({
|
||||
workspaceRoot,
|
||||
env: { ...VALID_ENV, HUB_PROJECT_WORKSPACE_ROOT: workspaceRoot },
|
||||
}),
|
||||
),
|
||||
).toThrow("must not overlap deployment root");
|
||||
});
|
||||
|
||||
it("rejects a runtime workspace that differs from the provisioned path", () => {
|
||||
expect(() =>
|
||||
validateDeploymentPreflight(
|
||||
input({ env: { ...VALID_ENV, HUB_PROJECT_WORKSPACE_ROOT: "/var/lib/cph-hub/other" } }),
|
||||
),
|
||||
).toThrow("HUB_PROJECT_WORKSPACE_ROOT must equal provisioned workspace root");
|
||||
});
|
||||
|
||||
it("rejects a relative runtime workspace even if it resolves to the provisioned path", () => {
|
||||
expect(() =>
|
||||
validateDeploymentPreflight(
|
||||
input({ env: { ...VALID_ENV, HUB_PROJECT_WORKSPACE_ROOT: "./workspaces" } }),
|
||||
),
|
||||
).toThrow("HUB_PROJECT_WORKSPACE_ROOT must be an absolute normalized path");
|
||||
});
|
||||
|
||||
it("reports all invalid required production values in one failed preflight", () => {
|
||||
expect.assertions(2);
|
||||
try {
|
||||
validateDeploymentPreflight(
|
||||
input({
|
||||
env: {
|
||||
...VALID_ENV,
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
FEISHU_APP_SECRET: "",
|
||||
HUB_PUBLIC_BASE_URL: "http://hub.example.com",
|
||||
HUB_SESSION_SECRET: "short",
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(DeploymentPreflightError);
|
||||
expect((error as Error).message).toMatchInlineSnapshot(`
|
||||
"deployment preflight failed:
|
||||
- ANTHROPIC_AUTH_TOKEN is required
|
||||
- FEISHU_APP_SECRET is required
|
||||
- HUB_PUBLIC_BASE_URL must use https
|
||||
- HUB_SESSION_SECRET must contain at least 32 characters"
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects relative operational paths", () => {
|
||||
expect(() => validateDeploymentPreflight(input({ cacheDir: "./cache" }))).toThrow(
|
||||
"cache directory must be an absolute normalized path",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-empty ANTHROPIC_API_KEY that would conflict with auth-token mode", () => {
|
||||
expect(() =>
|
||||
validateDeploymentPreflight(
|
||||
input({ env: { ...VALID_ENV, ANTHROPIC_API_KEY: "conflicting-api-key" } }),
|
||||
),
|
||||
).toThrow("ANTHROPIC_API_KEY must be present and empty");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readServerBinding } from "../../src/settings/server.js";
|
||||
|
||||
describe("readServerBinding", () => {
|
||||
it("honors the configured bind host and port", () => {
|
||||
expect(readServerBinding({ HOST: "127.0.0.2", PORT: "9100" })).toEqual({
|
||||
host: "127.0.0.2",
|
||||
port: 9100,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["0", "65536", "1.5", "not-a-port"])('rejects invalid PORT="%s"', (port) => {
|
||||
expect(() => readServerBinding({ HOST: "127.0.0.1", PORT: port })).toThrow(
|
||||
"PORT must be an integer between 1 and 65535",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a bind URL in place of a host", () => {
|
||||
expect(() => readServerBinding({ HOST: "https://127.0.0.1", PORT: "8788" })).toThrow(
|
||||
"HOST must be a hostname or IP address",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user