mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
## Thinking Path > - Paperclip is the open source control plane for AI-agent companies. > - Operators can hide settings that their users must not change. > - The server shares the effective restrictions with the UI and settings API. > - An explicit list must change whenever a new experimental flag is added. > - This pull request adds a wildcard with named exceptions to the existing setting. > - Core applies the policy to its current catalog, so new flags stay hidden automatically. ## Linked Issues or Issue Description Related: #11823 introduced settings visibility. #13907 added workspace isolation visibility. I searched related PRs and issues and found no duplicate wildcard implementation. **What existing behavior does this improve?** Operator control of experimental setting visibility through `PAPERCLIP_HIDDEN_SETTINGS`. **Subsystem affected** Shared settings policy and its existing server health and mutation consumers. **Current behavior** Operators must name every hidden experimental toggle. A new Core flag can become visible until the operator updates that list. **Proposed behavior** `instance.experimental.*` hides current and future experimental toggles. Entries such as `!instance.experimental.enableEnvironments` leave named controls available. Explicit hidden keys and the hidden parent page take precedence over exceptions. **Reason and benefit** Operators can maintain a short list of allowed controls instead of a second copy of Core's full feature catalog. **Breaking changes** Existing explicit lists and unset configuration keep their behavior. The new syntax is opt-in. Older images ignore it, so operators must retain explicit restrictions until those images are upgraded. Visibility does not change feature values. ## What Changed - Expand the wildcard into concrete catalog keys in the shared parser. - Limit exceptions to known experimental controls and preserve explicit restrictions in either input order. - Test a synthetic future catalog addition, duplicate and invalid entries, API rejection, same-value echoes, and the effective health payload. - Document the syntax and the transition for deployments with mixed image versions. ## Verification - Targeted parser, future-catalog, health, and settings-route tests pass: 95 tests across four files. - `pnpm -r typecheck` passes, including Rust checks, with the installed Cargo directory on PATH. - `pnpm build` passes. - All current-head CI gates pass, including the full test shards, Rust, build, browser E2E, and canary dry run: https://github.com/paperclipai/paperclip/actions/runs/36083292578. One unchanged runtime-exposure cold-start test passed on its first retry. - The full local `pnpm test:run` did not pass on macOS/Node 25: the first server group reported 13,356 passed, 18 failed, and 99 skipped, with six failed files (including two failed suite setups). Failures were in unchanged runtime/company skill cache, chat/email connector fixtures, embedded-Postgres setup, and workspace cleanup tests. A standalone filesystem probe reproduced the read-only-directory rename permission failure. Missing connector fixture paths, database startup failures, and two integration assertions also occurred; the remaining local groups were not reached after this group failed. The corresponding CI lanes all pass. These local failures are not claimed as fixed by this PR. - Browser suites were not run because this changes the shared policy, not UI rendering or browser workflows. Health payload and route tests cover the shared UI/API contract. ## Risks A malformed exception remains hidden and is reported as unknown. Exceptions cannot override an explicit hidden toggle or parent page. Older images ignore wildcard syntax; keep their explicit list during a mixed-version rollout. No schema or feature-value changes are included. ## Model Used OpenAI GPT-6 through Codex, with reasoning, tool use, and code execution. The exact runtime variant and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
587 lines
19 KiB
TypeScript
587 lines
19 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { healthRoutes } from "../routes/health.js";
|
|
import * as devServerStatus from "../dev-server-status.js";
|
|
import { serverVersion } from "../version.js";
|
|
|
|
const mockReadPersistedDevServerStatus = vi.hoisted(() => vi.fn());
|
|
const testServerInfo = {
|
|
processStartedAt: "2026-06-26T00:00:00.000Z",
|
|
git: {
|
|
available: true,
|
|
fullSha: "0123456789abcdef0123456789abcdef01234567",
|
|
shortSha: "0123456",
|
|
branchName: "master",
|
|
subject: "Add server info debug view",
|
|
committedAt: "2026-06-25T23:00:00.000Z",
|
|
localChanges: {
|
|
available: true,
|
|
hasLocalChanges: false,
|
|
stagedFileCount: 0,
|
|
unstagedFileCount: 0,
|
|
untrackedFileCount: 0,
|
|
},
|
|
},
|
|
} as const;
|
|
|
|
function createHealthyDb(): Db {
|
|
return {
|
|
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
|
} as unknown as Db;
|
|
}
|
|
|
|
vi.mock("../dev-server-status.js", () => ({
|
|
readPersistedDevServerStatus: mockReadPersistedDevServerStatus,
|
|
toDevServerHealthStatus: vi.fn(),
|
|
}));
|
|
|
|
function createApp(
|
|
db?: Db,
|
|
serverInfo = testServerInfo,
|
|
databaseBackupHealth?: Parameters<typeof healthRoutes>[1]["databaseBackupHealth"],
|
|
runtimeEnv?: Parameters<typeof healthRoutes>[1]["runtimeEnv"],
|
|
) {
|
|
const app = express();
|
|
app.use(
|
|
"/health",
|
|
healthRoutes(db, {
|
|
deploymentMode: "local_trusted",
|
|
deploymentExposure: "private",
|
|
authReady: true,
|
|
companyDeletionEnabled: true,
|
|
serverInfo,
|
|
databaseBackupHealth,
|
|
runtimeEnv,
|
|
}),
|
|
);
|
|
return app;
|
|
}
|
|
|
|
describe("GET /health", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockReadPersistedDevServerStatus.mockReturnValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
it("returns 200 with status ok", async () => {
|
|
const app = createApp();
|
|
const res = await request(app).get("/health");
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ status: "ok", version: serverVersion, serverVersion: serverVersion, commit: testServerInfo.git.fullSha, serverInfo: testServerInfo });
|
|
}, 15_000);
|
|
|
|
it("keeps the self-hosted health response byte-identical and omits cloud", async () => {
|
|
const app = createApp(undefined, testServerInfo, undefined, {});
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
const baseline = {
|
|
status: "ok",
|
|
version: serverVersion,
|
|
serverVersion,
|
|
commit: testServerInfo.git.fullSha,
|
|
serverInfo: testServerInfo,
|
|
};
|
|
expect(res.text).toBe(JSON.stringify(baseline));
|
|
expect(Object.prototype.hasOwnProperty.call(res.body, "cloud")).toBe(false);
|
|
});
|
|
|
|
it("exposes public stack metadata on cloud-simulated health", async () => {
|
|
const app = createApp(undefined, testServerInfo, undefined, {
|
|
PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "tenant-token",
|
|
PAPERCLIP_CLOUD_STACK_ID: "stack-1",
|
|
PAPERCLIP_STACK_SLUG: "acme",
|
|
PAPERCLIP_CLOUD_ACCOUNT_GROUP_ID: "account-group-1",
|
|
PAPERCLIP_PRIMARY_HOST: "acme.paperclip.app",
|
|
PAPERCLIP_CLOUD_API_ORIGIN: "https://app.paperclip.app",
|
|
});
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.cloud).toEqual({
|
|
managed: true,
|
|
managedBy: "paperclip-cloud",
|
|
stackSlug: "acme",
|
|
cloudBaseUrl: "https://app.paperclip.app",
|
|
});
|
|
});
|
|
|
|
it("lists operator-hidden settings and drops unknown keys", async () => {
|
|
const app = createApp(undefined, testServerInfo, undefined, {
|
|
PAPERCLIP_HIDDEN_SETTINGS: "instance.plugins,instance.adapters,instance.bogus",
|
|
});
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.hiddenSettings).toEqual(["instance.plugins", "instance.adapters"]);
|
|
});
|
|
|
|
it("omits hiddenSettings entirely when nothing is hidden", async () => {
|
|
const app = createApp(undefined, testServerInfo, undefined, {});
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(Object.prototype.hasOwnProperty.call(res.body, "hiddenSettings")).toBe(false);
|
|
});
|
|
|
|
it("publishes concrete wildcard restrictions to the UI and refreshes changed exceptions", async () => {
|
|
const env = { PAPERCLIP_HIDDEN_SETTINGS: "instance.plugins,instance.experimental.*,!instance.experimental.enableEnvironments" };
|
|
const app = createApp(undefined, testServerInfo, undefined, env);
|
|
const first = await request(app).get("/health");
|
|
expect(first.status).toBe(200);
|
|
expect(first.body.hiddenSettings).toContain("instance.plugins");
|
|
expect(first.body.hiddenSettings).toContain("instance.experimental.enableMemoryConnectors");
|
|
expect(first.body.hiddenSettings).not.toContain("instance.experimental.enableEnvironments");
|
|
expect(first.body.hiddenSettings.some((key: string) => key.includes("*") || key.startsWith("!"))).toBe(false);
|
|
|
|
env.PAPERCLIP_HIDDEN_SETTINGS = "instance.experimental.*,!instance.experimental.enableMemoryConnectors";
|
|
const second = await request(app).get("/health");
|
|
expect(second.body.hiddenSettings).toContain("instance.experimental.enableEnvironments");
|
|
expect(second.body.hiddenSettings).not.toContain("instance.experimental.enableMemoryConnectors");
|
|
});
|
|
|
|
it("returns 200 when the database probe succeeds", async () => {
|
|
const db = {
|
|
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
|
} as unknown as Db;
|
|
const app = createApp(db);
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(db.execute).toHaveBeenCalledTimes(1);
|
|
expect(res.body).toMatchObject({
|
|
status: "ok",
|
|
version: serverVersion,
|
|
serverInfo: testServerInfo,
|
|
});
|
|
});
|
|
|
|
it("returns 503 when the database probe fails", async () => {
|
|
const db = {
|
|
execute: vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
|
|
} as unknown as Db;
|
|
const app = createApp(db);
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
expect(res.status).toBe(503);
|
|
expect(res.body).toEqual({
|
|
status: "unhealthy",
|
|
version: serverVersion,
|
|
serverVersion,
|
|
commit: testServerInfo.git.fullSha,
|
|
error: "database_unreachable",
|
|
serverInfo: testServerInfo,
|
|
});
|
|
});
|
|
|
|
it("returns safe server info fallbacks when git metadata is unavailable", async () => {
|
|
const app = createApp(undefined, {
|
|
processStartedAt: "2026-06-26T00:00:00.000Z",
|
|
git: {
|
|
available: false,
|
|
unavailableReason: "git_unavailable",
|
|
},
|
|
});
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.serverInfo).toEqual({
|
|
processStartedAt: "2026-06-26T00:00:00.000Z",
|
|
git: {
|
|
available: false,
|
|
unavailableReason: "git_unavailable",
|
|
},
|
|
});
|
|
// With no git metadata baked in, the exposed commit is null (not omitted).
|
|
expect(res.body.commit).toBeNull();
|
|
});
|
|
|
|
it("surfaces a stale database backup warning in full health details", async () => {
|
|
const backupDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-health-backups-"));
|
|
const backupFile = path.join(backupDir, "paperclip-20260705-031702.sql.gz");
|
|
fs.writeFileSync(backupFile, "backup");
|
|
fs.utimesSync(
|
|
backupFile,
|
|
new Date("2026-07-05T03:17:02.000Z"),
|
|
new Date("2026-07-05T03:17:02.000Z"),
|
|
);
|
|
const app = createApp(createHealthyDb(), testServerInfo, {
|
|
enabled: true,
|
|
backupDir,
|
|
maxAgeHours: 26,
|
|
now: new Date("2026-07-06T13:00:00.000Z"),
|
|
});
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.databaseBackup).toMatchObject({
|
|
status: "warning",
|
|
backupDir,
|
|
maxAgeHours: 26,
|
|
latestBackup: {
|
|
name: "paperclip-20260705-031702.sql.gz",
|
|
ageHours: 33.7,
|
|
},
|
|
warnings: [
|
|
{
|
|
code: "database_backup_stale",
|
|
},
|
|
],
|
|
});
|
|
expect(res.body.warnings).toEqual(res.body.databaseBackup.warnings);
|
|
});
|
|
|
|
it("surfaces database backup failure markers in full health details", async () => {
|
|
const backupDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-health-backups-"));
|
|
const backupFile = path.join(backupDir, "paperclip-20260706-031702.sql.gz");
|
|
const alertFile = path.join(backupDir, "db-backup-to-s3.failure");
|
|
fs.writeFileSync(backupFile, "backup");
|
|
fs.writeFileSync(alertFile, "db-backup-to-s3 failed at 2026-07-06T03:17:00.000Z exit=1\n");
|
|
const app = createApp(createHealthyDb(), testServerInfo, {
|
|
enabled: true,
|
|
backupDir,
|
|
maxAgeHours: 26,
|
|
alertFile,
|
|
now: new Date("2026-07-06T04:00:00.000Z"),
|
|
});
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.databaseBackup).toMatchObject({
|
|
status: "warning",
|
|
lastFailure: {
|
|
path: alertFile,
|
|
message: "db-backup-to-s3 failed at 2026-07-06T03:17:00.000Z exit=1",
|
|
},
|
|
warnings: [
|
|
{
|
|
code: "database_backup_last_failure",
|
|
message: "db-backup-to-s3 failed at 2026-07-06T03:17:00.000Z exit=1",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("finds conventional database backup failure markers without an explicit alert file", async () => {
|
|
const backupRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-health-backups-root-"));
|
|
const backupDir = path.join(backupRoot, "backups");
|
|
fs.mkdirSync(backupDir);
|
|
const backupFile = path.join(backupDir, "paperclip-20260706-031702.sql.gz");
|
|
const alertFile = path.join(backupRoot, "db-backup-to-s3.failure");
|
|
fs.writeFileSync(backupFile, "backup");
|
|
fs.writeFileSync(alertFile, "db-backup-to-s3 failed beside backups\n");
|
|
const app = createApp(createHealthyDb(), testServerInfo, {
|
|
enabled: true,
|
|
backupDir,
|
|
maxAgeHours: 26,
|
|
now: new Date("2026-07-06T04:00:00.000Z"),
|
|
});
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.databaseBackup).toMatchObject({
|
|
status: "warning",
|
|
lastFailure: {
|
|
path: alertFile,
|
|
message: "db-backup-to-s3 failed beside backups",
|
|
},
|
|
warnings: [
|
|
{
|
|
code: "database_backup_last_failure",
|
|
message: "db-backup-to-s3 failed beside backups",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("surfaces redacted database backup warnings for anonymous authenticated probes", async () => {
|
|
const backupDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-health-redacted-backups-"));
|
|
const backupFile = path.join(backupDir, "paperclip-20260705-031702.sql.gz");
|
|
fs.writeFileSync(backupFile, "backup");
|
|
fs.utimesSync(
|
|
backupFile,
|
|
new Date("2026-07-05T03:17:02.000Z"),
|
|
new Date("2026-07-05T03:17:02.000Z"),
|
|
);
|
|
const { healthRoutes } = await import("../routes/health.js");
|
|
const db = {
|
|
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
|
select: vi.fn(() => ({
|
|
from: vi.fn(() => ({
|
|
where: vi.fn().mockResolvedValue([{ count: 1 }]),
|
|
})),
|
|
})),
|
|
} as unknown as Db;
|
|
const app = express();
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = { type: "none", source: "none" };
|
|
next();
|
|
});
|
|
app.use(
|
|
"/health",
|
|
healthRoutes(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
authReady: true,
|
|
companyDeletionEnabled: false,
|
|
serverInfo: testServerInfo,
|
|
databaseBackupHealth: {
|
|
enabled: true,
|
|
backupDir,
|
|
maxAgeHours: 26,
|
|
now: new Date("2026-07-06T13:00:00.000Z"),
|
|
},
|
|
}),
|
|
);
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({
|
|
status: "ok",
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
localAiLoginSupported: false,
|
|
commit: testServerInfo.git.fullSha,
|
|
bootstrapStatus: "ready",
|
|
bootstrapInviteActive: false,
|
|
databaseBackup: {
|
|
enabled: true,
|
|
status: "warning",
|
|
warnings: [
|
|
{
|
|
code: "database_backup_stale",
|
|
message: "Latest database backup is stale.",
|
|
},
|
|
],
|
|
},
|
|
warnings: [
|
|
{
|
|
code: "database_backup_stale",
|
|
message: "Latest database backup is stale.",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("redacts detailed metadata for anonymous requests in authenticated mode", async () => {
|
|
const devServerStatus = await import("../dev-server-status.js");
|
|
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
|
|
const { healthRoutes } = await import("../routes/health.js");
|
|
const db = {
|
|
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
|
select: vi.fn(() => ({
|
|
from: vi.fn(() => ({
|
|
where: vi.fn().mockResolvedValue([{ count: 1 }]),
|
|
})),
|
|
})),
|
|
} as unknown as Db;
|
|
const app = express();
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = { type: "none", source: "none" };
|
|
next();
|
|
});
|
|
app.use(
|
|
"/health",
|
|
healthRoutes(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
authReady: true,
|
|
companyDeletionEnabled: false,
|
|
serverInfo: testServerInfo,
|
|
}),
|
|
);
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({
|
|
status: "ok",
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
localAiLoginSupported: false,
|
|
commit: testServerInfo.git.fullSha,
|
|
bootstrapStatus: "ready",
|
|
bootstrapInviteActive: false,
|
|
});
|
|
expect(res.body.serverInfo).toBeUndefined();
|
|
});
|
|
|
|
it("redacts detailed metadata when authenticated mode is reached without auth middleware", async () => {
|
|
const devServerStatus = await import("../dev-server-status.js");
|
|
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
|
|
const { healthRoutes } = await import("../routes/health.js");
|
|
const db = {
|
|
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
|
select: vi.fn(() => ({
|
|
from: vi.fn(() => ({
|
|
where: vi.fn().mockResolvedValue([{ count: 1 }]),
|
|
})),
|
|
})),
|
|
} as unknown as Db;
|
|
const app = express();
|
|
app.use(
|
|
"/health",
|
|
healthRoutes(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
authReady: true,
|
|
companyDeletionEnabled: false,
|
|
serverInfo: testServerInfo,
|
|
}),
|
|
);
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({
|
|
status: "ok",
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
localAiLoginSupported: false,
|
|
commit: testServerInfo.git.fullSha,
|
|
bootstrapStatus: "ready",
|
|
bootstrapInviteActive: false,
|
|
});
|
|
expect(res.body.serverInfo).toBeUndefined();
|
|
});
|
|
|
|
it("keeps detailed metadata for authenticated requests in authenticated mode", async () => {
|
|
const devServerStatus = await import("../dev-server-status.js");
|
|
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
|
|
const { healthRoutes } = await import("../routes/health.js");
|
|
const db = {
|
|
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
|
select: vi.fn(() => ({
|
|
from: vi.fn(() => ({
|
|
where: vi.fn().mockResolvedValue([{ count: 1 }]),
|
|
})),
|
|
})),
|
|
} as unknown as Db;
|
|
const app = express();
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = { type: "board", userId: "user-1", source: "session" };
|
|
next();
|
|
});
|
|
app.use(
|
|
"/health",
|
|
healthRoutes(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
authReady: true,
|
|
companyDeletionEnabled: false,
|
|
serverInfo: testServerInfo,
|
|
}),
|
|
);
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
status: "ok",
|
|
version: serverVersion,
|
|
serverVersion,
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
authReady: true,
|
|
bootstrapStatus: "ready",
|
|
bootstrapInviteActive: false,
|
|
features: {
|
|
companyDeletionEnabled: false,
|
|
},
|
|
serverInfo: testServerInfo,
|
|
});
|
|
});
|
|
|
|
it("reports bootstrap_pending in authenticated mode when no instance admin exists", async () => {
|
|
const { healthRoutes } = await import("../routes/health.js");
|
|
const db = {
|
|
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
|
select: vi.fn(() => ({
|
|
from: vi.fn(() => ({
|
|
where: vi.fn().mockResolvedValue([{ count: 0 }]),
|
|
})),
|
|
})),
|
|
} as unknown as Db;
|
|
const app = express();
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = { type: "none", source: "none" };
|
|
next();
|
|
});
|
|
app.use(
|
|
"/health",
|
|
healthRoutes(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
authReady: true,
|
|
companyDeletionEnabled: false,
|
|
serverInfo: testServerInfo,
|
|
}),
|
|
);
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
status: "ok",
|
|
bootstrapStatus: "bootstrap_pending",
|
|
bootstrapInviteActive: false,
|
|
});
|
|
});
|
|
|
|
it("reports bootstrapStatus ready for cloud-managed instances regardless of instance admin count", async () => {
|
|
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-tenant-server-token");
|
|
const { healthRoutes } = await import("../routes/health.js");
|
|
const db = {
|
|
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
|
select: vi.fn(() => ({
|
|
from: vi.fn(() => ({
|
|
where: vi.fn().mockResolvedValue([{ count: 0 }]),
|
|
})),
|
|
})),
|
|
} as unknown as Db;
|
|
const app = express();
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = { type: "none", source: "none" };
|
|
next();
|
|
});
|
|
app.use(
|
|
"/health",
|
|
healthRoutes(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
authReady: true,
|
|
companyDeletionEnabled: false,
|
|
serverInfo: testServerInfo,
|
|
}),
|
|
);
|
|
|
|
const res = await request(app).get("/health");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
status: "ok",
|
|
bootstrapStatus: "ready",
|
|
bootstrapInviteActive: false,
|
|
});
|
|
});
|
|
});
|