mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
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:
1 parent
1eaa26a69b
commit
d4b2af3c8e
7 files changed
+216
-17
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user