Made google calendar auth not business specific. global-settings.ts — new GoogleCalendarSettings section, encrypted at rest the same way as the SMTP password/VAPID private key. google-calendar-rollout.ts — pushes the shared client ID/secret into every business's .env and recreates its container. GOOGLE_CALENDAR_REDIRECT_URI is left alone (it must keep tracking each business's own domain). provisioning.ts / compose-template.ts — new businesses now pick up the global client automatically at creation, same as Email/Push already do. Settings UI — new "Google Calendar" card at the bottom of Manager's Settings page (google-calendar-settings-form.tsx), with Save + "Reapply to all businesses."
This commit is contained in:
11 files changed
+684
-10
No files matched your search
@@ -0,0 +1,109 @@
|
||||
import { mkdir, mkdtemp, 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(),
|
||||
composeStatus: vi.fn().mockResolvedValue("stopped"),
|
||||
reloadCaddy: vi.fn(),
|
||||
runMigrations: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("applyGoogleCalendarSettingsToAllBusinesses", () => {
|
||||
let stacksDir: string;
|
||||
const originalStacksDir = process.env.STACKS_DIR;
|
||||
|
||||
beforeEach(async () => {
|
||||
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-gcal-rollout-test-"));
|
||||
process.env.STACKS_DIR = stacksDir;
|
||||
composeUpMock.mockClear();
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
it("writes the shared client ID/secret into every business and force-recreates each container", async () => {
|
||||
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
|
||||
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY="existing-key"'].join("\n") + "\n");
|
||||
|
||||
const results = await applyGoogleCalendarSettingsToAllBusinesses({
|
||||
clientId: "shared-client-id",
|
||||
clientSecret: "shared-client-secret",
|
||||
});
|
||||
|
||||
expect(results).toEqual([{ slug: "acme", ok: true }]);
|
||||
const env = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_ID="shared-client-id"');
|
||||
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_SECRET="shared-client-secret"');
|
||||
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), { forceRecreate: true });
|
||||
});
|
||||
|
||||
it("never touches GOOGLE_CALENDAR_REDIRECT_URI — it stays derived from each business's own domain", async () => {
|
||||
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
|
||||
await createStack(
|
||||
"acme",
|
||||
['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'GOOGLE_CALENDAR_REDIRECT_URI="https://acme.example.com/api/staff/calendar/google/callback"'].join(
|
||||
"\n",
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
|
||||
|
||||
const env = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||
expect(env).toContain('GOOGLE_CALENDAR_REDIRECT_URI="https://acme.example.com/api/staff/calendar/google/callback"');
|
||||
});
|
||||
|
||||
it("leaves an existing CALENDAR_TOKEN_ENCRYPTION_KEY untouched — it must never be shared across businesses", async () => {
|
||||
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
|
||||
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY="already-set-key"'].join("\n") + "\n");
|
||||
|
||||
await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
|
||||
|
||||
const env = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||
expect(env).toContain('CALENDAR_TOKEN_ENCRYPTION_KEY="already-set-key"');
|
||||
});
|
||||
|
||||
it("backfills a fresh, distinct CALENDAR_TOKEN_ENCRYPTION_KEY for a business that never had one (pre-fix)", async () => {
|
||||
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
|
||||
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY=""'].join("\n") + "\n");
|
||||
await createStack("other", ['BUSINESS_NAME="Other"', 'DOMAIN="other.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY=""'].join("\n") + "\n");
|
||||
|
||||
await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
|
||||
|
||||
const envA = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||
const envB = await readFile(path.join(stacksDir, "other", ".env"), "utf8");
|
||||
const keyA = /CALENDAR_TOKEN_ENCRYPTION_KEY="([^"]*)"/.exec(envA)?.[1];
|
||||
const keyB = /CALENDAR_TOKEN_ENCRYPTION_KEY="([^"]*)"/.exec(envB)?.[1];
|
||||
expect(keyA).toBeTruthy();
|
||||
expect(keyB).toBeTruthy();
|
||||
expect(keyA).not.toBe(keyB);
|
||||
});
|
||||
|
||||
it("reports per-business failures without aborting the rest", async () => {
|
||||
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
|
||||
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"'].join("\n") + "\n");
|
||||
await createStack("broken", ['BUSINESS_NAME="Broken"', 'DOMAIN="broken.example.com"'].join("\n") + "\n");
|
||||
composeUpMock.mockImplementation(async (dir: string) => {
|
||||
if (dir.includes("broken")) throw new Error("container recreate failed");
|
||||
});
|
||||
|
||||
const results = await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
|
||||
|
||||
expect(results).toContainEqual({ slug: "acme", ok: true });
|
||||
expect(results).toContainEqual({ slug: "broken", ok: false, error: "container recreate failed" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user