Auth Fix
CI / ci (push) Canceled after 0s

Fixed an issue where the user would be redirected to a localhost address because an auth url was not set in the env.
This commit is contained in:
Stefan Lazic committed 2026-09-06 21:01:54 +01:00
1 parent e9e28a243f
commit cbeccc6c1f
8 files changed
+420 -71

No files matched your search

@@ -35,3 +35,17 @@ describe("generateBusinessEnv — INSTALLATION_TOKEN", () => {
expect(envB).not.toContain("token-a");
});
});
describe("generateBusinessEnv — AUTH_URL", () => {
it("sets AUTH_URL explicitly to the business's own https domain — not left to header-based detection", () => {
const env = generateBusinessEnv(baseValues);
expect(env).toContain('AUTH_URL="https://acme.example.com"');
});
it("AUTH_URL always matches DOMAIN/NEXT_PUBLIC_APP_URL for a given business", () => {
const env = generateBusinessEnv({ ...baseValues, domain: "other-business.example.com" });
expect(env).toContain('DOMAIN="other-business.example.com"');
expect(env).toContain('AUTH_URL="https://other-business.example.com"');
expect(env).toContain('NEXT_PUBLIC_APP_URL="https://other-business.example.com"');
});
});
@@ -0,0 +1,102 @@
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);
const reloadCaddyMock = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/docker", () => ({
composeUp: (...args: unknown[]) => composeUpMock(...args),
composeDown: vi.fn(),
composeStatus: vi.fn().mockResolvedValue("stopped"),
reloadCaddy: (...args: unknown[]) => reloadCaddyMock(...args),
runMigrations: vi.fn(),
}));
/**
* Regression coverage for a real production bug: a business behind a
* reverse proxy redirected every post-sign-in navigation to
* http://localhost:3000 instead of its own domain, because AUTH_URL was
* never set and Auth.js's header-based host detection didn't behave as
* expected in that deployment. The fix makes AUTH_URL explicit at
* provisioning time (see compose-template.test.ts) — this file covers
* the other place a business's domain gets set: editing it later, where
* AUTH_URL must keep tracking DOMAIN exactly like NEXT_PUBLIC_APP_URL
* already did, or a business that changes domains would silently regress
* back into the same bug.
*/
describe("updateBusiness — AUTH_URL tracks domain changes", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
const originalMysqlHost = process.env.MYSQL_HOST;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-updatebiz-test-"));
process.env.STACKS_DIR = stacksDir;
// updateBusiness's own domain-availability check just scans other
// stacks' .env files via listBusinessStacks() — no real MySQL needed
// for this test since we never change the domain to something
// already in use.
composeUpMock.mockClear();
reloadCaddyMock.mockClear();
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
if (originalMysqlHost === undefined) delete process.env.MYSQL_HOST;
else process.env.MYSQL_HOST = originalMysqlHost;
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("updates AUTH_URL to the new domain, alongside DOMAIN and NEXT_PUBLIC_APP_URL", async () => {
const { updateBusiness } = await import("../provisioning");
await createStack(
"acme",
[
'BUSINESS_NAME="Acme"',
'DOMAIN="old-domain.example.com"',
'AUTH_URL="https://old-domain.example.com"',
'NEXT_PUBLIC_APP_URL="https://old-domain.example.com"',
'AUTH_SECRET="unrelated-secret"',
].join("\n") + "\n",
);
const result = await updateBusiness({ slug: "acme", name: "Acme", domain: "new-domain.example.com" });
expect(result.restarted).toBe(true);
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(envAfter).toContain('DOMAIN="new-domain.example.com"');
expect(envAfter).toContain('AUTH_URL="https://new-domain.example.com"');
expect(envAfter).toContain('NEXT_PUBLIC_APP_URL="https://new-domain.example.com"');
expect(envAfter).not.toContain("old-domain.example.com");
// Unrelated secrets untouched.
expect(envAfter).toContain('AUTH_SECRET="unrelated-secret"');
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), { forceRecreate: true });
});
it("leaves AUTH_URL alone (no restart) when only the business name changes, not the domain", async () => {
const { updateBusiness } = await import("../provisioning");
await createStack(
"acme",
['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'AUTH_URL="https://acme.example.com"'].join("\n") + "\n",
);
const result = await updateBusiness({ slug: "acme", name: "Acme Barbers Ltd", domain: "acme.example.com" });
expect(result.restarted).toBe(false);
expect(composeUpMock).not.toHaveBeenCalled();
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(envAfter).toContain('AUTH_URL="https://acme.example.com"');
expect(envAfter).toContain('BUSINESS_NAME="Acme Barbers Ltd"');
});
});