Finish multi-user QA follow-up fixes

- add stable Company Settings test hooks
- probe DB connectivity from /api/health
- include companyName in invite summaries

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dottaandPaperclip committed 2026-03-30 13:32:23 -05:00
1 parent 1eaa26a69b
commit d4b2af3c8e
7 files changed
+216 -17

No files matched your search

+44 -4
View File
@@ -1,16 +1,56 @@
import { describe, it, expect } from "vitest";
import { describe, expect, it, vi } from "vitest";
import express from "express";
import request from "supertest";
import { healthRoutes } from "../routes/health.js";
import { serverVersion } from "../version.js";
describe("GET /health", () => {
const app = express();
app.use("/health", healthRoutes());
vi.mock("../dev-server-status.js", async () => {
const actual = await vi.importActual<typeof import("../dev-server-status.js")>(
"../dev-server-status.js",
);
return {
...actual,
readPersistedDevServerStatus: () => null,
};
});
describe("GET /health", () => {
it("returns 200 with status ok", async () => {
const app = express();
app.use("/health", healthRoutes());
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: "ok", version: serverVersion });
});
it("checks database connectivity before reporting healthy", async () => {
const execute = vi.fn().mockResolvedValue([]);
const app = express();
app.use("/health", healthRoutes({ execute } as any));
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(execute).toHaveBeenCalledTimes(1);
expect(res.body).toMatchObject({ status: "ok", version: serverVersion });
});
it("returns 503 when the database probe fails", async () => {
const app = express();
app.use(
"/health",
healthRoutes({
execute: vi.fn().mockRejectedValue(new Error("db offline")),
} as any),
);
const res = await request(app).get("/health");
expect(res.status).toBe(503);
expect(res.body).toEqual({
status: "error",
error: "Database unavailable",
version: serverVersion,
});
});
});
@@ -0,0 +1,77 @@
import express from "express";
import request from "supertest";
import { describe, expect, it } from "vitest";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
function createSelectChain(rows: unknown[]) {
return {
from() {
return {
where() {
return Promise.resolve(rows);
},
};
},
};
}
function createDbStub(inviteRows: unknown[], companyRows: unknown[]) {
let selectCall = 0;
return {
select() {
selectCall += 1;
return selectCall === 1
? createSelectChain(inviteRows)
: createSelectChain(companyRows);
},
};
}
function createApp(db: Record<string, unknown>) {
const app = express();
app.use((req, _res, next) => {
(req as any).actor = { type: "anon" };
next();
});
app.use(
"/api",
accessRoutes(db as any, {
deploymentMode: "local_trusted",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
}
describe("GET /invites/:token", () => {
it("returns companyName in the invite summary response", async () => {
const invite = {
id: "invite-1",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "human",
tokenHash: "hash",
defaultsPayload: null,
expiresAt: new Date("2027-03-07T00:10:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
const app = createApp(
createDbStub([invite], [{ name: "Acme Robotics" }]),
);
const res = await request(app).get("/api/invites/pcp_invite_test");
expect(res.status).toBe(200);
expect(res.body.companyId).toBe("company-1");
expect(res.body.companyName).toBe("Acme Robotics");
expect(res.body.inviteType).toBe("company_join");
});
});
+48 -7
View File
@@ -14,6 +14,7 @@ import type { Db } from "@paperclipai/db";
import {
agentApiKeys,
authUsers,
companies,
invites,
joinRequests
} from "@paperclipai/db";
@@ -870,7 +871,8 @@ export function normalizeAgentDefaultsForJoin(input: {
function toInviteSummaryResponse(
req: Request,
token: string,
invite: typeof invites.$inferSelect
invite: typeof invites.$inferSelect,
companyName: string | null = null,
) {
const baseUrl = requestBaseUrl(req);
const onboardingPath = `/api/invites/${token}/onboarding`;
@@ -879,6 +881,7 @@ function toInviteSummaryResponse(
return {
id: invite.id,
companyId: invite.companyId,
companyName,
inviteType: invite.inviteType,
allowedJoinTypes: invite.allowedJoinTypes,
humanRole: extractInviteHumanRole(invite.defaultsPayload),
@@ -897,6 +900,18 @@ function toInviteSummaryResponse(
};
}
async function resolveInviteCompanyName(
db: Db,
companyId: string | null,
): Promise<string | null> {
if (!companyId) return null;
return db
.select({ name: companies.name })
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0]?.name ?? null);
}
function buildOnboardingDiscoveryDiagnostics(input: {
apiBaseUrl: string;
deploymentMode: DeploymentMode;
@@ -1012,7 +1027,8 @@ function buildInviteOnboardingManifest(
deploymentExposure: DeploymentExposure;
bindHost: string;
allowedHostnames: string[];
}
},
companyName: string | null = null,
) {
const baseUrl = requestBaseUrl(req);
const skillPath = "/api/skills/paperclip";
@@ -1039,7 +1055,7 @@ function buildInviteOnboardingManifest(
});
return {
invite: toInviteSummaryResponse(req, token, invite),
invite: toInviteSummaryResponse(req, token, invite, companyName),
onboarding: {
instructions:
"Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).",
@@ -1960,7 +1976,12 @@ export function accessRoutes(
}
});
const inviteSummary = toInviteSummaryResponse(req, token, created);
const inviteSummary = toInviteSummaryResponse(
req,
token,
created,
await resolveInviteCompanyName(db, created.companyId),
);
res.status(201).json({
...created,
token,
@@ -2005,7 +2026,12 @@ export function accessRoutes(
}
});
const inviteSummary = toInviteSummaryResponse(req, token, created);
const inviteSummary = toInviteSummaryResponse(
req,
token,
created,
await resolveInviteCompanyName(db, created.companyId),
);
res.status(201).json({
...created,
token,
@@ -2034,7 +2060,14 @@ export function accessRoutes(
throw notFound("Invite not found");
}
res.json(toInviteSummaryResponse(req, token, invite));
res.json(
toInviteSummaryResponse(
req,
token,
invite,
await resolveInviteCompanyName(db, invite.companyId),
),
);
});
router.get("/invites/:token/onboarding", async (req, res) => {
@@ -2049,7 +2082,15 @@ export function accessRoutes(
throw notFound("Invite not found");
}
res.json(buildInviteOnboardingManifest(req, token, invite, opts));
res.json(
buildInviteOnboardingManifest(
req,
token,
invite,
opts,
await resolveInviteCompanyName(db, invite.companyId),
),
);
});
router.get("/invites/:token/onboarding.txt", async (req, res) => {
+13
View File
@@ -4,6 +4,7 @@ import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db";
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js";
import { logger } from "../middleware/logger.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { serverVersion } from "../version.js";
@@ -29,6 +30,18 @@ export function healthRoutes(
return;
}
try {
await db.execute(sql`select 1`);
} catch (error) {
logger.warn({ err: error }, "Health check database probe failed");
res.status(503).json({
status: "error",
error: "Database unavailable",
version: serverVersion,
});
return;
}
let bootstrapStatus: "ready" | "bootstrap_pending" = "ready";
let bootstrapInviteActive = false;
if (opts.deploymentMode === "authenticated") {
+1
View File
@@ -6,6 +6,7 @@ export type HumanCompanyRole = "owner" | "admin" | "operator" | "viewer";
type InviteSummary = {
id: string;
companyId: string | null;
companyName?: string | null;
inviteType: "company_join" | "bootstrap_ceo";
allowedJoinTypes: "human" | "agent" | "both";
humanRole?: HumanCompanyRole | null;
+20 -3
View File
@@ -475,7 +475,10 @@ export function CompanySettings() {
</div>
{/* Invites */}
<div className="space-y-4">
<div
className="space-y-4"
data-testid="company-settings-invites-section"
>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Invites
</div>
@@ -491,6 +494,7 @@ export function CompanySettings() {
<>
<div className="flex flex-wrap items-center gap-2">
<select
data-testid="company-settings-human-invite-role"
className="rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={humanInviteRole}
onChange={(e) => setHumanInviteRole(e.target.value as HumanCompanyRole)}
@@ -502,6 +506,7 @@ export function CompanySettings() {
))}
</select>
<Button
data-testid="company-settings-create-human-invite"
size="sm"
onClick={() => humanInviteMutation.mutate()}
disabled={humanInviteMutation.isPending}
@@ -521,6 +526,7 @@ export function CompanySettings() {
Human invite link
</div>
<textarea
data-testid="company-settings-human-invite-url"
className="mt-1 h-24 w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none"
value={humanInviteUrl}
readOnly
@@ -545,6 +551,7 @@ export function CompanySettings() {
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
data-testid="company-settings-generate-openclaw-invite"
size="sm"
onClick={() => inviteMutation.mutate()}
disabled={inviteMutation.isPending}
@@ -575,6 +582,7 @@ export function CompanySettings() {
</div>
<div className="mt-1 space-y-1.5">
<textarea
data-testid="company-settings-openclaw-invite-snippet"
className="h-[28rem] w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none"
value={inviteSnippet}
readOnly
@@ -603,11 +611,17 @@ export function CompanySettings() {
</div>
</div>
<div className="space-y-4">
<div
className="space-y-4"
data-testid="company-settings-team-section"
>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Team
</div>
<div className="space-y-3 rounded-md border border-border px-4 py-4">
<div
className="space-y-3 rounded-md border border-border px-4 py-4"
data-testid="company-settings-team-list"
>
{membersQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading members...</p>
) : membersQuery.isError ? (
@@ -625,6 +639,7 @@ export function CompanySettings() {
<div
key={member.id}
className="flex flex-col gap-3 rounded-md border border-border/70 p-3"
data-testid={`company-settings-member-${member.id}`}
>
<div className="flex items-start justify-between gap-3">
<div>
@@ -642,6 +657,7 @@ export function CompanySettings() {
<div className="flex flex-wrap items-center gap-2">
{canManageMembers ? (
<select
data-testid={`company-settings-member-role-${member.id}`}
className="rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={member.membershipRole}
disabled={updatingMember}
@@ -666,6 +682,7 @@ export function CompanySettings() {
)}
{canManageMembers && (
<Button
data-testid={`company-settings-member-status-${member.id}`}
size="sm"
variant="outline"
disabled={updatingMember}
+13 -3
View File
@@ -127,7 +127,10 @@ export function InviteLandingPage() {
if (inviteQuery.error || !invite) {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<div
className="rounded-lg border border-border bg-card p-6"
data-testid="invite-error"
>
<h1 className="text-lg font-semibold">Invite not available</h1>
<p className="mt-2 text-sm text-muted-foreground">
This invite may be expired, revoked, or already used.
@@ -237,9 +240,16 @@ export function InviteLandingPage() {
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-xl font-semibold">
{invite.inviteType === "bootstrap_ceo" ? "Bootstrap your Paperclip instance" : "Join this Paperclip company"}
{invite.inviteType === "bootstrap_ceo"
? "Bootstrap your Paperclip instance"
: invite.companyName
? `Join ${invite.companyName}`
: "Join this Paperclip company"}
</h1>
<p className="mt-2 text-sm text-muted-foreground">Invite expires {dateTime(invite.expiresAt)}.</p>
<p className="mt-2 text-sm text-muted-foreground">
{invite.companyName ? `You were invited to ${invite.companyName}. ` : ""}
Invite expires {dateTime(invite.expiresAt)}.
</p>
{invite.inviteType !== "bootstrap_ceo" && (
<div className="mt-5 flex gap-2">