mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
2426 lines
68 KiB
TypeScript
2426 lines
68 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { BetterAuthOptions, BetterAuthPlugin } from "@better-auth/core";
|
|
import { oauthProvider } from "@better-auth/oauth-provider";
|
|
import { scim } from "@better-auth/scim";
|
|
import { betterAuth } from "better-auth";
|
|
import * as releaseMigration from "better-auth/db/migration";
|
|
import {
|
|
getMigrations,
|
|
validateMigrationFrom16,
|
|
} from "better-auth/db/migration";
|
|
import { jwt, organization } from "better-auth/plugins";
|
|
import { betterAuth as betterAuth1630 } from "better-auth-1-6-30";
|
|
import { getMigrations as getMigrations1630 } from "better-auth-1-6-30/db/migration";
|
|
import {
|
|
oidcProvider as oidcProvider1630,
|
|
organization as organization1630,
|
|
} from "better-auth-1-6-30/plugins";
|
|
import { scim as scim1630 } from "better-auth-scim-1-6-30";
|
|
import Database from "better-sqlite3";
|
|
import prompts from "prompts";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { Spinner } from "yocto-spinner";
|
|
import yoctoSpinner from "yocto-spinner";
|
|
import {
|
|
createMigrateCommand,
|
|
migrateAction,
|
|
printHumanMigrationPlan,
|
|
} from "../src/commands/migrate";
|
|
import { createMigrationPlan } from "../src/commands/migration-plan";
|
|
import * as config from "../src/utils/get-config";
|
|
|
|
vi.mock("prompts", () => ({
|
|
default: vi.fn(),
|
|
}));
|
|
vi.mock(import("yocto-spinner"), () => ({
|
|
default: vi.fn(() => {
|
|
const spinner: Spinner = {
|
|
text: "",
|
|
color: "cyan",
|
|
start: vi.fn(() => spinner),
|
|
stop: vi.fn(() => spinner),
|
|
success: vi.fn(() => spinner),
|
|
error: vi.fn(() => spinner),
|
|
warning: vi.fn(() => spinner),
|
|
info: vi.fn(() => spinner),
|
|
clear: vi.fn(() => spinner),
|
|
get isSpinning() {
|
|
return false;
|
|
},
|
|
};
|
|
return spinner;
|
|
}),
|
|
}));
|
|
|
|
function backfill1630CredentialAccountIdentity(db: Database.Database) {
|
|
db.exec(`
|
|
ALTER TABLE account ADD COLUMN issuer TEXT;
|
|
UPDATE account
|
|
SET issuer = 'local:' || providerId
|
|
WHERE providerId = 'credential';
|
|
`);
|
|
}
|
|
|
|
function enforce1630CredentialAccountIdentityConstraints(
|
|
db: Database.Database,
|
|
) {
|
|
db.exec(`
|
|
PRAGMA foreign_keys = OFF;
|
|
CREATE TABLE accountWithIdentity (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
issuer TEXT NOT NULL,
|
|
accountId TEXT NOT NULL,
|
|
providerId TEXT NOT NULL,
|
|
userId TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
|
|
accessToken TEXT,
|
|
refreshToken TEXT,
|
|
idToken TEXT,
|
|
accessTokenExpiresAt INTEGER,
|
|
refreshTokenExpiresAt INTEGER,
|
|
scope TEXT,
|
|
password TEXT,
|
|
createdAt INTEGER NOT NULL,
|
|
updatedAt INTEGER NOT NULL
|
|
);
|
|
INSERT INTO accountWithIdentity (
|
|
id,
|
|
issuer,
|
|
accountId,
|
|
providerId,
|
|
userId,
|
|
accessToken,
|
|
refreshToken,
|
|
idToken,
|
|
accessTokenExpiresAt,
|
|
refreshTokenExpiresAt,
|
|
scope,
|
|
password,
|
|
createdAt,
|
|
updatedAt
|
|
)
|
|
SELECT
|
|
id,
|
|
issuer,
|
|
accountId,
|
|
providerId,
|
|
userId,
|
|
accessToken,
|
|
refreshToken,
|
|
idToken,
|
|
accessTokenExpiresAt,
|
|
refreshTokenExpiresAt,
|
|
scope,
|
|
password,
|
|
createdAt,
|
|
updatedAt
|
|
FROM account;
|
|
DROP TABLE account;
|
|
ALTER TABLE accountWithIdentity RENAME TO account;
|
|
PRAGMA foreign_keys = ON;
|
|
`);
|
|
}
|
|
|
|
describe("migrate base auth instance", () => {
|
|
const db = new Database(":memory:");
|
|
|
|
const auth = betterAuth({
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.spyOn(process, "exit").mockImplementation((code) => {
|
|
return code as never;
|
|
});
|
|
vi.spyOn(config, "getConfig").mockImplementation(async () => auth.options);
|
|
});
|
|
|
|
it("should migrate the database and sign-up a user", async () => {
|
|
await migrateAction({
|
|
approved: true,
|
|
cwd: process.cwd(),
|
|
config: "test/auth.ts",
|
|
mode: "apply",
|
|
});
|
|
const signUpRes = await auth.api.signUpEmail({
|
|
body: {
|
|
name: "test",
|
|
email: "test@email.com",
|
|
password: "password",
|
|
},
|
|
});
|
|
expect(signUpRes.token).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("migrate auth instance with plugins", () => {
|
|
const db = new Database(":memory:");
|
|
const testPlugin = {
|
|
id: "plugin",
|
|
schema: {
|
|
plugin: {
|
|
fields: {
|
|
test: {
|
|
type: "string",
|
|
fieldName: "test",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} satisfies BetterAuthPlugin;
|
|
|
|
const auth = betterAuth({
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
plugins: [testPlugin],
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.spyOn(process, "exit").mockImplementation((code) => {
|
|
return code as never;
|
|
});
|
|
vi.spyOn(config, "getConfig").mockImplementation(async () => auth.options);
|
|
});
|
|
|
|
it("should migrate the database and sign-up a user", async () => {
|
|
await migrateAction({
|
|
approved: true,
|
|
cwd: process.cwd(),
|
|
config: "test/auth.ts",
|
|
mode: "apply",
|
|
});
|
|
const res = db
|
|
.prepare("INSERT INTO plugin (id, test) VALUES (?, ?)")
|
|
.run("1", "test");
|
|
expect(res.changes).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("migrate an index-only schema change", () => {
|
|
const db = new Database(":memory:");
|
|
const basePlugin = {
|
|
id: "directory",
|
|
schema: {
|
|
directoryUser: {
|
|
fields: {
|
|
connectionId: { type: "string" },
|
|
externalId: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
} satisfies BetterAuthPlugin;
|
|
const indexedPlugin = {
|
|
...basePlugin,
|
|
schema: {
|
|
directoryUser: {
|
|
...basePlugin.schema.directoryUser,
|
|
indexes: [
|
|
{
|
|
fields: ["connectionId", "externalId"],
|
|
unique: true,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
} satisfies BetterAuthPlugin;
|
|
let options = betterAuth({ database: db, plugins: [basePlugin] }).options;
|
|
|
|
beforeEach(() => {
|
|
vi.spyOn(process, "exit").mockImplementation((code) => code as never);
|
|
vi.spyOn(config, "getConfig").mockImplementation(async () => options);
|
|
});
|
|
|
|
it("runs when only a compound index is missing", async () => {
|
|
await migrateAction({
|
|
approved: true,
|
|
cwd: process.cwd(),
|
|
mode: "apply",
|
|
});
|
|
options = betterAuth({ database: db, plugins: [indexedPlugin] }).options;
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
await migrateAction({
|
|
approved: true,
|
|
cwd: process.cwd(),
|
|
mode: "apply",
|
|
});
|
|
|
|
expect(consoleLog).not.toHaveBeenCalledWith("🚀 No migrations needed.");
|
|
db.prepare(
|
|
"INSERT INTO directoryUser (id, connectionId, externalId) VALUES (?, ?, ?)",
|
|
).run("du1", "okta", "employee-1");
|
|
expect(() =>
|
|
db
|
|
.prepare(
|
|
"INSERT INTO directoryUser (id, connectionId, externalId) VALUES (?, ?, ?)",
|
|
)
|
|
.run("du2", "okta", "employee-1"),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("inspect a migration without applying it", () => {
|
|
it("shows a human-readable dry run and leaves the database unchanged", async () => {
|
|
const db = new Database(":memory:");
|
|
const auth = betterAuth({ database: db });
|
|
vi.spyOn(config, "getConfig").mockImplementation(async () => auth.options);
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
await migrateAction({ cwd: process.cwd(), mode: "plan" });
|
|
|
|
expect(consoleLog).toHaveBeenCalledWith("Target: kysely/sqlite");
|
|
expect(consoleLog).toHaveBeenCalledWith("Blockers: none");
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
"No database changes were applied.",
|
|
);
|
|
expect(
|
|
db
|
|
.prepare(
|
|
"SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table'",
|
|
)
|
|
.get(),
|
|
).toEqual({ count: 0 });
|
|
});
|
|
|
|
it("prints a deterministic JSON plan and leaves the database unchanged", async () => {
|
|
const db = new Database(":memory:");
|
|
const auth = betterAuth({ database: db });
|
|
vi.spyOn(config, "getConfig").mockImplementation(async () => auth.options);
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
await migrateAction({
|
|
cwd: process.cwd(),
|
|
mode: "plan",
|
|
outputFormat: "json",
|
|
});
|
|
|
|
expect(consoleLog).toHaveBeenCalledTimes(1);
|
|
const migrationPlan = JSON.parse(String(consoleLog.mock.calls[0]?.[0])) as {
|
|
changes: {
|
|
createTables: Array<{ table: string }>;
|
|
};
|
|
formatVersion: number;
|
|
status: string;
|
|
target: { adapter: string; dialect: string };
|
|
};
|
|
expect(migrationPlan).toMatchObject({
|
|
formatVersion: 1,
|
|
status: "ready",
|
|
target: {
|
|
adapter: "kysely",
|
|
dialect: "sqlite",
|
|
},
|
|
});
|
|
expect(
|
|
migrationPlan.changes.createTables.map(({ table }) => table),
|
|
).toEqual(["account", "session", "user", "verification"]);
|
|
expect(
|
|
db
|
|
.prepare(
|
|
"SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table'",
|
|
)
|
|
.get(),
|
|
).toEqual({ count: 0 });
|
|
});
|
|
});
|
|
|
|
describe("migration plan status", () => {
|
|
/**
|
|
* @see https://github.com/better-auth/better-auth/pull/10575#discussion_r3830648973
|
|
*/
|
|
it("reports pending release work when the schema is already current", () => {
|
|
const plan = createMigrationPlan({
|
|
accountIdentity: {
|
|
selectedStrategy: "provider-id",
|
|
detectedStrategy: "provider-id",
|
|
migrationRequired: false,
|
|
requiresRekey: false,
|
|
},
|
|
hasChanges: false,
|
|
migrationBlockers: [],
|
|
migrationTarget: { adapter: "kysely", dialect: "sqlite" },
|
|
releaseMigration: {
|
|
actions: ["write the 1.7 account identity"],
|
|
id: "1.6-to-1.7",
|
|
},
|
|
toBeAdded: [],
|
|
toBeAddedIndexes: [],
|
|
toBeCreated: [],
|
|
});
|
|
|
|
expect(plan.status).toBe("ready");
|
|
});
|
|
|
|
it("prints blocker details and remediation in the human plan", () => {
|
|
const plan = createMigrationPlan({
|
|
accountIdentity: {
|
|
selectedStrategy: "issuer",
|
|
detectedStrategy: "provider-id",
|
|
migrationRequired: true,
|
|
requiresRekey: false,
|
|
},
|
|
hasChanges: true,
|
|
migrationBlockers: [
|
|
{
|
|
code: "required-column-backfill",
|
|
columns: ["issuer"],
|
|
table: "account",
|
|
},
|
|
],
|
|
migrationTarget: { adapter: "drizzle", dialect: "postgres" },
|
|
toBeAdded: [],
|
|
toBeAddedIndexes: [],
|
|
toBeCreated: [],
|
|
});
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
printHumanMigrationPlan(plan, [], [], []);
|
|
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
"->",
|
|
"account: existing rows need values for issuer.",
|
|
);
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
" Remediation:",
|
|
'Backfill issuer for every row in "account", then run `auth migrate apply` again.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("migrate published 1.6.30 account data", () => {
|
|
/**
|
|
* @see https://github.com/better-auth/better-auth/pull/10575#discussion_r3812055040
|
|
*/
|
|
it("keeps unrelated required account fields in the CLI migration plan", async () => {
|
|
const db = new Database(":memory:");
|
|
const auth1630 = betterAuth1630({
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: { enabled: true },
|
|
});
|
|
await (await getMigrations1630(auth1630.options)).runMigrations();
|
|
await auth1630.api.signUpEmail({
|
|
body: {
|
|
email: "ada@example.com",
|
|
name: "Ada",
|
|
password: "correct-horse-battery-staple",
|
|
},
|
|
});
|
|
|
|
const auth17 = betterAuth({
|
|
account: { identityStrategy: "provider-id" },
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
plugins: [
|
|
{
|
|
id: "required-account-field",
|
|
schema: {
|
|
account: {
|
|
fields: {
|
|
externalKey: {
|
|
required: true,
|
|
type: "string",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
vi.spyOn(config, "getConfig").mockImplementation(
|
|
async () => auth17.options,
|
|
);
|
|
vi.spyOn(process, "exit").mockImplementation((code) => code as never);
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
try {
|
|
await migrateAction({
|
|
cwd: process.cwd(),
|
|
mode: "plan",
|
|
outputFormat: "json",
|
|
});
|
|
|
|
const migrationPlan = JSON.parse(
|
|
String(consoleLog.mock.calls[0]?.[0]),
|
|
) as {
|
|
blockers: Array<{ code: string; columns?: string[] }>;
|
|
status: string;
|
|
};
|
|
expect(migrationPlan.status).toBe("blocked");
|
|
expect(migrationPlan.blockers).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
code: "required-column-backfill",
|
|
columns: expect.arrayContaining(["externalKey"]),
|
|
}),
|
|
]),
|
|
);
|
|
expect(
|
|
db
|
|
.prepare("PRAGMA table_info(account)")
|
|
.all()
|
|
.map((column) => (column as { name: string }).name),
|
|
).not.toContain("issuer");
|
|
} finally {
|
|
process.exitCode = undefined;
|
|
}
|
|
});
|
|
|
|
it("blocks required identity columns before changing a populated account table", async () => {
|
|
const db = new Database(":memory:");
|
|
const auth1630 = betterAuth1630({
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
});
|
|
await (await getMigrations1630(auth1630.options)).runMigrations();
|
|
|
|
for (const user of [
|
|
{ email: "ada@example.com", name: "Ada" },
|
|
{ email: "grace@example.com", name: "Grace" },
|
|
]) {
|
|
await auth1630.api.signUpEmail({
|
|
body: {
|
|
...user,
|
|
password: "correct-horse-battery-staple",
|
|
},
|
|
});
|
|
}
|
|
|
|
const sourceAccounts = db
|
|
.prepare(
|
|
"SELECT id, accountId, providerId, userId FROM account ORDER BY userId",
|
|
)
|
|
.all();
|
|
expect(sourceAccounts).toHaveLength(2);
|
|
|
|
const auth17 = betterAuth({
|
|
account: { identityStrategy: "provider-id" },
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
});
|
|
const migration = await getMigrations(auth17.options);
|
|
|
|
expect(migration.migrationBlockers).toContainEqual({
|
|
code: "required-column-backfill",
|
|
columns: ["issuer"],
|
|
table: "account",
|
|
});
|
|
await expect(migration.runMigrations()).rejects.toThrow(
|
|
'Migration blocked: existing table "account" contains rows and requires values for "issuer".',
|
|
);
|
|
|
|
const accountColumns = db
|
|
.prepare("PRAGMA table_info(account)")
|
|
.all()
|
|
.map((column) => (column as { name: string }).name);
|
|
expect(accountColumns).toContain("accountId");
|
|
expect(accountColumns).not.toContain("issuer");
|
|
|
|
vi.spyOn(config, "getConfig").mockImplementation(
|
|
async () => auth17.options,
|
|
);
|
|
const processExit = vi
|
|
.spyOn(process, "exit")
|
|
.mockImplementation((code) => code as never);
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
await migrateAction({ cwd: process.cwd(), mode: "plan" });
|
|
|
|
expect(processExit).not.toHaveBeenCalled();
|
|
expect(consoleLog).toHaveBeenCalledWith("Blockers: none");
|
|
expect(consoleLog).toHaveBeenCalledWith("Release migration: 1.6-to-1.7");
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
"->",
|
|
"write the 1.7 account identity onto every existing account row",
|
|
);
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
"No database changes were applied.",
|
|
);
|
|
expect(
|
|
db
|
|
.prepare("PRAGMA table_info(account)")
|
|
.all()
|
|
.map((column) => (column as { name: string }).name),
|
|
).not.toContain("issuer");
|
|
|
|
consoleLog.mockClear();
|
|
await migrateAction({
|
|
cwd: process.cwd(),
|
|
mode: "plan",
|
|
outputFormat: "json",
|
|
});
|
|
|
|
expect(processExit).not.toHaveBeenCalled();
|
|
expect(process.exitCode).toBeUndefined();
|
|
expect(consoleLog).toHaveBeenCalledTimes(1);
|
|
const jsonPlan = JSON.parse(String(consoleLog.mock.calls[0]?.[0])) as {
|
|
blockers: Array<Record<string, unknown>>;
|
|
formatVersion: number;
|
|
releaseMigration: { actions: string[]; id: string };
|
|
status: string;
|
|
};
|
|
expect(jsonPlan).toMatchObject({
|
|
blockers: [],
|
|
formatVersion: 1,
|
|
releaseMigration: {
|
|
actions: [
|
|
"write the 1.7 account identity onto every existing account row",
|
|
],
|
|
id: "1.6-to-1.7",
|
|
},
|
|
status: "ready",
|
|
});
|
|
|
|
backfill1630CredentialAccountIdentity(db);
|
|
const backfilledMigration = await getMigrations(auth17.options);
|
|
expect(backfilledMigration.migrationBlockers).toContainEqual({
|
|
code: "required-column-constraint",
|
|
columns: ["issuer"],
|
|
table: "account",
|
|
});
|
|
await expect(backfilledMigration.runMigrations()).rejects.toThrow(
|
|
'Migration blocked: existing table "account" must make "issuer" non-nullable.',
|
|
);
|
|
await expect(backfilledMigration.compileMigrations()).rejects.toThrow(
|
|
'Migration blocked: existing table "account" must make "issuer" non-nullable.',
|
|
);
|
|
|
|
enforce1630CredentialAccountIdentityConstraints(db);
|
|
const reviewedMigration = await getMigrations(auth17.options);
|
|
expect(reviewedMigration.migrationBlockers).toEqual([]);
|
|
await reviewedMigration.runMigrations();
|
|
|
|
for (const credentials of [
|
|
{ email: "ada@example.com", name: "Ada" },
|
|
{ email: "grace@example.com", name: "Grace" },
|
|
]) {
|
|
const signIn = await auth17.api.signInEmail({
|
|
body: {
|
|
email: credentials.email,
|
|
password: "correct-horse-battery-staple",
|
|
},
|
|
});
|
|
expect(signIn.user.name).toBe(credentials.name);
|
|
}
|
|
});
|
|
|
|
it("migrates account identities the configuration can resolve on its own", async () => {
|
|
const db = new Database(":memory:");
|
|
const auth1630 = betterAuth1630({
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
});
|
|
await (await getMigrations1630(auth1630.options)).runMigrations();
|
|
await auth1630.api.signUpEmail({
|
|
body: {
|
|
email: "ada@example.com",
|
|
name: "Ada",
|
|
password: "correct-horse-battery-staple",
|
|
},
|
|
});
|
|
const auth17 = betterAuth({
|
|
account: { identityStrategy: "provider-id" },
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
});
|
|
vi.spyOn(config, "getConfig").mockImplementation(
|
|
async () => auth17.options,
|
|
);
|
|
vi.spyOn(process, "exit").mockImplementation((code) => code as never);
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => {});
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
await migrateAction({
|
|
approved: true,
|
|
cwd: process.cwd(),
|
|
mode: "apply",
|
|
});
|
|
|
|
expect(consoleError).not.toHaveBeenCalledWith(
|
|
"Migration blocked. No database changes were applied.",
|
|
);
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
"🚀 migration was completed successfully!",
|
|
);
|
|
const migratedAccount = db
|
|
.prepare("SELECT accountId, issuer, userId FROM account")
|
|
.get() as {
|
|
accountId: string;
|
|
issuer: string;
|
|
userId: string;
|
|
};
|
|
expect(migratedAccount.issuer).toBe("local:credential");
|
|
expect(migratedAccount.accountId).toBe(migratedAccount.userId);
|
|
const signIn = await auth17.api.signInEmail({
|
|
body: {
|
|
email: "ada@example.com",
|
|
password: "correct-horse-battery-staple",
|
|
},
|
|
});
|
|
expect(signIn.user.name).toBe("Ada");
|
|
|
|
consoleLog.mockClear();
|
|
await migrateAction({
|
|
approved: true,
|
|
cwd: process.cwd(),
|
|
mode: "apply",
|
|
});
|
|
|
|
expect(consoleLog).toHaveBeenCalledWith("🚀 No migrations needed.");
|
|
});
|
|
});
|
|
|
|
async function createReleaseDecisionFixture(
|
|
db: Database.Database,
|
|
{
|
|
identityStrategy = "provider-id",
|
|
sourceClientSecretStorage = "plain",
|
|
targetClientSecretStorage = "hashed",
|
|
}: {
|
|
identityStrategy?: "issuer" | "provider-id" | "unset";
|
|
sourceClientSecretStorage?: "encrypted" | "hashed" | "plain";
|
|
targetClientSecretStorage?: "encrypted" | "hashed";
|
|
} = {},
|
|
) {
|
|
const auth1630 = betterAuth1630({
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
plugins: [
|
|
oidcProvider1630({
|
|
allowDynamicClientRegistration: true,
|
|
loginPage: "/login",
|
|
storeClientSecret: sourceClientSecretStorage,
|
|
}),
|
|
scim1630(),
|
|
],
|
|
});
|
|
await (await getMigrations1630(auth1630.options)).runMigrations();
|
|
const credentials = {
|
|
email: "release-admin@example.com",
|
|
name: "Release Admin",
|
|
password: "correct-horse-battery-staple",
|
|
};
|
|
const admin = await auth1630.api.signUpEmail({ body: credentials });
|
|
const signIn = await auth1630.api.signInEmail({
|
|
body: {
|
|
email: credentials.email,
|
|
password: credentials.password,
|
|
},
|
|
returnHeaders: true,
|
|
});
|
|
const cookie = signIn.headers.getSetCookie()[0];
|
|
if (!cookie) {
|
|
throw new Error("Expected the 1.6.30 sign-in to set a session cookie");
|
|
}
|
|
const generated = await auth1630.api.generateSCIMToken({
|
|
body: { providerId: "workforce-fixture" },
|
|
headers: { cookie },
|
|
});
|
|
await auth1630.api.createSCIMUser({
|
|
body: {
|
|
name: { formatted: "Ada Provisioned" },
|
|
userName: "ada-provisioned@example.com",
|
|
},
|
|
headers: {
|
|
authorization: `Bearer ${generated.scimToken}`,
|
|
},
|
|
});
|
|
const sourceContext = await auth1630.$context;
|
|
const scimAccount = await sourceContext.adapter.findOne<{ id: string }>({
|
|
model: "account",
|
|
where: [{ field: "providerId", value: "workforce-fixture" }],
|
|
});
|
|
if (!scimAccount) {
|
|
throw new Error("Expected the 1.6.30 SCIM authentication account");
|
|
}
|
|
const registeredClient = await auth1630.api.registerOAuthApplication({
|
|
body: {
|
|
client_name: "Release decision fixture",
|
|
redirect_uris: ["https://client.example/callback"],
|
|
},
|
|
});
|
|
const now = new Date();
|
|
await sourceContext.adapter.create({
|
|
model: "oauthConsent",
|
|
data: {
|
|
clientId: registeredClient.client_id,
|
|
consentGiven: true,
|
|
createdAt: now,
|
|
scopes: "openid profile",
|
|
updatedAt: now,
|
|
userId: admin.user.id,
|
|
},
|
|
});
|
|
|
|
const options17: BetterAuthOptions = {
|
|
...(identityStrategy !== "unset" && { account: { identityStrategy } }),
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
plugins: [
|
|
jwt(),
|
|
oauthProvider({
|
|
consentPage: "/consent",
|
|
disableJwtPlugin: targetClientSecretStorage === "encrypted",
|
|
loginPage: "/login",
|
|
silenceWarnings: {
|
|
oauthAuthServerConfig: true,
|
|
openidConfig: true,
|
|
},
|
|
storeClientSecret: targetClientSecretStorage,
|
|
}),
|
|
scim({
|
|
connections: [
|
|
{
|
|
credentials: [
|
|
{
|
|
id: "fixture-token",
|
|
token: "fixture-token",
|
|
type: "bearer",
|
|
},
|
|
],
|
|
id: "workforce-fixture",
|
|
},
|
|
],
|
|
}),
|
|
],
|
|
};
|
|
vi.spyOn(config, "getConfig").mockImplementation(async () => options17);
|
|
return {
|
|
credentials,
|
|
options17,
|
|
registeredClientId: registeredClient.client_id,
|
|
scimAccountId: scimAccount.id,
|
|
};
|
|
}
|
|
|
|
async function writeMigrationDecisions(decisions: unknown) {
|
|
const directory = await fs.mkdtemp(
|
|
path.join(os.tmpdir(), "better-auth-migration-decisions-"),
|
|
);
|
|
const filePath = path.join(directory, "better-auth-migration.json");
|
|
await fs.writeFile(filePath, JSON.stringify(decisions, null, 2));
|
|
return filePath;
|
|
}
|
|
|
|
function readTableNames(db: Database.Database) {
|
|
return db
|
|
.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
|
|
)
|
|
.all()
|
|
.map((table) => (table as { name: string }).name);
|
|
}
|
|
|
|
describe("migrate command modes", () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(process, "exit").mockImplementation((code) => code as never);
|
|
});
|
|
|
|
it("applies a migration in JSON mode and prints one structured result", async () => {
|
|
const db = new Database(":memory:");
|
|
const auth = betterAuth({ database: db });
|
|
vi.spyOn(config, "getConfig").mockImplementation(async () => auth.options);
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
await createMigrateCommand().parseAsync([
|
|
"node",
|
|
"auth",
|
|
"apply",
|
|
"--json",
|
|
"--yes",
|
|
]);
|
|
|
|
expect(consoleLog).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(String(consoleLog.mock.calls[0]?.[0]))).toMatchObject({
|
|
formatVersion: 1,
|
|
mode: "apply",
|
|
plan: {
|
|
status: "ready",
|
|
target: { adapter: "kysely", dialect: "sqlite" },
|
|
},
|
|
status: "applied",
|
|
});
|
|
expect(readTableNames(db)).toEqual([
|
|
"account",
|
|
"session",
|
|
"user",
|
|
"verification",
|
|
]);
|
|
});
|
|
|
|
it("returns a blocked JSON result without changing release data", async () => {
|
|
const db = new Database(":memory:");
|
|
await createReleaseDecisionFixture(db);
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
await migrateAction({
|
|
approved: true,
|
|
cwd: process.cwd(),
|
|
mode: "apply",
|
|
outputFormat: "json",
|
|
});
|
|
|
|
expect(process.exit).toHaveBeenCalledWith(1);
|
|
expect(consoleLog).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(String(consoleLog.mock.calls[0]?.[0]))).toMatchObject({
|
|
formatVersion: 1,
|
|
mode: "apply",
|
|
plan: { status: "blocked" },
|
|
status: "blocked",
|
|
});
|
|
expect(
|
|
db
|
|
.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'oauthClient'",
|
|
)
|
|
.get(),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("stops the CLI spinner when a release migration fails", async () => {
|
|
const db = new Database(":memory:");
|
|
const { scimAccountId } = await createReleaseDecisionFixture(db);
|
|
const migrationFile = await writeMigrationDecisions({
|
|
formatVersion: 1,
|
|
migration: "1.6-to-1.7",
|
|
oauth: {
|
|
clientSecrets: { source: "plain", target: "hashed" },
|
|
consents: "migrate",
|
|
},
|
|
scim: { retireAccountIds: [scimAccountId] },
|
|
});
|
|
vi.spyOn(releaseMigration, "migrateFrom16").mockRejectedValueOnce(
|
|
new Error("forced apply failure"),
|
|
);
|
|
await expect(
|
|
migrateAction({
|
|
approved: true,
|
|
cwd: process.cwd(),
|
|
migrationFile,
|
|
mode: "apply",
|
|
}),
|
|
).rejects.toThrow("forced apply failure");
|
|
|
|
const spinner = vi.mocked(yoctoSpinner).mock.results.at(-1)?.value;
|
|
expect(spinner?.stop).toHaveBeenCalled();
|
|
});
|
|
|
|
it("stops the CLI spinner when migration inspection fails", async () => {
|
|
const db = new Database(":memory:");
|
|
const auth = betterAuth({ database: db });
|
|
vi.spyOn(config, "getConfig").mockImplementation(async () => auth.options);
|
|
vi.spyOn(releaseMigration, "getMigrations").mockRejectedValueOnce(
|
|
new Error("forced inspection failure"),
|
|
);
|
|
|
|
await expect(
|
|
migrateAction({ cwd: process.cwd(), mode: "plan" }),
|
|
).rejects.toThrow("forced inspection failure");
|
|
|
|
const spinner = vi.mocked(yoctoSpinner).mock.results.at(-1)?.value;
|
|
expect(spinner?.stop).toHaveBeenCalled();
|
|
});
|
|
|
|
/**
|
|
* @see https://github.com/better-auth/better-auth/pull/10575#discussion_r3830648963
|
|
*/
|
|
it.each([
|
|
"plan",
|
|
"apply",
|
|
] as const)("reports release inspection failures as a blocked JSON %s result", async (mode) => {
|
|
const db = new Database(":memory:");
|
|
const auth = betterAuth({ database: db });
|
|
vi.spyOn(config, "getConfig").mockImplementation(async () => auth.options);
|
|
const inspectLegacyReleaseData = vi
|
|
.spyOn(releaseMigration, "inspectLegacyReleaseDataFrom16")
|
|
.mockRejectedValueOnce(new Error("legacy data is unreadable"));
|
|
const migrationFile = await writeMigrationDecisions({
|
|
formatVersion: 1,
|
|
migration: "1.6-to-1.7",
|
|
});
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
try {
|
|
await expect(
|
|
migrateAction({
|
|
approved: true,
|
|
cwd: process.cwd(),
|
|
migrationFile,
|
|
mode,
|
|
outputFormat: "json",
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(inspectLegacyReleaseData).toHaveBeenCalledOnce();
|
|
if (mode === "plan") {
|
|
expect(process.exitCode).toBe(1);
|
|
} else {
|
|
expect(process.exit).toHaveBeenCalledWith(1);
|
|
}
|
|
const output = JSON.parse(String(consoleLog.mock.calls[0]?.[0]));
|
|
const plan = mode === "apply" ? output.plan : output;
|
|
expect(plan).toMatchObject({
|
|
blockers: [
|
|
{
|
|
code: "release-migration-error",
|
|
message: "legacy data is unreadable",
|
|
},
|
|
],
|
|
status: "blocked",
|
|
});
|
|
if (mode === "apply") {
|
|
expect(output).toMatchObject({ status: "blocked" });
|
|
}
|
|
} finally {
|
|
process.exitCode = undefined;
|
|
}
|
|
});
|
|
|
|
it("rejects interactive JSON application before inspecting the database", async () => {
|
|
const getConfig = vi.spyOn(config, "getConfig");
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
await createMigrateCommand().parseAsync([
|
|
"node",
|
|
"auth",
|
|
"apply",
|
|
"--json",
|
|
]);
|
|
|
|
expect(getConfig).not.toHaveBeenCalled();
|
|
expect(process.exit).toHaveBeenCalledWith(1);
|
|
expect(consoleLog).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(String(consoleLog.mock.calls[0]?.[0]))).toEqual({
|
|
formatVersion: 1,
|
|
mode: "apply",
|
|
remediation: "Pass --yes to confirm a non-interactive JSON application.",
|
|
status: "approval-required",
|
|
});
|
|
});
|
|
|
|
it("keeps approval outside the read-only plan command", async () => {
|
|
const command = createMigrateCommand();
|
|
const planCommand = command.commands.find(
|
|
(childCommand) => childCommand.name() === "plan",
|
|
);
|
|
planCommand?.exitOverride().configureOutput({ writeErr: () => {} });
|
|
|
|
await expect(
|
|
command.parseAsync(["node", "auth", "plan", "--yes"]),
|
|
).rejects.toMatchObject({
|
|
code: "commander.unknownOption",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
"plan",
|
|
"apply",
|
|
] as const)("passes the positional migration file to migrate %s", async (mode) => {
|
|
const migrationFile = await writeMigrationDecisions({
|
|
formatVersion: 2,
|
|
migration: "1.6-to-1.7",
|
|
});
|
|
const getConfig = vi.spyOn(config, "getConfig");
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => {});
|
|
|
|
await createMigrateCommand().parseAsync([
|
|
"node",
|
|
"auth",
|
|
mode,
|
|
migrationFile,
|
|
...(mode === "apply" ? ["--yes"] : []),
|
|
]);
|
|
|
|
expect(getConfig).not.toHaveBeenCalled();
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
`The migration decisions file "${migrationFile}" is invalid`,
|
|
),
|
|
);
|
|
});
|
|
|
|
it("prefers explicit subcommand paths over parent command paths", async () => {
|
|
const parentDirectory = await fs.mkdtemp(
|
|
path.join(os.tmpdir(), "better-auth-parent-migration-"),
|
|
);
|
|
const childDirectory = await fs.mkdtemp(
|
|
path.join(os.tmpdir(), "better-auth-child-migration-"),
|
|
);
|
|
const getConfig = vi.spyOn(config, "getConfig").mockResolvedValue(null);
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
await createMigrateCommand().parseAsync([
|
|
"node",
|
|
"auth",
|
|
"--cwd",
|
|
parentDirectory,
|
|
"--config",
|
|
"parent-auth.ts",
|
|
"plan",
|
|
"--cwd",
|
|
childDirectory,
|
|
"--config",
|
|
"child-auth.ts",
|
|
]);
|
|
|
|
expect(getConfig).toHaveBeenCalledWith({
|
|
configPath: "child-auth.ts",
|
|
cwd: childDirectory,
|
|
});
|
|
});
|
|
|
|
it("keeps the released plain migrate command as an apply alias", async () => {
|
|
const db = new Database(":memory:");
|
|
const auth = betterAuth({ database: db });
|
|
vi.spyOn(config, "getConfig").mockImplementation(async () => auth.options);
|
|
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
await createMigrateCommand().parseAsync(["node", "auth", "--yes"]);
|
|
|
|
expect(consoleWarn).toHaveBeenCalledWith(
|
|
"WARNING: `auth migrate` without an action is deprecated. Use `auth migrate apply`.",
|
|
);
|
|
expect(readTableNames(db)).toEqual([
|
|
"account",
|
|
"session",
|
|
"user",
|
|
"verification",
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
"--dry-run",
|
|
"--plan",
|
|
])("rejects the unpublished %s option", async (option) => {
|
|
const command = createMigrateCommand();
|
|
command.exitOverride().configureOutput({ writeErr: () => {} });
|
|
|
|
await expect(
|
|
command.parseAsync([
|
|
"node",
|
|
"auth",
|
|
option,
|
|
...(option === "--plan" ? ["better-auth-migration.json"] : []),
|
|
]),
|
|
).rejects.toMatchObject({
|
|
code: "commander.unknownOption",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("plan every unresolved 1.6.30 release decision", () => {
|
|
it("reports the provider-id issuer backfill and an unknown SCIM inventory accurately", async () => {
|
|
const db = new Database(":memory:");
|
|
await createReleaseDecisionFixture(db, {
|
|
identityStrategy: "provider-id",
|
|
});
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
try {
|
|
await migrateAction({
|
|
cwd: process.cwd(),
|
|
mode: "plan",
|
|
outputFormat: "json",
|
|
});
|
|
|
|
const jsonPlan = JSON.parse(String(consoleLog.mock.calls[0]?.[0])) as {
|
|
releaseMigration?: { actions: string[] };
|
|
};
|
|
expect(jsonPlan.releaseMigration?.actions).toContain(
|
|
"write the 1.7 account identity onto every existing account row",
|
|
);
|
|
expect(jsonPlan.releaseMigration?.actions).toContain(
|
|
"retire 1 SCIM provider, confirm the complete provisioned-account retirement inventory, and require a full reprovision of every SCIM connection",
|
|
);
|
|
expect(jsonPlan.releaseMigration?.actions).not.toContain(
|
|
"retire 1 SCIM provider, delete 0 provisioned accounts, and require a full reprovision of every SCIM connection",
|
|
);
|
|
} finally {
|
|
process.exitCode = undefined;
|
|
}
|
|
});
|
|
|
|
it("reports the consent strategy and SCIM inventory in one run", async () => {
|
|
const db = new Database(":memory:");
|
|
const { scimAccountId } = await createReleaseDecisionFixture(db, {
|
|
identityStrategy: "unset",
|
|
});
|
|
const plan = await writeMigrationDecisions({
|
|
formatVersion: 1,
|
|
migration: "1.6-to-1.7",
|
|
scim: { retireAccountIds: [] },
|
|
});
|
|
const processExit = vi
|
|
.spyOn(process, "exit")
|
|
.mockImplementation((code) => code as never);
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
try {
|
|
await migrateAction({
|
|
cwd: process.cwd(),
|
|
migrationFile: plan,
|
|
mode: "plan",
|
|
outputFormat: "json",
|
|
});
|
|
|
|
expect(processExit).not.toHaveBeenCalled();
|
|
expect(process.exitCode).toBe(1);
|
|
const jsonPlan = JSON.parse(String(consoleLog.mock.calls[0]?.[0])) as {
|
|
blockers: Array<Record<string, unknown>>;
|
|
releaseMigration?: { actions: string[] };
|
|
status: string;
|
|
};
|
|
expect(jsonPlan.status).toBe("blocked");
|
|
expect(jsonPlan.releaseMigration?.actions).toContain(
|
|
"write the 1.7 account identity onto every existing account row",
|
|
);
|
|
expect(jsonPlan.blockers).toEqual([
|
|
{
|
|
accountCount: 2,
|
|
code: "account-identity-strategy-required",
|
|
providerIds: ["credential", "workforce-fixture"],
|
|
remediation: {
|
|
docs: "https://better-auth.com/docs/guides/1-7-upgrade-guide#choose-account-identity-strategy",
|
|
summary:
|
|
'Set account: { identityStrategy: "provider-id" } to preserve 1.6 account identity, then run the plan again.',
|
|
},
|
|
table: "account",
|
|
},
|
|
{
|
|
code: "scim-inventory-mismatch",
|
|
missingAccountIds: [scimAccountId],
|
|
remediation: {
|
|
docs: "https://better-auth.com/docs/guides/1-7-upgrade-guide#scim-requires-full-reprovisioning",
|
|
summary:
|
|
"Set scim.retireAccountIds in better-auth-migration.json to exactly the accounts this blocker reports.",
|
|
},
|
|
table: "account",
|
|
unknownAccountIds: [],
|
|
},
|
|
{
|
|
code: "oauth-client-decision-required",
|
|
remediation: {
|
|
docs: "https://better-auth.com/docs/guides/1-7-upgrade-guide#migrate-from-16-to-17",
|
|
summary:
|
|
'Record how the 1.6 client secrets are stored under oauth.clientSecrets in better-auth-migration.json; the configured 1.7 target is "hashed".',
|
|
},
|
|
rowCount: 1,
|
|
table: "oauthApplication",
|
|
target: "hashed",
|
|
},
|
|
{
|
|
code: "oauth-consent-decision-required",
|
|
remediation: {
|
|
docs: "https://better-auth.com/docs/guides/1-7-upgrade-guide#migrate-from-16-to-17",
|
|
summary:
|
|
'Record oauth.consents as "migrate" or "reauthorize" in better-auth-migration.json, or run `auth migrate apply` in a terminal to answer it there.',
|
|
},
|
|
rowCount: 1,
|
|
table: "oauthConsent",
|
|
},
|
|
]);
|
|
} finally {
|
|
process.exitCode = undefined;
|
|
}
|
|
});
|
|
|
|
it("refuses to guess the decisions when none are recorded", async () => {
|
|
const db = new Database(":memory:");
|
|
await createReleaseDecisionFixture(db);
|
|
const processExit = vi
|
|
.spyOn(process, "exit")
|
|
.mockImplementation((code) => code as never);
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => {});
|
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
await migrateAction({
|
|
approved: true,
|
|
cwd: process.cwd(),
|
|
mode: "apply",
|
|
});
|
|
|
|
expect(processExit).toHaveBeenCalledWith(1);
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
"Migration blocked. No database changes were applied.",
|
|
);
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
'-> [scim-decision-required] The 1.6 SCIM migration requires providers: "reprovision" and an explicit accountIdsToRetire inventory.',
|
|
);
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
" Fix: Record scim.retireAccountIds in better-auth-migration.json, or run `auth migrate apply` in a terminal to confirm the retirement inventory there.",
|
|
);
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
" Docs: https://better-auth.com/docs/guides/1-7-upgrade-guide#scim-requires-full-reprovisioning",
|
|
);
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
"This database holds Better Auth 1.6 data. Run `auth migrate apply` in a terminal to answer these decisions, or record them in better-auth-migration.json and run `auth migrate apply better-auth-migration.json`. Upgrade guide: https://better-auth.com/docs/guides/1-7-upgrade-guide",
|
|
);
|
|
expect(
|
|
db
|
|
.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'oauthClient'",
|
|
)
|
|
.get(),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("applies the recorded decisions", async () => {
|
|
const db = new Database(":memory:");
|
|
const { credentials, scimAccountId } =
|
|
await createReleaseDecisionFixture(db);
|
|
const plan = await writeMigrationDecisions({
|
|
formatVersion: 1,
|
|
migration: "1.6-to-1.7",
|
|
oauth: {
|
|
clientSecrets: { source: "plain", target: "hashed" },
|
|
consents: "reauthorize",
|
|
},
|
|
scim: { retireAccountIds: [scimAccountId] },
|
|
});
|
|
vi.spyOn(process, "exit").mockImplementation((code) => code as never);
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => {});
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
await migrateAction({
|
|
approved: true,
|
|
cwd: process.cwd(),
|
|
migrationFile: plan,
|
|
mode: "apply",
|
|
});
|
|
|
|
expect(consoleError).not.toHaveBeenCalledWith(
|
|
"Migration blocked. No database changes were applied.",
|
|
);
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
"🚀 migration was completed successfully!",
|
|
);
|
|
expect(
|
|
db.prepare("SELECT COUNT(*) AS count FROM oauthClient").get(),
|
|
).toEqual({ count: 1 });
|
|
expect(
|
|
db.prepare("SELECT COUNT(*) AS count FROM oauthConsent").get(),
|
|
).toEqual({ count: 0 });
|
|
expect(
|
|
db
|
|
.prepare("SELECT COUNT(*) AS count FROM account WHERE id = ?")
|
|
.get(scimAccountId),
|
|
).toEqual({ count: 0 });
|
|
expect(
|
|
db
|
|
.prepare(
|
|
`SELECT name FROM sqlite_master
|
|
WHERE type = 'table' AND name LIKE '%__better_auth_1_6'
|
|
ORDER BY name`,
|
|
)
|
|
.all()
|
|
.map((table) => (table as { name: string }).name),
|
|
).toEqual([
|
|
"oauthAccessToken__better_auth_1_6",
|
|
"oauthApplication__better_auth_1_6",
|
|
"oauthConsent__better_auth_1_6",
|
|
"scimProvider__better_auth_1_6",
|
|
]);
|
|
const auth17 = betterAuth({
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: { enabled: true },
|
|
});
|
|
const signIn = await auth17.api.signInEmail({
|
|
body: {
|
|
email: credentials.email,
|
|
password: credentials.password,
|
|
},
|
|
});
|
|
expect(signIn.user.name).toBe(credentials.name);
|
|
});
|
|
|
|
it("preserves default SHA-256 client secrets instead of hashing them twice", async () => {
|
|
const db = new Database(":memory:");
|
|
const { registeredClientId, scimAccountId } =
|
|
await createReleaseDecisionFixture(db, {
|
|
sourceClientSecretStorage: "hashed",
|
|
});
|
|
const sourceClient = db
|
|
.prepare("SELECT clientSecret FROM oauthApplication WHERE clientId = ?")
|
|
.get(registeredClientId) as { clientSecret: string };
|
|
const plan = await writeMigrationDecisions({
|
|
formatVersion: 1,
|
|
migration: "1.6-to-1.7",
|
|
oauth: {
|
|
clientSecrets: { source: "hashed", target: "hashed" },
|
|
consents: "reauthorize",
|
|
},
|
|
scim: { retireAccountIds: [scimAccountId] },
|
|
});
|
|
vi.spyOn(process, "exit").mockImplementation((code) => code as never);
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
await migrateAction({
|
|
approved: true,
|
|
cwd: process.cwd(),
|
|
migrationFile: plan,
|
|
mode: "apply",
|
|
});
|
|
|
|
expect(
|
|
db
|
|
.prepare("SELECT clientSecret FROM oauthClient WHERE clientId = ?")
|
|
.get(registeredClientId),
|
|
).toEqual(sourceClient);
|
|
});
|
|
|
|
it("preserves encrypted client secrets when both versions use encryption", async () => {
|
|
const db = new Database(":memory:");
|
|
const { registeredClientId, scimAccountId } =
|
|
await createReleaseDecisionFixture(db, {
|
|
sourceClientSecretStorage: "encrypted",
|
|
targetClientSecretStorage: "encrypted",
|
|
});
|
|
const sourceClient = db
|
|
.prepare("SELECT clientSecret FROM oauthApplication WHERE clientId = ?")
|
|
.get(registeredClientId) as { clientSecret: string };
|
|
const plan = await writeMigrationDecisions({
|
|
formatVersion: 1,
|
|
migration: "1.6-to-1.7",
|
|
oauth: {
|
|
clientSecrets: { source: "encrypted", target: "encrypted" },
|
|
consents: "reauthorize",
|
|
},
|
|
scim: { retireAccountIds: [scimAccountId] },
|
|
});
|
|
vi.spyOn(process, "exit").mockImplementation((code) => code as never);
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
await migrateAction({
|
|
approved: true,
|
|
cwd: process.cwd(),
|
|
migrationFile: plan,
|
|
mode: "apply",
|
|
});
|
|
|
|
expect(
|
|
db
|
|
.prepare("SELECT clientSecret FROM oauthClient WHERE clientId = ?")
|
|
.get(registeredClientId),
|
|
).toEqual(sourceClient);
|
|
});
|
|
|
|
it("rejects a decisions file it does not understand", async () => {
|
|
const db = new Database(":memory:");
|
|
await createReleaseDecisionFixture(db);
|
|
const plan = await writeMigrationDecisions({
|
|
formatVersion: 2,
|
|
migration: "1.6-to-1.7",
|
|
retireScim: true,
|
|
});
|
|
const processExit = vi
|
|
.spyOn(process, "exit")
|
|
.mockImplementation((code) => code as never);
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => {});
|
|
|
|
await migrateAction({
|
|
approved: true,
|
|
cwd: process.cwd(),
|
|
migrationFile: plan,
|
|
mode: "apply",
|
|
});
|
|
|
|
expect(processExit).toHaveBeenCalledWith(1);
|
|
const message = String(consoleError.mock.calls[0]?.[0]);
|
|
expect(message).toContain(
|
|
`The migration decisions file "${plan}" is invalid`,
|
|
);
|
|
expect(message).toContain("formatVersion");
|
|
expect(message).toContain("retireScim");
|
|
});
|
|
});
|
|
|
|
async function createResolvableAccountFixture(db: Database.Database) {
|
|
const auth1630 = betterAuth1630({
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
});
|
|
await (await getMigrations1630(auth1630.options)).runMigrations();
|
|
const credentials = {
|
|
email: "ada@example.com",
|
|
name: "Ada",
|
|
password: "correct-horse-battery-staple",
|
|
};
|
|
await auth1630.api.signUpEmail({ body: credentials });
|
|
const options17: BetterAuthOptions = {
|
|
account: { identityStrategy: "provider-id" },
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
};
|
|
vi.spyOn(config, "getConfig").mockImplementation(async () => options17);
|
|
return { credentials };
|
|
}
|
|
|
|
function stubStdinIsTTY(isTTY: boolean) {
|
|
const original = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
|
Object.defineProperty(process.stdin, "isTTY", {
|
|
configurable: true,
|
|
value: isTTY,
|
|
});
|
|
return () => {
|
|
if (original) {
|
|
Object.defineProperty(process.stdin, "isTTY", original);
|
|
return;
|
|
}
|
|
Reflect.deleteProperty(process.stdin, "isTTY");
|
|
};
|
|
}
|
|
|
|
async function createInterviewDirectory() {
|
|
return await fs.mkdtemp(
|
|
path.join(os.tmpdir(), "better-auth-migration-interview-"),
|
|
);
|
|
}
|
|
|
|
async function readRecordedDecisions(cwd: string) {
|
|
return JSON.parse(
|
|
await fs.readFile(path.join(cwd, "better-auth-migration.json"), "utf8"),
|
|
);
|
|
}
|
|
|
|
describe("interview the unresolved 1.6.30 release decisions", () => {
|
|
it("records every answer and applies the migration", async () => {
|
|
const db = new Database(":memory:");
|
|
const { scimAccountId } = await createReleaseDecisionFixture(db);
|
|
const cwd = await createInterviewDirectory();
|
|
const restoreStdin = stubStdinIsTTY(true);
|
|
vi.spyOn(process, "exit").mockImplementation((code) => code as never);
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => {});
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
vi.mocked(prompts)
|
|
.mockResolvedValueOnce({ source: "plain" })
|
|
.mockResolvedValueOnce({ consents: "reauthorize" })
|
|
.mockResolvedValueOnce({ retire: true })
|
|
.mockResolvedValueOnce({ migrate: true });
|
|
|
|
try {
|
|
await migrateAction({ cwd, mode: "apply" });
|
|
} finally {
|
|
restoreStdin();
|
|
}
|
|
|
|
expect(prompts).toHaveBeenCalledTimes(4);
|
|
expect(prompts).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
message:
|
|
'How did Better Auth 1.6 store OAuth client secrets? The configured 1.7 target is "hashed".',
|
|
name: "source",
|
|
type: "select",
|
|
}),
|
|
);
|
|
expect(prompts).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({
|
|
message: "Stored OAuth consents",
|
|
name: "consents",
|
|
type: "select",
|
|
}),
|
|
);
|
|
expect(prompts).toHaveBeenNthCalledWith(
|
|
3,
|
|
expect.objectContaining({
|
|
message: "Retire these SCIM accounts?",
|
|
name: "retire",
|
|
type: "confirm",
|
|
}),
|
|
);
|
|
expect(prompts).toHaveBeenNthCalledWith(
|
|
4,
|
|
expect.objectContaining({
|
|
message: "Are you sure you want to run these migrations?",
|
|
name: "migrate",
|
|
type: "confirm",
|
|
}),
|
|
);
|
|
expect(consoleLog).toHaveBeenCalledWith("->", scimAccountId);
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
"->",
|
|
"drop 1 stored consent so users grant them again",
|
|
);
|
|
expect(await readRecordedDecisions(cwd)).toEqual({
|
|
formatVersion: 1,
|
|
migration: "1.6-to-1.7",
|
|
oauth: {
|
|
clientSecrets: { source: "plain", target: "hashed" },
|
|
consents: "reauthorize",
|
|
},
|
|
scim: { retireAccountIds: [scimAccountId] },
|
|
});
|
|
expect(consoleError).not.toHaveBeenCalledWith(
|
|
"Migration blocked. No database changes were applied.",
|
|
);
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
"🚀 migration was completed successfully!",
|
|
);
|
|
expect(
|
|
db.prepare("SELECT COUNT(*) AS count FROM oauthClient").get(),
|
|
).toEqual({ count: 1 });
|
|
expect(
|
|
db
|
|
.prepare("SELECT COUNT(*) AS count FROM account WHERE id = ?")
|
|
.get(scimAccountId),
|
|
).toEqual({ count: 0 });
|
|
});
|
|
|
|
it("keeps the recorded answers when the final confirmation is declined", async () => {
|
|
const db = new Database(":memory:");
|
|
const { scimAccountId } = await createReleaseDecisionFixture(db);
|
|
const cwd = await createInterviewDirectory();
|
|
const restoreStdin = stubStdinIsTTY(true);
|
|
vi.spyOn(process, "exit").mockImplementation((code) => code as never);
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
vi.mocked(prompts)
|
|
.mockResolvedValueOnce({ source: "plain" })
|
|
.mockResolvedValueOnce({ consents: "migrate" })
|
|
.mockResolvedValueOnce({ retire: true })
|
|
.mockResolvedValueOnce({ migrate: false });
|
|
|
|
try {
|
|
await migrateAction({ cwd, mode: "apply" });
|
|
} finally {
|
|
restoreStdin();
|
|
}
|
|
|
|
expect(consoleLog).toHaveBeenCalledWith("Migration cancelled.");
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
"Apply the recorded decisions later with `auth migrate apply better-auth-migration.json`.",
|
|
);
|
|
expect(await readRecordedDecisions(cwd)).toEqual({
|
|
formatVersion: 1,
|
|
migration: "1.6-to-1.7",
|
|
oauth: {
|
|
clientSecrets: { source: "plain", target: "hashed" },
|
|
consents: "migrate",
|
|
},
|
|
scim: { retireAccountIds: [scimAccountId] },
|
|
});
|
|
expect(
|
|
db
|
|
.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'oauthClient'",
|
|
)
|
|
.get(),
|
|
).toBeUndefined();
|
|
expect(
|
|
db
|
|
.prepare("SELECT COUNT(*) AS count FROM account WHERE id = ?")
|
|
.get(scimAccountId),
|
|
).toEqual({ count: 1 });
|
|
});
|
|
|
|
it("preserves a different existing decisions file and exits cleanly", async () => {
|
|
const db = new Database(":memory:");
|
|
await createReleaseDecisionFixture(db);
|
|
const cwd = await createInterviewDirectory();
|
|
const existingDecisions = {
|
|
formatVersion: 1,
|
|
migration: "1.6-to-1.7",
|
|
};
|
|
await fs.writeFile(
|
|
path.join(cwd, "better-auth-migration.json"),
|
|
`${JSON.stringify(existingDecisions, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
const restoreStdin = stubStdinIsTTY(true);
|
|
const processExit = vi
|
|
.spyOn(process, "exit")
|
|
.mockImplementation((code) => code as never);
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => {});
|
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
vi.mocked(prompts)
|
|
.mockResolvedValueOnce({ source: "plain" })
|
|
.mockResolvedValueOnce({ consents: "reauthorize" })
|
|
.mockResolvedValueOnce({ retire: true });
|
|
|
|
try {
|
|
await migrateAction({ cwd, mode: "apply" });
|
|
} finally {
|
|
restoreStdin();
|
|
}
|
|
|
|
expect(processExit).toHaveBeenCalledWith(1);
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
"already exists with different decisions and was not changed",
|
|
),
|
|
);
|
|
expect(await readRecordedDecisions(cwd)).toEqual(existingDecisions);
|
|
expect(prompts).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("records nothing when a question is cancelled", async () => {
|
|
const db = new Database(":memory:");
|
|
await createReleaseDecisionFixture(db);
|
|
const cwd = await createInterviewDirectory();
|
|
const restoreStdin = stubStdinIsTTY(true);
|
|
vi.spyOn(process, "exit").mockImplementation((code) => code as never);
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
vi.mocked(prompts).mockResolvedValueOnce({});
|
|
|
|
try {
|
|
await migrateAction({ cwd, mode: "apply" });
|
|
} finally {
|
|
restoreStdin();
|
|
}
|
|
|
|
expect(prompts).toHaveBeenCalledTimes(1);
|
|
expect(consoleLog).toHaveBeenCalledWith("Migration cancelled.");
|
|
expect(await fs.readdir(cwd)).toEqual([]);
|
|
expect(
|
|
db
|
|
.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'oauthClient'",
|
|
)
|
|
.get(),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("asks nothing but the final confirmation when the configuration resolves every decision", async () => {
|
|
const db = new Database(":memory:");
|
|
const { credentials } = await createResolvableAccountFixture(db);
|
|
const cwd = await createInterviewDirectory();
|
|
const restoreStdin = stubStdinIsTTY(true);
|
|
vi.spyOn(process, "exit").mockImplementation((code) => code as never);
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
vi.mocked(prompts).mockResolvedValueOnce({ migrate: true });
|
|
|
|
try {
|
|
await migrateAction({ cwd, mode: "apply" });
|
|
} finally {
|
|
restoreStdin();
|
|
}
|
|
|
|
expect(prompts).toHaveBeenCalledTimes(1);
|
|
expect(prompts).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "Are you sure you want to run these migrations?",
|
|
name: "migrate",
|
|
type: "confirm",
|
|
}),
|
|
);
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
"->",
|
|
"write the 1.7 account identity onto every existing account row",
|
|
);
|
|
expect(await fs.readdir(cwd)).toEqual([]);
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
"🚀 migration was completed successfully!",
|
|
);
|
|
expect(db.prepare("SELECT issuer FROM account").get()).toEqual({
|
|
issuer: "local:credential",
|
|
});
|
|
const auth17 = betterAuth({
|
|
account: { identityStrategy: "provider-id" },
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: { enabled: true },
|
|
});
|
|
const signIn = await auth17.api.signInEmail({
|
|
body: {
|
|
email: credentials.email,
|
|
password: credentials.password,
|
|
},
|
|
});
|
|
expect(signIn.user.name).toBe(credentials.name);
|
|
});
|
|
|
|
it("asks nothing outside a terminal", async () => {
|
|
const db = new Database(":memory:");
|
|
await createReleaseDecisionFixture(db);
|
|
const cwd = await createInterviewDirectory();
|
|
const restoreStdin = stubStdinIsTTY(false);
|
|
const processExit = vi
|
|
.spyOn(process, "exit")
|
|
.mockImplementation((code) => code as never);
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => {});
|
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
try {
|
|
await migrateAction({ cwd, mode: "apply" });
|
|
} finally {
|
|
restoreStdin();
|
|
}
|
|
|
|
expect(prompts).not.toHaveBeenCalled();
|
|
expect(processExit).toHaveBeenCalledWith(1);
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
"Migration blocked. No database changes were applied.",
|
|
);
|
|
expect(await fs.readdir(cwd)).toEqual([]);
|
|
expect(
|
|
db
|
|
.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'oauthClient'",
|
|
)
|
|
.get(),
|
|
).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("migrate published 1.6.30 provider client data", () => {
|
|
it("blocks before leaving registered clients in the retired table", async () => {
|
|
const db = new Database(":memory:");
|
|
const auth1630 = betterAuth1630({
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
plugins: [
|
|
oidcProvider1630({
|
|
allowDynamicClientRegistration: true,
|
|
loginPage: "/login",
|
|
schema: {
|
|
oauthApplication: {
|
|
modelName: "legacyOAuthApplication",
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
await (await getMigrations1630(auth1630.options)).runMigrations();
|
|
const registeredClient = await auth1630.api.registerOAuthApplication({
|
|
body: {
|
|
client_name: "Migration fixture",
|
|
redirect_uris: ["https://client.example/callback"],
|
|
},
|
|
});
|
|
expect(registeredClient.client_id).toBeTypeOf("string");
|
|
expect(
|
|
db.prepare("SELECT COUNT(*) AS count FROM legacyOAuthApplication").get(),
|
|
).toEqual({ count: 1 });
|
|
const sourceUser = await auth1630.api.signUpEmail({
|
|
body: {
|
|
email: "provider-owner@example.com",
|
|
name: "Provider Owner",
|
|
password: "correct-horse-battery-staple",
|
|
},
|
|
});
|
|
const sourceContext = await auth1630.$context;
|
|
const now = new Date();
|
|
await sourceContext.adapter.create({
|
|
model: "oauthAccessToken",
|
|
data: {
|
|
accessToken: crypto.randomUUID(),
|
|
accessTokenExpiresAt: new Date(now.getTime() + 60_000),
|
|
clientId: registeredClient.client_id,
|
|
createdAt: now,
|
|
refreshToken: crypto.randomUUID(),
|
|
refreshTokenExpiresAt: new Date(now.getTime() + 120_000),
|
|
scopes: "openid profile",
|
|
updatedAt: now,
|
|
userId: sourceUser.user.id,
|
|
},
|
|
});
|
|
await sourceContext.adapter.create({
|
|
model: "oauthConsent",
|
|
data: {
|
|
clientId: registeredClient.client_id,
|
|
consentGiven: true,
|
|
createdAt: now,
|
|
scopes: "openid profile",
|
|
updatedAt: now,
|
|
userId: sourceUser.user.id,
|
|
},
|
|
});
|
|
db.exec("DELETE FROM session; DELETE FROM account;");
|
|
|
|
const auth17 = betterAuth({
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
plugins: [
|
|
jwt(),
|
|
oauthProvider({
|
|
consentPage: "/consent",
|
|
loginPage: "/login",
|
|
silenceWarnings: {
|
|
oauthAuthServerConfig: true,
|
|
openidConfig: true,
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
const migration = await getMigrations(auth17.options, {
|
|
legacyTableNames: {
|
|
oauthApplication: "legacyOAuthApplication",
|
|
},
|
|
});
|
|
|
|
expect(migration.migrationBlockers).toContainEqual({
|
|
code: "table-data-move",
|
|
migration: "1.7-provider-client-store",
|
|
sourceTable: "legacyOAuthApplication",
|
|
targetTable: "oauthClient",
|
|
});
|
|
expect(migration.migrationBlockers).toContainEqual({
|
|
code: "retired-table-data",
|
|
migration: "1.7-provider-token-store",
|
|
table: "oauthAccessToken",
|
|
});
|
|
expect(migration.migrationBlockers).toContainEqual({
|
|
code: "table-data-conversion",
|
|
conversion: "space-delimited-string-to-string-array",
|
|
migration: "1.7-provider-consent-store",
|
|
sourceTable: "oauthConsent",
|
|
targetTable: "oauthConsent",
|
|
});
|
|
expect(migration.migrationBlockers).not.toContainEqual(
|
|
expect.objectContaining({
|
|
code: "required-column-backfill",
|
|
table: "oauthAccessToken",
|
|
}),
|
|
);
|
|
await expect(migration.runMigrations()).rejects.toThrow(
|
|
'Migration blocked: move rows from retired table "legacyOAuthApplication" to "oauthClient" before applying the schema migration.',
|
|
);
|
|
expect(
|
|
db
|
|
.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'oauthClient'",
|
|
)
|
|
.get(),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("does not classify populated 1.7 token and consent tables as legacy", async () => {
|
|
const db = new Database(":memory:");
|
|
const auth17 = betterAuth({
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
plugins: [
|
|
jwt(),
|
|
oauthProvider({
|
|
consentPage: "/consent",
|
|
loginPage: "/login",
|
|
silenceWarnings: {
|
|
oauthAuthServerConfig: true,
|
|
openidConfig: true,
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
await (await getMigrations(auth17.options)).runMigrations();
|
|
const context = await auth17.$context;
|
|
const now = new Date();
|
|
const clientId = crypto.randomUUID();
|
|
await context.adapter.create({
|
|
model: "oauthClient",
|
|
data: {
|
|
clientId,
|
|
createdAt: now,
|
|
name: "Current fixture",
|
|
redirectUris: ["https://current-client.example/callback"],
|
|
updatedAt: now,
|
|
},
|
|
});
|
|
await context.adapter.create({
|
|
model: "oauthAccessToken",
|
|
data: {
|
|
clientId,
|
|
createdAt: now,
|
|
expiresAt: new Date(now.getTime() + 60_000),
|
|
scopes: ["openid", "profile"],
|
|
token: crypto.randomUUID(),
|
|
},
|
|
});
|
|
await context.adapter.create({
|
|
model: "oauthConsent",
|
|
data: {
|
|
clientId,
|
|
createdAt: now,
|
|
scopes: ["openid", "profile"],
|
|
updatedAt: now,
|
|
},
|
|
});
|
|
|
|
const migration = await getMigrations(auth17.options);
|
|
|
|
expect(migration.migrationBlockers).toEqual([]);
|
|
await expect(validateMigrationFrom16(auth17.options, {})).resolves.toEqual(
|
|
[],
|
|
);
|
|
});
|
|
});
|
|
|
|
async function createRenamedLegacyClientFixture(db: Database.Database) {
|
|
const auth1630 = betterAuth1630({
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
plugins: [
|
|
oidcProvider1630({
|
|
allowDynamicClientRegistration: true,
|
|
loginPage: "/login",
|
|
schema: {
|
|
oauthApplication: {
|
|
modelName: "legacyOAuthApplication",
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
await (await getMigrations1630(auth1630.options)).runMigrations();
|
|
const credentials = {
|
|
email: "renamed-table-admin@example.com",
|
|
name: "Renamed Table Admin",
|
|
password: "correct-horse-battery-staple",
|
|
};
|
|
await auth1630.api.signUpEmail({ body: credentials });
|
|
await auth1630.api.registerOAuthApplication({
|
|
body: {
|
|
client_name: "Renamed table fixture",
|
|
redirect_uris: ["https://client.example/callback"],
|
|
},
|
|
});
|
|
const options17: BetterAuthOptions = {
|
|
account: { identityStrategy: "provider-id" },
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
plugins: [
|
|
jwt(),
|
|
oauthProvider({
|
|
consentPage: "/consent",
|
|
loginPage: "/login",
|
|
silenceWarnings: {
|
|
oauthAuthServerConfig: true,
|
|
openidConfig: true,
|
|
},
|
|
}),
|
|
],
|
|
};
|
|
vi.spyOn(config, "getConfig").mockImplementation(async () => options17);
|
|
return { credentials };
|
|
}
|
|
|
|
function readBackupTableNames(db: Database.Database) {
|
|
return db
|
|
.prepare(
|
|
`SELECT name FROM sqlite_master
|
|
WHERE type = 'table' AND name LIKE '%__better_auth_1_6'
|
|
ORDER BY name`,
|
|
)
|
|
.all()
|
|
.map((table) => (table as { name: string }).name);
|
|
}
|
|
|
|
describe("migrate a customized 1.6.30 table name", () => {
|
|
it("proposes the renamed table instead of leaving its clients behind", async () => {
|
|
const db = new Database(":memory:");
|
|
await createRenamedLegacyClientFixture(db);
|
|
const processExit = vi
|
|
.spyOn(process, "exit")
|
|
.mockImplementation((code) => code as never);
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
try {
|
|
await migrateAction({
|
|
cwd: process.cwd(),
|
|
mode: "plan",
|
|
outputFormat: "json",
|
|
});
|
|
|
|
expect(processExit).not.toHaveBeenCalled();
|
|
expect(process.exitCode).toBe(1);
|
|
const jsonPlan = JSON.parse(String(consoleLog.mock.calls[0]?.[0])) as {
|
|
blockers: Array<Record<string, unknown>>;
|
|
};
|
|
expect(jsonPlan.blockers).toEqual([
|
|
{
|
|
candidateTables: ["legacyOAuthApplication"],
|
|
code: "legacy-table-candidate",
|
|
model: "oauthApplication",
|
|
remediation: {
|
|
docs: "https://better-auth.com/docs/guides/1-7-upgrade-guide#migrate-from-16-to-17",
|
|
summary:
|
|
'Record which table holds the 1.6 "oauthApplication" data under legacyTableNames in better-auth-migration.json, or null when none of them does, or run `auth migrate apply` in a terminal to answer it there.',
|
|
},
|
|
table: "oauthApplication",
|
|
},
|
|
]);
|
|
} finally {
|
|
process.exitCode = undefined;
|
|
}
|
|
});
|
|
|
|
it("migrates the renamed table recorded in the decisions file", async () => {
|
|
const db = new Database(":memory:");
|
|
await createRenamedLegacyClientFixture(db);
|
|
const plan = await writeMigrationDecisions({
|
|
formatVersion: 1,
|
|
migration: "1.6-to-1.7",
|
|
legacyTableNames: { oauthApplication: "legacyOAuthApplication" },
|
|
oauth: {
|
|
clientSecrets: { source: "plain", target: "hashed" },
|
|
consents: "reauthorize",
|
|
},
|
|
});
|
|
vi.spyOn(process, "exit").mockImplementation((code) => code as never);
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => {});
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
await migrateAction({
|
|
approved: true,
|
|
cwd: process.cwd(),
|
|
migrationFile: plan,
|
|
mode: "apply",
|
|
});
|
|
|
|
expect(consoleError).not.toHaveBeenCalledWith(
|
|
"Migration blocked. No database changes were applied.",
|
|
);
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
"🚀 migration was completed successfully!",
|
|
);
|
|
expect(
|
|
db.prepare("SELECT COUNT(*) AS count FROM oauthClient").get(),
|
|
).toEqual({ count: 1 });
|
|
expect(readBackupTableNames(db)).toContain(
|
|
"legacyOAuthApplication__better_auth_1_6",
|
|
);
|
|
});
|
|
|
|
it("migrates the renamed table confirmed in the interview", async () => {
|
|
const db = new Database(":memory:");
|
|
await createRenamedLegacyClientFixture(db);
|
|
const cwd = await createInterviewDirectory();
|
|
const restoreStdin = stubStdinIsTTY(true);
|
|
vi.spyOn(process, "exit").mockImplementation((code) => code as never);
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
vi.mocked(prompts)
|
|
.mockResolvedValueOnce({ isLegacyTable: true })
|
|
.mockResolvedValueOnce({ source: "plain" })
|
|
.mockResolvedValueOnce({ migrate: true });
|
|
|
|
try {
|
|
await migrateAction({ cwd, mode: "apply" });
|
|
} finally {
|
|
restoreStdin();
|
|
}
|
|
|
|
expect(prompts).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
message:
|
|
'Does "legacyOAuthApplication" hold your 1.6 oauthApplication data?',
|
|
name: "isLegacyTable",
|
|
type: "confirm",
|
|
}),
|
|
);
|
|
expect(await readRecordedDecisions(cwd)).toEqual({
|
|
formatVersion: 1,
|
|
migration: "1.6-to-1.7",
|
|
legacyTableNames: { oauthApplication: "legacyOAuthApplication" },
|
|
oauth: {
|
|
clientSecrets: { source: "plain", target: "hashed" },
|
|
consents: "reauthorize",
|
|
},
|
|
});
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
"->",
|
|
"move 1 OAuth client into oauthClient and hash the stored plaintext client secrets for the 1.7 provider",
|
|
);
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
"🚀 migration was completed successfully!",
|
|
);
|
|
expect(
|
|
db.prepare("SELECT COUNT(*) AS count FROM oauthClient").get(),
|
|
).toEqual({ count: 1 });
|
|
expect(readBackupTableNames(db)).toContain(
|
|
"legacyOAuthApplication__better_auth_1_6",
|
|
);
|
|
});
|
|
|
|
it("leaves a table the interview rejects untouched", async () => {
|
|
const db = new Database(":memory:");
|
|
await createRenamedLegacyClientFixture(db);
|
|
const cwd = await createInterviewDirectory();
|
|
const restoreStdin = stubStdinIsTTY(true);
|
|
vi.spyOn(process, "exit").mockImplementation((code) => code as never);
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
vi.mocked(prompts)
|
|
.mockResolvedValueOnce({ isLegacyTable: false })
|
|
.mockResolvedValueOnce({ migrate: true });
|
|
|
|
try {
|
|
await migrateAction({ cwd, mode: "apply" });
|
|
} finally {
|
|
restoreStdin();
|
|
}
|
|
|
|
expect(prompts).toHaveBeenCalledTimes(2);
|
|
expect(await readRecordedDecisions(cwd)).toEqual({
|
|
formatVersion: 1,
|
|
migration: "1.6-to-1.7",
|
|
legacyTableNames: { oauthApplication: null },
|
|
});
|
|
expect(consoleLog).toHaveBeenCalledWith(
|
|
"🚀 migration was completed successfully!",
|
|
);
|
|
expect(
|
|
db.prepare("SELECT COUNT(*) AS count FROM legacyOAuthApplication").get(),
|
|
).toEqual({ count: 1 });
|
|
expect(readBackupTableNames(db)).not.toContain(
|
|
"legacyOAuthApplication__better_auth_1_6",
|
|
);
|
|
expect(
|
|
db.prepare("SELECT COUNT(*) AS count FROM oauthClient").get(),
|
|
).toEqual({ count: 0 });
|
|
});
|
|
});
|
|
|
|
describe("migrate published 1.6.30 SCIM data", () => {
|
|
it("requires a reviewed reprovision before creating the replacement models", async () => {
|
|
const db = new Database(":memory:");
|
|
const auth1630 = betterAuth1630({
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
plugins: [scim1630()],
|
|
});
|
|
await (await getMigrations1630(auth1630.options)).runMigrations();
|
|
const credentials = {
|
|
email: "scim-admin@example.com",
|
|
name: "SCIM Admin",
|
|
password: "correct-horse-battery-staple",
|
|
};
|
|
await auth1630.api.signUpEmail({ body: credentials });
|
|
const signIn = await auth1630.api.signInEmail({
|
|
body: {
|
|
email: credentials.email,
|
|
password: credentials.password,
|
|
},
|
|
returnHeaders: true,
|
|
});
|
|
const cookie = signIn.headers.getSetCookie()[0];
|
|
if (!cookie) {
|
|
throw new Error("Expected the 1.6.30 sign-in to set a session cookie");
|
|
}
|
|
const generated = await auth1630.api.generateSCIMToken({
|
|
body: { providerId: "workforce-fixture" },
|
|
headers: { cookie },
|
|
});
|
|
expect(generated.scimToken).toBeTypeOf("string");
|
|
expect(
|
|
db.prepare("SELECT COUNT(*) AS count FROM scimProvider").get(),
|
|
).toEqual({ count: 1 });
|
|
|
|
db.exec("DELETE FROM session; DELETE FROM account;");
|
|
const migration = await getMigrations({
|
|
database: db,
|
|
plugins: [
|
|
scim({
|
|
connections: [
|
|
{
|
|
id: "workforce-fixture",
|
|
credentials: [
|
|
{
|
|
type: "bearer",
|
|
id: "fixture-token",
|
|
token: "fixture-token",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect(migration.migrationBlockers).toContainEqual({
|
|
code: "reprovision-data",
|
|
migration: "1.7-scim",
|
|
sourceTables: ["scimProvider"],
|
|
targetTables: [
|
|
"scimConnectionBinding",
|
|
"scimIdentityTombstone",
|
|
"scimSubject",
|
|
"scimUser",
|
|
"scimProjectionGrant",
|
|
"scimGroup",
|
|
"scimGroupMember",
|
|
],
|
|
});
|
|
await expect(migration.runMigrations()).rejects.toThrow(
|
|
'Migration blocked: back up and remove rows from retired SCIM table "scimProvider", then complete a full SCIM reprovision.',
|
|
);
|
|
expect(
|
|
db
|
|
.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'scimConnectionBinding'",
|
|
)
|
|
.get(),
|
|
).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("migrate published 1.6.30 organization team data", () => {
|
|
it("keeps legacy memberships and repairs the team counter on the first write", async () => {
|
|
const db = new Database(":memory:");
|
|
const auth1630 = betterAuth1630({
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
plugins: [
|
|
organization1630({
|
|
teams: {
|
|
enabled: true,
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
await (await getMigrations1630(auth1630.options)).runMigrations();
|
|
const owner = await auth1630.api.signUpEmail({
|
|
body: {
|
|
email: "team-owner@example.com",
|
|
name: "Team Owner",
|
|
password: "correct-horse-battery-staple",
|
|
},
|
|
});
|
|
const teammate = await auth1630.api.signUpEmail({
|
|
body: {
|
|
email: "team-member@example.com",
|
|
name: "Team Member",
|
|
password: "correct-horse-battery-staple",
|
|
},
|
|
});
|
|
const sourceOrganization = await auth1630.api.createOrganization({
|
|
body: {
|
|
name: "Migration fixture",
|
|
slug: "migration-fixture",
|
|
userId: owner.user.id,
|
|
},
|
|
});
|
|
const sourceTeam = db
|
|
.prepare("SELECT id FROM team WHERE organizationId = ?")
|
|
.get(sourceOrganization?.id) as { id: string };
|
|
expect(
|
|
db
|
|
.prepare(
|
|
"SELECT COUNT(*) AS count FROM teamMember WHERE teamId = ? AND userId = ?",
|
|
)
|
|
.get(sourceTeam.id, owner.user.id),
|
|
).toEqual({ count: 1 });
|
|
|
|
backfill1630CredentialAccountIdentity(db);
|
|
enforce1630CredentialAccountIdentityConstraints(db);
|
|
const organizationOptions = {
|
|
teams: {
|
|
enabled: true as const,
|
|
maximumMembersPerTeam: 2,
|
|
},
|
|
};
|
|
const auth17 = betterAuth({
|
|
account: { identityStrategy: "provider-id" },
|
|
baseURL: "http://localhost:3000",
|
|
database: db,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
plugins: [organization(organizationOptions)],
|
|
});
|
|
const migration = await getMigrations(auth17.options);
|
|
|
|
expect(migration.migrationBlockers).toEqual([]);
|
|
await migration.runMigrations();
|
|
expect(
|
|
db
|
|
.prepare("SELECT memberCount FROM team WHERE id = ?")
|
|
.get(sourceTeam.id),
|
|
).toEqual({ memberCount: 0 });
|
|
expect(
|
|
db
|
|
.prepare(
|
|
"SELECT membershipKey FROM teamMember WHERE teamId = ? AND userId = ?",
|
|
)
|
|
.get(sourceTeam.id, owner.user.id),
|
|
).toEqual({ membershipKey: null });
|
|
|
|
const added = await auth17.api.addMember({
|
|
body: {
|
|
organizationId: sourceOrganization?.id,
|
|
role: "member",
|
|
teamId: sourceTeam.id,
|
|
userId: teammate.user.id,
|
|
},
|
|
});
|
|
expect(added?.userId).toBe(teammate.user.id);
|
|
expect(
|
|
db
|
|
.prepare("SELECT memberCount FROM team WHERE id = ?")
|
|
.get(sourceTeam.id),
|
|
).toEqual({ memberCount: 2 });
|
|
expect(
|
|
db
|
|
.prepare("SELECT COUNT(*) AS count FROM teamMember WHERE teamId = ?")
|
|
.get(sourceTeam.id),
|
|
).toEqual({ count: 2 });
|
|
});
|
|
});
|