mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-18 11:32:15 -05:00
test(demo): validate migrated 1.6 SCIM and SSO workflows (#10986)
This commit is contained in:
@@ -19,6 +19,9 @@ SCIM_DEMO_OIDC_CLIENT_SECRET=
|
||||
SCIM_DEMO_OIDC_SIGNING_PRIVATE_KEY=
|
||||
# Optional local SQLite database path used by the demo and its end-to-end tests.
|
||||
DEMO_SQLITE_PATH=
|
||||
# Keep this empty only for v1.7 issuer compatibility mode. Use provider-id for
|
||||
# a new database or one migrated from v1.6 with provider-id identity.
|
||||
DEMO_ACCOUNT_IDENTITY_STRATEGY=
|
||||
TURSO_DATABASE_URL=
|
||||
TURSO_AUTH_TOKEN=
|
||||
APPLE_CLIENT_ID=
|
||||
|
||||
@@ -38,6 +38,15 @@ import { getSCIMDemoOIDCProvider } from "./scim-demo-oidc.ts";
|
||||
|
||||
const DEMO_SQLITE_CONTENTION_TIMEOUT_MS = 5_000;
|
||||
|
||||
const demoAccountIdentityStrategy = (() => {
|
||||
const strategy = process.env.DEMO_ACCOUNT_IDENTITY_STRATEGY;
|
||||
if (!strategy) return undefined;
|
||||
if (strategy === "issuer" || strategy === "provider-id") return strategy;
|
||||
throw new Error(
|
||||
'DEMO_ACCOUNT_IDENTITY_STRATEGY must be either "issuer" or "provider-id"',
|
||||
);
|
||||
})();
|
||||
|
||||
const database = (() => {
|
||||
if (process.env.DEMO_SQLITE_PATH) {
|
||||
return {
|
||||
@@ -129,6 +138,9 @@ const authOptions = {
|
||||
},
|
||||
},
|
||||
account: {
|
||||
...(demoAccountIdentityStrategy && {
|
||||
identityStrategy: demoAccountIdentityStrategy,
|
||||
}),
|
||||
accountLinking: {
|
||||
trustedProviders: [
|
||||
"email-password",
|
||||
|
||||
@@ -7,11 +7,15 @@ import { join, resolve } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import type { APIRequestContext, BrowserContext, Page } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { MigratedPublished16Database } from "../published-1-6-migration";
|
||||
import { prepareMigratedPublished16Database } from "../published-1-6-migration";
|
||||
|
||||
const USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
|
||||
const GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group";
|
||||
const PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
|
||||
const demoDirectory = resolve(process.cwd(), "../../demo/nextjs");
|
||||
const demoAuthSecret =
|
||||
"better-auth-scim-demo-e2e-secret-at-least-thirty-two-characters";
|
||||
|
||||
interface NextDemoRuntime {
|
||||
baseURL: string;
|
||||
@@ -21,6 +25,11 @@ interface NextDemoRuntime {
|
||||
temporaryDirectory: string;
|
||||
}
|
||||
|
||||
interface NextDemoRuntimeOptions {
|
||||
accountIdentityStrategy?: "issuer" | "provider-id";
|
||||
prepareDatabase?: (databasePath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
const server = createServer();
|
||||
const port = await new Promise<number>((resolvePort, reject) => {
|
||||
@@ -48,18 +57,26 @@ async function stopProcess(
|
||||
await terminate(child.pid);
|
||||
}
|
||||
|
||||
async function startNextDemoRuntime(): Promise<NextDemoRuntime> {
|
||||
async function startNextDemoRuntime(
|
||||
options: NextDemoRuntimeOptions = {},
|
||||
): Promise<NextDemoRuntime> {
|
||||
const temporaryDirectory = await mkdtemp(
|
||||
join(tmpdir(), "better-auth-scim-managed-demo-"),
|
||||
);
|
||||
const port = await getAvailablePort();
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
const databasePath = join(temporaryDirectory, "demo.sqlite");
|
||||
try {
|
||||
await options.prepareDatabase?.(databasePath);
|
||||
} catch (error) {
|
||||
await rm(temporaryDirectory, { force: true, recursive: true });
|
||||
throw error;
|
||||
}
|
||||
const environment: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
BETTER_AUTH_SECRET:
|
||||
"better-auth-scim-demo-e2e-secret-at-least-thirty-two-characters",
|
||||
BETTER_AUTH_SECRET: demoAuthSecret,
|
||||
BETTER_AUTH_URL: baseURL,
|
||||
DEMO_ACCOUNT_IDENTITY_STRATEGY: options.accountIdentityStrategy,
|
||||
DEMO_SQLITE_PATH: databasePath,
|
||||
NO_COLOR: "1",
|
||||
SCIM_DEMO_CREDENTIAL_PEPPER:
|
||||
@@ -167,6 +184,23 @@ async function signUp(
|
||||
expect(response.ok(), await response.text()).toBe(true);
|
||||
}
|
||||
|
||||
async function signIn(
|
||||
context: BrowserContext,
|
||||
baseURL: string,
|
||||
email: string,
|
||||
): Promise<void> {
|
||||
const response = await context.request.post(
|
||||
`${baseURL}/api/auth/sign-in/email`,
|
||||
{
|
||||
data: {
|
||||
email,
|
||||
password: "correct-horse-battery-staple",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(response.ok(), await response.text()).toBe(true);
|
||||
}
|
||||
|
||||
async function createOrganization(
|
||||
request: APIRequestContext,
|
||||
baseURL: string,
|
||||
@@ -199,6 +233,32 @@ function managementURL(baseURL: string, organizationId: string): string {
|
||||
return `${baseURL}/api/scim-demo/organizations/${organizationId}/connections`;
|
||||
}
|
||||
|
||||
async function createEmployeeLink(
|
||||
request: APIRequestContext,
|
||||
baseURL: string,
|
||||
organizationId: string,
|
||||
scimUserId: string,
|
||||
): Promise<string> {
|
||||
const response = await request.post(
|
||||
`${managementURL(baseURL, organizationId)}/employee-links`,
|
||||
{
|
||||
headers: { origin: baseURL },
|
||||
data: { organizationId, scimUserId },
|
||||
},
|
||||
);
|
||||
expect(response.status(), await response.text()).toBe(201);
|
||||
const body: unknown = await response.json();
|
||||
if (
|
||||
typeof body !== "object" ||
|
||||
body === null ||
|
||||
!("url" in body) ||
|
||||
typeof body.url !== "string"
|
||||
) {
|
||||
throw new Error("Employee link response did not return a URL");
|
||||
}
|
||||
return body.url;
|
||||
}
|
||||
|
||||
async function readIssuedCredential(response: {
|
||||
json(): Promise<unknown>;
|
||||
}): Promise<{ id: string; token: string }> {
|
||||
@@ -328,11 +388,20 @@ async function completeEmployeeSignIn(page: Page): Promise<void> {
|
||||
page.getByRole("heading", { name: "Sign in with Acme Identity" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue as Maya" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "You’re signed in" }),
|
||||
).toBeVisible();
|
||||
try {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "You’re signed in" }),
|
||||
).toBeVisible();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Employee SSO did not complete at ${page.url()}:\n${await page.locator("body").innerText()}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.describe("Next.js managed SCIM catalog demo", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
test.setTimeout(120_000);
|
||||
@@ -898,33 +967,16 @@ test.describe("Next.js managed SCIM catalog demo", () => {
|
||||
database.close();
|
||||
}
|
||||
|
||||
const employeeLinkResponse = await page.request.post(
|
||||
`${managementURL(runtime.baseURL, organizationId)}/employee-links`,
|
||||
{
|
||||
headers: { origin: runtime.baseURL },
|
||||
data: {
|
||||
organizationId,
|
||||
scimUserId: maya.id,
|
||||
},
|
||||
},
|
||||
const employeeLinkURL = await createEmployeeLink(
|
||||
page.request,
|
||||
runtime.baseURL,
|
||||
organizationId,
|
||||
String(maya.id),
|
||||
);
|
||||
expect(
|
||||
employeeLinkResponse.status(),
|
||||
await employeeLinkResponse.text(),
|
||||
).toBe(201);
|
||||
const employeeLinkBody: unknown = await employeeLinkResponse.json();
|
||||
if (
|
||||
typeof employeeLinkBody !== "object" ||
|
||||
employeeLinkBody === null ||
|
||||
!("url" in employeeLinkBody) ||
|
||||
typeof employeeLinkBody.url !== "string"
|
||||
) {
|
||||
throw new Error("Employee link response did not return a URL");
|
||||
}
|
||||
const employeeContext = await browser.newContext();
|
||||
try {
|
||||
const employeePage = await employeeContext.newPage();
|
||||
await employeePage.goto(employeeLinkBody.url);
|
||||
await employeePage.goto(employeeLinkURL);
|
||||
await completeEmployeeSignIn(employeePage);
|
||||
} finally {
|
||||
await employeeContext.close();
|
||||
@@ -936,13 +988,14 @@ test.describe("Next.js managed SCIM catalog demo", () => {
|
||||
try {
|
||||
const accounts = accountDatabase
|
||||
.prepare(
|
||||
`SELECT "providerId", "accountId", "userId"
|
||||
`SELECT "issuer", "providerId", "accountId", "userId"
|
||||
FROM "account"
|
||||
WHERE "providerId" = 'scim-demo-sso'`,
|
||||
)
|
||||
.all();
|
||||
expect(accounts).toEqual([
|
||||
{
|
||||
issuer: `${runtime.baseURL}/api/scim-demo/idp`,
|
||||
providerId: "scim-demo-sso",
|
||||
accountId: maya.externalId,
|
||||
userId: maya.userId,
|
||||
@@ -962,3 +1015,252 @@ test.describe("Next.js managed SCIM catalog demo", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Next.js SCIM and SSO demo on a migrated published 1.6 database", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
test.setTimeout(180_000);
|
||||
|
||||
let migration: MigratedPublished16Database | undefined;
|
||||
let runtime: NextDemoRuntime | undefined;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
runtime = await startNextDemoRuntime({
|
||||
accountIdentityStrategy: "provider-id",
|
||||
prepareDatabase: async (databasePath) => {
|
||||
migration = await prepareMigratedPublished16Database(
|
||||
databasePath,
|
||||
demoAuthSecret,
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await stopProcess(runtime?.process);
|
||||
if (runtime) {
|
||||
await rm(runtime.temporaryDirectory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("runs the real SCIM lifecycle and repeated OIDC SSO sign-in without re-keying migrated identities", async ({
|
||||
browser,
|
||||
page,
|
||||
}) => {
|
||||
if (!runtime || !migration) {
|
||||
throw new Error("The migrated Next.js demo runtime was not prepared");
|
||||
}
|
||||
|
||||
await signIn(
|
||||
page.context(),
|
||||
runtime.baseURL,
|
||||
"administrator@migration.example.com",
|
||||
);
|
||||
const organizationId = await createOrganization(
|
||||
page.request,
|
||||
runtime.baseURL,
|
||||
"Migrated SCIM Demo",
|
||||
);
|
||||
const provisioningDomainId = `scim-demo-org:${organizationId}`;
|
||||
|
||||
await page.goto(`${runtime.baseURL}/dashboard/scim`);
|
||||
const createResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url() === managementURL(runtime.baseURL, organizationId) &&
|
||||
response.request().method() === "POST",
|
||||
);
|
||||
await page.getByRole("button", { name: "Create SCIM connection" }).click();
|
||||
const createResponse = await createResponsePromise;
|
||||
expect(createResponse.status()).toBe(201);
|
||||
const originalCredential = await readIssuedCredential(createResponse);
|
||||
|
||||
await page.getByRole("button", { name: "Run local recipe" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Recipe complete" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("entra-local-recipe-steps")
|
||||
.locator('[data-step-status="passed"]'),
|
||||
).toHaveCount(10);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Users (2)" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Groups (1)" }),
|
||||
).toBeVisible();
|
||||
|
||||
const database = new DatabaseSync(runtime.databasePath, {
|
||||
readOnly: true,
|
||||
});
|
||||
let demoEmployee: {
|
||||
externalId: string;
|
||||
id: string;
|
||||
userId: string;
|
||||
};
|
||||
try {
|
||||
const employee = database
|
||||
.prepare(
|
||||
`SELECT "id", "userId", "externalId"
|
||||
FROM "scimUser"
|
||||
WHERE "provisioningDomainId" = ?
|
||||
AND "serializedAttributes" LIKE '%Finance Director%'`,
|
||||
)
|
||||
.get(provisioningDomainId);
|
||||
if (
|
||||
typeof employee?.id !== "string" ||
|
||||
typeof employee.userId !== "string" ||
|
||||
typeof employee.externalId !== "string"
|
||||
) {
|
||||
throw new Error("The migrated demo did not provision its SSO employee");
|
||||
}
|
||||
demoEmployee = {
|
||||
externalId: employee.externalId,
|
||||
id: employee.id,
|
||||
userId: employee.userId,
|
||||
};
|
||||
expect(
|
||||
database
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS "count"
|
||||
FROM "account"
|
||||
WHERE "providerId" = 'scim-demo-sso'`,
|
||||
)
|
||||
.get(),
|
||||
).toEqual({ count: 0 });
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
for (let signInAttempt = 0; signInAttempt < 2; signInAttempt += 1) {
|
||||
const employeeLink = await createEmployeeLink(
|
||||
page.request,
|
||||
runtime.baseURL,
|
||||
organizationId,
|
||||
demoEmployee.id,
|
||||
);
|
||||
const employeeContext = await browser.newContext();
|
||||
try {
|
||||
const employeePage = await employeeContext.newPage();
|
||||
await employeePage.goto(employeeLink);
|
||||
await completeEmployeeSignIn(employeePage);
|
||||
} finally {
|
||||
await employeeContext.close();
|
||||
}
|
||||
}
|
||||
|
||||
const migratedDatabase = new DatabaseSync(runtime.databasePath, {
|
||||
readOnly: true,
|
||||
});
|
||||
try {
|
||||
const accounts = migratedDatabase
|
||||
.prepare(
|
||||
`SELECT "issuer", "providerId", "accountId", "userId"
|
||||
FROM "account"
|
||||
WHERE "providerId" IN (
|
||||
'credential',
|
||||
'scim-demo-sso',
|
||||
'workforce-scim',
|
||||
'workforce-sso'
|
||||
)
|
||||
ORDER BY "providerId", "accountId"`,
|
||||
)
|
||||
.all();
|
||||
expect(accounts).toEqual([
|
||||
expect.objectContaining({
|
||||
issuer: "local:credential",
|
||||
providerId: "credential",
|
||||
userId: migration.source.administratorUserId,
|
||||
}),
|
||||
{
|
||||
issuer: "local:oauth:scim-demo-sso",
|
||||
providerId: "scim-demo-sso",
|
||||
accountId: demoEmployee.externalId,
|
||||
userId: demoEmployee.userId,
|
||||
},
|
||||
{
|
||||
issuer: "local:oauth:workforce-sso",
|
||||
providerId: "workforce-sso",
|
||||
accountId: migration.source.directorySubject,
|
||||
userId: migration.verified.ssoUserId,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
migratedDatabase
|
||||
.prepare(
|
||||
`SELECT "userId"
|
||||
FROM "scimUser"
|
||||
WHERE "id" = ?`,
|
||||
)
|
||||
.get(migration.verified.reprovisionedSCIMUserId),
|
||||
).toEqual({ userId: migration.verified.scimUserId });
|
||||
} finally {
|
||||
migratedDatabase.close();
|
||||
}
|
||||
|
||||
const rotateResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url() ===
|
||||
`${managementURL(runtime.baseURL, organizationId)}/rotate` &&
|
||||
response.request().method() === "POST",
|
||||
);
|
||||
await page.getByRole("button", { name: "Rotate credential" }).click();
|
||||
const rotateResponse = await rotateResponsePromise;
|
||||
expect(rotateResponse.status()).toBe(201);
|
||||
const rotatedCredential = await readIssuedCredential(rotateResponse);
|
||||
expect(rotatedCredential.token).not.toBe(originalCredential.token);
|
||||
|
||||
const revokeResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url() ===
|
||||
`${managementURL(
|
||||
runtime.baseURL,
|
||||
organizationId,
|
||||
)}/credentials/${originalCredential.id}/revoke` &&
|
||||
response.request().method() === "POST",
|
||||
);
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: `Revoke credential ${originalCredential.id}`,
|
||||
})
|
||||
.click();
|
||||
expect((await revokeResponsePromise).status()).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await scimRequest(
|
||||
page.request,
|
||||
runtime.baseURL,
|
||||
originalCredential.token,
|
||||
{ method: "GET", path: "/Users" },
|
||||
)
|
||||
).status(),
|
||||
).toBe(401);
|
||||
expect(
|
||||
(
|
||||
await scimRequest(
|
||||
page.request,
|
||||
runtime.baseURL,
|
||||
rotatedCredential.token,
|
||||
{ method: "GET", path: "/Users" },
|
||||
)
|
||||
).status(),
|
||||
).toBe(200);
|
||||
|
||||
const decommissionResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url() ===
|
||||
`${managementURL(runtime.baseURL, organizationId)}/decommission` &&
|
||||
response.request().method() === "POST",
|
||||
);
|
||||
await page.getByRole("button", { name: "Decommission connection" }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "Decommission permanently" })
|
||||
.click();
|
||||
expect((await decommissionResponsePromise).status()).toBe(200);
|
||||
await expect(
|
||||
page.getByText("Connection permanently decommissioned"),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
interface CommandResult {
|
||||
exitCode: number | null;
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
}
|
||||
|
||||
interface PublishedAccount {
|
||||
accountId: string;
|
||||
id: string;
|
||||
providerId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface Published16Result {
|
||||
accounts: PublishedAccount[];
|
||||
administratorUserId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
directorySubject: string;
|
||||
scimAccountId: string;
|
||||
tableCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
interface GuidedVerificationResult {
|
||||
credentialUserId: string;
|
||||
reprovisionedSCIMUserId: string;
|
||||
scimUserId: string;
|
||||
ssoUserId: string;
|
||||
}
|
||||
|
||||
export interface MigratedPublished16Database {
|
||||
source: Published16Result;
|
||||
verified: GuidedVerificationResult;
|
||||
}
|
||||
|
||||
const workspaceRootDirectory = resolve(process.cwd(), "../..");
|
||||
const migrationFixtureDirectory = join(
|
||||
workspaceRootDirectory,
|
||||
"e2e/adapter/test/kysely-adapter",
|
||||
);
|
||||
const published16Directory = join(
|
||||
migrationFixtureDirectory,
|
||||
"published-1-6-app",
|
||||
);
|
||||
const published16Seed = join(published16Directory, "seed.mjs");
|
||||
const guidedConfig = join(migrationFixtureDirectory, "guided-auth.mjs");
|
||||
const guidedVerifier = join(
|
||||
migrationFixtureDirectory,
|
||||
"verify-guided-migration.mjs",
|
||||
);
|
||||
const cliEntry = join(workspaceRootDirectory, "packages/cli/dist/index.mjs");
|
||||
const commandTimeoutMs = 60_000;
|
||||
|
||||
function redactCommandOutput(output: string): string {
|
||||
return output
|
||||
.replace(
|
||||
/^PUBLISHED_FIXTURE_RESULT=.*$/gm,
|
||||
"PUBLISHED_FIXTURE_RESULT=[redacted]",
|
||||
)
|
||||
.replace(
|
||||
/^GUIDED_MIGRATION_RESULT=.*$/gm,
|
||||
"GUIDED_MIGRATION_RESULT=[redacted]",
|
||||
);
|
||||
}
|
||||
|
||||
function runNode(
|
||||
cwd: string,
|
||||
args: string[],
|
||||
environment: NodeJS.ProcessEnv = {},
|
||||
): Promise<CommandResult> {
|
||||
return new Promise((resolveCommand, rejectCommand) => {
|
||||
const child = spawn(process.execPath, args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...environment, NO_COLOR: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
const settle = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
callback();
|
||||
};
|
||||
timeout = setTimeout(() => {
|
||||
child.kill();
|
||||
settle(() =>
|
||||
rejectCommand(
|
||||
new Error(
|
||||
`Subprocess timed out after ${commandTimeoutMs}ms:\n${redactCommandOutput(stdout)}\n${redactCommandOutput(stderr)}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
}, commandTimeoutMs);
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.once("error", (error) => {
|
||||
settle(() => rejectCommand(error));
|
||||
});
|
||||
child.once("close", (exitCode) => {
|
||||
settle(() => resolveCommand({ exitCode, stderr, stdout }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function requireSuccessfulCommand(
|
||||
result: CommandResult,
|
||||
context: string,
|
||||
): void {
|
||||
if (result.exitCode === 0) return;
|
||||
throw new Error(
|
||||
`${context} failed with exit code ${result.exitCode}:\n${redactCommandOutput(result.stdout)}\n${redactCommandOutput(result.stderr)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function readSentinelResult<Result>(stdout: string, sentinel: string): Result {
|
||||
const line = stdout
|
||||
.split("\n")
|
||||
.find((candidate) => candidate.startsWith(`${sentinel}=`));
|
||||
if (!line) {
|
||||
throw new Error(
|
||||
`Missing ${sentinel} in subprocess output:\n${redactCommandOutput(stdout)}`,
|
||||
);
|
||||
}
|
||||
return JSON.parse(line.slice(sentinel.length + 1)) as Result;
|
||||
}
|
||||
|
||||
async function requireFixtureFiles(): Promise<void> {
|
||||
for (const requiredFile of [
|
||||
cliEntry,
|
||||
published16Seed,
|
||||
guidedConfig,
|
||||
guidedVerifier,
|
||||
]) {
|
||||
if (!existsSync(requiredFile)) {
|
||||
throw new Error(`Missing published migration fixture: ${requiredFile}`);
|
||||
}
|
||||
}
|
||||
const packageMetadata = JSON.parse(
|
||||
await readFile(
|
||||
join(published16Directory, "node_modules/better-auth/package.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as { version?: string };
|
||||
if (packageMetadata.version !== "1.6.30") {
|
||||
throw new Error(
|
||||
`Expected the published 1.6 fixture to use better-auth 1.6.30, received ${packageMetadata.version ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareMigratedPublished16Database(
|
||||
databasePath: string,
|
||||
authSecret: string,
|
||||
): Promise<MigratedPublished16Database> {
|
||||
await requireFixtureFiles();
|
||||
|
||||
const environment = {
|
||||
BETTER_AUTH_MIGRATION_DATABASE: databasePath,
|
||||
BETTER_AUTH_SECRET: authSecret,
|
||||
};
|
||||
const seed = await runNode(
|
||||
published16Directory,
|
||||
[published16Seed, databasePath],
|
||||
environment,
|
||||
);
|
||||
requireSuccessfulCommand(seed, "Published Better Auth 1.6 seed");
|
||||
const source = readSentinelResult<Published16Result>(
|
||||
seed.stdout,
|
||||
"PUBLISHED_FIXTURE_RESULT",
|
||||
);
|
||||
const sourceProviders = source.accounts.map(({ providerId }) => providerId);
|
||||
if (
|
||||
source.tableCounts.account !== 3 ||
|
||||
!sourceProviders.includes("credential") ||
|
||||
!sourceProviders.includes("workforce-scim") ||
|
||||
!sourceProviders.includes("workforce-sso")
|
||||
) {
|
||||
throw new Error(
|
||||
`Published Better Auth 1.6 did not create the required populated workflows: accountCount=${source.tableCounts.account ?? "unknown"}, providers=${sourceProviders.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const decisionsFile = join(
|
||||
dirname(databasePath),
|
||||
"better-auth-migration.json",
|
||||
);
|
||||
await writeFile(
|
||||
decisionsFile,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
formatVersion: 1,
|
||||
migration: "1.6-to-1.7",
|
||||
oauth: {
|
||||
clientSecrets: { source: "plain", target: "hashed" },
|
||||
consents: "migrate",
|
||||
},
|
||||
scim: { retireAccountIds: [source.scimAccountId] },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const plan = await runNode(
|
||||
migrationFixtureDirectory,
|
||||
[
|
||||
cliEntry,
|
||||
"migrate",
|
||||
"plan",
|
||||
decisionsFile,
|
||||
"--config",
|
||||
guidedConfig,
|
||||
"--json",
|
||||
],
|
||||
environment,
|
||||
);
|
||||
requireSuccessfulCommand(plan, "Guided Better Auth 1.7 migration plan");
|
||||
const parsedPlan = JSON.parse(plan.stdout) as {
|
||||
accountIdentity?: { selectedStrategy?: string };
|
||||
blockers?: unknown[];
|
||||
status?: string;
|
||||
};
|
||||
if (
|
||||
parsedPlan.status !== "ready" ||
|
||||
parsedPlan.blockers?.length !== 0 ||
|
||||
parsedPlan.accountIdentity?.selectedStrategy !== "provider-id"
|
||||
) {
|
||||
throw new Error(
|
||||
`Guided migration did not produce a ready provider-id plan: ${plan.stdout}`,
|
||||
);
|
||||
}
|
||||
|
||||
const apply = await runNode(
|
||||
migrationFixtureDirectory,
|
||||
[
|
||||
cliEntry,
|
||||
"migrate",
|
||||
"apply",
|
||||
decisionsFile,
|
||||
"--config",
|
||||
guidedConfig,
|
||||
"--yes",
|
||||
"--json",
|
||||
],
|
||||
environment,
|
||||
);
|
||||
requireSuccessfulCommand(apply, "Guided Better Auth 1.7 migration apply");
|
||||
const parsedApply = JSON.parse(apply.stdout) as { status?: string };
|
||||
if (parsedApply.status !== "applied") {
|
||||
throw new Error(`Guided migration was not applied: ${apply.stdout}`);
|
||||
}
|
||||
|
||||
const repeatedPlan = await runNode(
|
||||
migrationFixtureDirectory,
|
||||
[
|
||||
cliEntry,
|
||||
"migrate",
|
||||
"plan",
|
||||
decisionsFile,
|
||||
"--config",
|
||||
guidedConfig,
|
||||
"--json",
|
||||
],
|
||||
environment,
|
||||
);
|
||||
requireSuccessfulCommand(
|
||||
repeatedPlan,
|
||||
"Repeated Better Auth 1.7 migration plan",
|
||||
);
|
||||
const parsedRepeatedPlan = JSON.parse(repeatedPlan.stdout) as {
|
||||
blockers?: unknown[];
|
||||
changes?: {
|
||||
addColumns?: unknown[];
|
||||
addIndexes?: unknown[];
|
||||
createTables?: unknown[];
|
||||
};
|
||||
releaseMigration?: unknown;
|
||||
status?: string;
|
||||
};
|
||||
if (
|
||||
parsedRepeatedPlan.status !== "up-to-date" ||
|
||||
parsedRepeatedPlan.blockers?.length !== 0 ||
|
||||
parsedRepeatedPlan.changes?.addColumns?.length !== 0 ||
|
||||
parsedRepeatedPlan.changes?.addIndexes?.length !== 0 ||
|
||||
parsedRepeatedPlan.changes?.createTables?.length !== 0 ||
|
||||
parsedRepeatedPlan.releaseMigration !== undefined
|
||||
) {
|
||||
throw new Error(
|
||||
`Guided migration did not become up-to-date after apply: ${repeatedPlan.stdout}`,
|
||||
);
|
||||
}
|
||||
|
||||
const provisionedSourceAccount = source.accounts.find(
|
||||
({ providerId }) => providerId === "workforce-scim",
|
||||
);
|
||||
if (!provisionedSourceAccount) {
|
||||
throw new Error("Published Better Auth 1.6 did not create a SCIM account");
|
||||
}
|
||||
const verification = await runNode(
|
||||
migrationFixtureDirectory,
|
||||
[guidedVerifier, databasePath],
|
||||
{
|
||||
...environment,
|
||||
BETTER_AUTH_MIGRATION_CLIENT_ID: source.clientId,
|
||||
BETTER_AUTH_MIGRATION_CLIENT_SECRET: source.clientSecret,
|
||||
BETTER_AUTH_MIGRATION_SCIM_USER_ID: provisionedSourceAccount.userId,
|
||||
},
|
||||
);
|
||||
requireSuccessfulCommand(
|
||||
verification,
|
||||
"Migrated Better Auth 1.7 workflow verification",
|
||||
);
|
||||
const verified = readSentinelResult<GuidedVerificationResult>(
|
||||
verification.stdout,
|
||||
"GUIDED_MIGRATION_RESULT",
|
||||
);
|
||||
|
||||
return { source, verified };
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"@better-auth/scim": "workspace:*",
|
||||
"@better-auth/sso": "workspace:*",
|
||||
"@better-auth/stripe": "workspace:*",
|
||||
"auth": "workspace:*",
|
||||
"better-auth": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+3
@@ -802,6 +802,9 @@ importers:
|
||||
'@better-auth/stripe':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/stripe
|
||||
auth:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/cli
|
||||
better-auth:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/better-auth
|
||||
|
||||
Reference in New Issue
Block a user