install token now shown in dashboard until install completes.
This commit is contained in:
18 files changed
+1977
-15
No files matched your search
@@ -0,0 +1,133 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const composeUpMock = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/docker", () => ({
|
||||
composeUp: (...args: unknown[]) => composeUpMock(...args),
|
||||
composeDown: vi.fn(),
|
||||
composeLogs: vi.fn(),
|
||||
composeRestart: vi.fn(),
|
||||
}));
|
||||
|
||||
const fetchInstallationStatusMock = vi.fn();
|
||||
vi.mock("@/lib/installation-status", () => ({
|
||||
fetchInstallationStatus: (...args: unknown[]) => fetchInstallationStatusMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { regenerateInstallationTokenAction } from "../actions";
|
||||
|
||||
const TOKEN_HEX_PATTERN = /^[a-f0-9]{64}$/;
|
||||
|
||||
describe("regenerateInstallationTokenAction", () => {
|
||||
let stacksDir: string;
|
||||
const originalStacksDir = process.env.STACKS_DIR;
|
||||
|
||||
beforeEach(async () => {
|
||||
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-actions-test-"));
|
||||
process.env.STACKS_DIR = stacksDir;
|
||||
composeUpMock.mockClear();
|
||||
fetchInstallationStatusMock.mockReset();
|
||||
fetchInstallationStatusMock.mockResolvedValue({ status: "not_installed", installedAt: null });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||
else process.env.STACKS_DIR = originalStacksDir;
|
||||
await rm(stacksDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createStack(slug: string, envContent: string) {
|
||||
const dir = path.join(stacksDir, slug);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8");
|
||||
await writeFile(path.join(dir, ".env"), envContent, "utf8");
|
||||
return dir;
|
||||
}
|
||||
|
||||
function formData(fields: Record<string, string>): FormData {
|
||||
const fd = new FormData();
|
||||
for (const [key, value] of Object.entries(fields)) fd.set(key, value);
|
||||
return fd;
|
||||
}
|
||||
|
||||
it("requires confirmation on the first call — does not touch the .env or the container yet", async () => {
|
||||
await createStack("acme", 'INSTALLATION_TOKEN="old-token"\n');
|
||||
|
||||
const result = await regenerateInstallationTokenAction("acme", { error: null, needsConfirmation: false, token: null }, formData({}));
|
||||
|
||||
expect(result).toEqual({ error: null, needsConfirmation: true, token: null });
|
||||
expect(composeUpMock).not.toHaveBeenCalled();
|
||||
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||
expect(envAfter).toContain("old-token");
|
||||
});
|
||||
|
||||
it("on confirmation, replaces the token, recreates the container, and returns the new token", async () => {
|
||||
await createStack("acme", 'BUSINESS_NAME="Acme"\nINSTALLATION_TOKEN="old-token"\nAUTH_SECRET="unrelated-secret"\n');
|
||||
|
||||
const result = await regenerateInstallationTokenAction(
|
||||
"acme",
|
||||
{ error: null, needsConfirmation: true, token: null },
|
||||
formData({ confirmRegenerate: "true" }),
|
||||
);
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.token).toMatch(TOKEN_HEX_PATTERN);
|
||||
expect(result.token).not.toBe("old-token");
|
||||
|
||||
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||
expect(envAfter).toContain(`INSTALLATION_TOKEN="${result.token}"`);
|
||||
expect(envAfter).not.toContain("old-token");
|
||||
// Rewriting the token must not touch unrelated secrets already in the file.
|
||||
expect(envAfter).toContain('AUTH_SECRET="unrelated-secret"');
|
||||
|
||||
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), { forceRecreate: true });
|
||||
});
|
||||
|
||||
it("invalidates the previous token — the old value no longer appears anywhere in the business's config", async () => {
|
||||
await createStack("acme", 'INSTALLATION_TOKEN="old-token-to-invalidate"\n');
|
||||
|
||||
await regenerateInstallationTokenAction("acme", { error: null, needsConfirmation: true, token: null }, formData({ confirmRegenerate: "true" }));
|
||||
|
||||
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||
expect(envAfter).not.toContain("old-token-to-invalidate");
|
||||
});
|
||||
|
||||
it("refuses to regenerate once installation is complete, even if the form is (re)submitted", async () => {
|
||||
await createStack("acme", 'INSTALLATION_TOKEN="old-token"\n');
|
||||
fetchInstallationStatusMock.mockResolvedValue({ status: "installed", installedAt: "2026-01-01T00:00:00.000Z" });
|
||||
|
||||
const result = await regenerateInstallationTokenAction(
|
||||
"acme",
|
||||
{ error: null, needsConfirmation: true, token: null },
|
||||
formData({ confirmRegenerate: "true" }),
|
||||
);
|
||||
|
||||
expect(result.error).toMatch(/already completed installation/i);
|
||||
expect(result.token).toBeNull();
|
||||
expect(composeUpMock).not.toHaveBeenCalled();
|
||||
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||
expect(envAfter).toContain("old-token");
|
||||
});
|
||||
|
||||
it("tenant isolation: regenerating one business's token never touches a sibling business's .env", async () => {
|
||||
await createStack("tenant-a", 'INSTALLATION_TOKEN="token-a"\n');
|
||||
await createStack("tenant-b", 'INSTALLATION_TOKEN="token-b"\n');
|
||||
|
||||
await regenerateInstallationTokenAction("tenant-a", { error: null, needsConfirmation: true, token: null }, formData({ confirmRegenerate: "true" }));
|
||||
|
||||
const envB = await readFile(path.join(stacksDir, "tenant-b", ".env"), "utf8");
|
||||
expect(envB).toContain('INSTALLATION_TOKEN="token-b"');
|
||||
expect(composeUpMock).toHaveBeenCalledTimes(1);
|
||||
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "tenant-a"), { forceRecreate: true });
|
||||
});
|
||||
|
||||
it("rejects an unknown/nonexistent slug", async () => {
|
||||
await expect(
|
||||
regenerateInstallationTokenAction("does-not-exist", { error: null, needsConfirmation: true, token: null }, formData({ confirmRegenerate: "true" })),
|
||||
).rejects.toThrow(/no such stack/i);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user