Installation
CI / ci (push) Canceled after 0s

install token now shown in dashboard until install completes.
This commit is contained in:
Stefan Lazic committed 2026-09-06 20:14:25 +01:00
1 parent d7a8b15482
commit e9e28a243f
18 files changed
+1977 -15

No files matched your search

@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const queryRawMock = vi.fn();
vi.mock("@/lib/db/prisma", () => ({
prisma: { $queryRaw: (...args: unknown[]) => queryRawMock(...args) },
}));
const getInstallationStateMock = vi.fn();
vi.mock("@/lib/installation/installation-state", () => ({
getInstallationState: () => getInstallationStateMock(),
}));
import { GET } from "../route";
/**
* This is a public, unauthenticated endpoint (see the route's own header)
* — the one contract that matters is that it never leaks anything beyond
* status/database/installed/installedAt, in particular never the
* installation token or any other tenant data. Manager (a separate app
* with no database access of its own) relies on `installed`/`installedAt`
* here to decide whether to still show that business's installation
* token, so those two fields also need to behave correctly.
*/
describe("GET /api/health", () => {
beforeEach(() => {
queryRawMock.mockReset();
getInstallationStateMock.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns unhealthy with 503 when the database is unreachable, without calling installation-state", async () => {
queryRawMock.mockRejectedValueOnce(new Error("connection refused"));
const response = await GET();
expect(response.status).toBe(503);
const body = await response.json();
expect(body).toEqual({ status: "unhealthy", database: "unreachable" });
expect(getInstallationStateMock).not.toHaveBeenCalled();
});
it("reports installed: false and installedAt: null before installation", async () => {
queryRawMock.mockResolvedValueOnce([{ 1: 1 }]);
getInstallationStateMock.mockResolvedValueOnce({ installed: false, status: "NOT_INSTALLED", installedAt: null });
const response = await GET();
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({ status: "healthy", database: "reachable", installed: false, installedAt: null });
});
it("reports installed: true and an ISO installedAt once installed", async () => {
queryRawMock.mockResolvedValueOnce([{ 1: 1 }]);
const installedAt = new Date("2026-01-15T10:30:00.000Z");
getInstallationStateMock.mockResolvedValueOnce({ installed: true, status: "INSTALLED", installedAt });
const response = await GET();
const body = await response.json();
expect(body).toEqual({
status: "healthy",
database: "reachable",
installed: true,
installedAt: "2026-01-15T10:30:00.000Z",
});
});
it("falls back to installed: false, installedAt: null rather than throwing if installation-state itself errors", async () => {
queryRawMock.mockResolvedValueOnce([{ 1: 1 }]);
getInstallationStateMock.mockRejectedValueOnce(new Error("boom"));
const response = await GET();
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({ status: "healthy", database: "reachable", installed: false, installedAt: null });
});
it("never returns anything beyond status/database/installed/installedAt — no token, no other tenant data", async () => {
queryRawMock.mockResolvedValueOnce([{ 1: 1 }]);
getInstallationStateMock.mockResolvedValueOnce({ installed: true, status: "INSTALLED", installedAt: new Date() });
const response = await GET();
const body = await response.json();
expect(Object.keys(body).sort()).toEqual(["database", "installed", "installedAt", "status"]);
});
});
+15 -2
View File
@@ -9,6 +9,14 @@ import { getInstallationState } from "@/lib/installation/installation-state";
* show the installer), not unhealthy. Only a database connectivity
* failure returns a non-200 status. Never returns secrets or a stack
* trace — just enough for a health probe to act on.
*
* `installed`/`installedAt` are also this deployment's only externally
* observable installation-status signal — NextChair Manager (a separate
* app with no database access of its own) polls this over the internal
* Docker network to decide whether to still show that business's
* installation token, rather than Manager needing its own authenticated
* status endpoint. Deliberately still just a boolean + a timestamp: never
* the installation token itself, never anything else about the tenant.
*/
export async function GET() {
try {
@@ -17,7 +25,12 @@ export async function GET() {
return NextResponse.json({ status: "unhealthy", database: "unreachable" }, { status: 503 });
}
const { installed } = await getInstallationState().catch(() => ({ installed: false }));
const { installed, installedAt } = await getInstallationState().catch(() => ({ installed: false, installedAt: null }));
return NextResponse.json({ status: "healthy", database: "reachable", installed });
return NextResponse.json({
status: "healthy",
database: "reachable",
installed,
installedAt: installedAt ? installedAt.toISOString() : null,
});
}
+4 -2
View File
@@ -12,6 +12,8 @@ export const INSTALLATION_SINGLETON_ID = "singleton";
export interface InstallationState {
installed: boolean;
status: InstallationStatus;
/** When `status === INSTALLED`, else null. Surfaced by GET /api/health so an external tool (e.g. NextChair Manager) can show it without ever needing DB access of its own — see that route's own header. */
installedAt: Date | null;
}
/**
@@ -25,10 +27,10 @@ export interface InstallationState {
export const getInstallationState = cache(async (): Promise<InstallationState> => {
const row = await prisma.applicationInstallation.findUnique({
where: { id: INSTALLATION_SINGLETON_ID },
select: { status: true },
select: { status: true, installedAt: true },
});
const status = row?.status ?? InstallationStatus.NOT_INSTALLED;
return { installed: status === InstallationStatus.INSTALLED, status };
return { installed: status === InstallationStatus.INSTALLED, status, installedAt: row?.installedAt ?? null };
});
/**