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:
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"');
|
||||
});
|
||||
});
|
||||
@@ -134,6 +134,14 @@ DATABASE_USER="${values.databaseUser}"
|
||||
DATABASE_PASSWORD="${values.databasePassword}"
|
||||
AUTH_SECRET="${values.authSecret}"
|
||||
AUTH_TRUST_HOST="true"
|
||||
# Explicit, not just inferred from AUTH_TRUST_HOST + request headers —
|
||||
# header-based detection (Host/X-Forwarded-Host) is exactly the kind of
|
||||
# thing that behaves inconsistently across reverse-proxy setups, and a
|
||||
# wrong guess here sends every post-sign-in/post-install redirect to the
|
||||
# wrong origin (seen for real: a business behind Caddy redirecting to
|
||||
# http://localhost:3000 instead of its own domain). Auth.js's own docs
|
||||
# recommend setting this explicitly for exactly this reason.
|
||||
AUTH_URL="https://${values.domain}"
|
||||
NEXT_PUBLIC_APP_URL="https://${values.domain}"
|
||||
INSTALLATION_TOKEN="${values.installationToken}"
|
||||
CRON_SECRET="${values.cronSecret}"
|
||||
|
||||
@@ -254,14 +254,16 @@ export async function updateBusiness(input: UpdateBusinessInput): Promise<Update
|
||||
const currentDomain = readEnvValue(currentEnv, "DOMAIN");
|
||||
const domainChanged = currentDomain !== domain;
|
||||
|
||||
// Only these four keys are ever touched — everything else in the file
|
||||
// Only these five keys are ever touched — everything else in the file
|
||||
// (secrets, integrations, anything hand-edited) is left byte-for-byte
|
||||
// alone. NEXT_PUBLIC_APP_URL/GOOGLE_CALENDAR_REDIRECT_URI are derived
|
||||
// from the domain and must track it exactly, or the app would serve
|
||||
// absolute URLs and OAuth callbacks pointed at the old domain.
|
||||
// alone. AUTH_URL/NEXT_PUBLIC_APP_URL/GOOGLE_CALENDAR_REDIRECT_URI are
|
||||
// derived from the domain and must track it exactly, or the app would
|
||||
// serve absolute URLs, OAuth callbacks, and post-sign-in redirects
|
||||
// pointed at the old domain.
|
||||
await upsertEnvValues(envPath, {
|
||||
BUSINESS_NAME: name,
|
||||
DOMAIN: domain,
|
||||
AUTH_URL: `https://${domain}`,
|
||||
NEXT_PUBLIC_APP_URL: `https://${domain}`,
|
||||
GOOGLE_CALENDAR_REDIRECT_URI: `https://${domain}/api/staff/calendar/google/callback`,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user