Release v2.20.1: migrate authentication to Better Auth
This commit is contained in:
1 parent
e9a53f577c
commit
fba4f99ff7
92 files changed
+5085
-1943
No files matched your search
@@ -5,6 +5,13 @@ require('../src/common');
|
||||
|
||||
const { getPendingMigrations } = require('../src/db/migrations');
|
||||
|
||||
async function getAuthMigrationStep(pool) {
|
||||
const pending = await getPendingMigrations(pool, { currentVersion: '2.19.1' });
|
||||
const migration = pending.find(item => item.version === '2.20.0');
|
||||
assert.ok(migration, 'Expected the consolidated 2.20.0 migration');
|
||||
return migration;
|
||||
}
|
||||
|
||||
function createPool(responses) {
|
||||
const queries = [];
|
||||
return {
|
||||
@@ -22,6 +29,41 @@ function createPool(responses) {
|
||||
};
|
||||
}
|
||||
|
||||
test('role two-factor migration adds an opt-in requirement for existing and fresh schemas', async () => {
|
||||
const pool = createPool([]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await migration.run(pool);
|
||||
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE a_roles ADD COLUMN require_two_factor TINYINT(1) NOT NULL DEFAULT 0')));
|
||||
const fs = require('node:fs');
|
||||
assert.match(fs.readFileSync(require.resolve('../src/db/index.js'), 'utf8'), /require_two_factor TINYINT\(1\) NOT NULL DEFAULT 0/);
|
||||
});
|
||||
|
||||
test('passkey credential storage is defined once in the current schema snapshot', async () => {
|
||||
const pool = createPool([]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await migration.run(pool);
|
||||
const statements = pool.queries.map(([sql]) => String(sql));
|
||||
assert.equal(statements.some(sql => /CREATE TABLE IF NOT EXISTS a_(?:user_)?passkeys/.test(sql)), false);
|
||||
assert.equal(statements.some(sql => /(?:RENAME TABLE a_passkeys|INSERT IGNORE INTO a_user_passkeys)/.test(sql)), false);
|
||||
const fs = require('node:fs');
|
||||
const schema = fs.readFileSync(require.resolve('../src/db/index.js'), 'utf8');
|
||||
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_user_passkeys/);
|
||||
assert.match(schema, /credential_id VARCHAR\(512\) NOT NULL/);
|
||||
});
|
||||
|
||||
test('the consolidated migration does not create and then rename user auth tables', async () => {
|
||||
const pool = createPool([]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await migration.run(pool);
|
||||
const statements = pool.queries.map(([sql]) => String(sql));
|
||||
assert.equal(statements.some(sql => /CREATE TABLE IF NOT EXISTS a_(?:two_factors|passkeys)/.test(sql)), false);
|
||||
assert.equal(statements.some(sql => /(?:RENAME TABLE a_(?:two_factors|passkeys)|INSERT IGNORE INTO a_user_(?:two_factors|passkeys))/.test(sql)), false);
|
||||
const fs = require('node:fs');
|
||||
const schema = fs.readFileSync(require.resolve('../src/db/index.js'), 'utf8');
|
||||
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_user_two_factors/);
|
||||
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_user_passkeys/);
|
||||
});
|
||||
|
||||
test('pending migrations are empty when the schema already matches the app version', async () => {
|
||||
const pool = createPool([
|
||||
{
|
||||
@@ -49,6 +91,336 @@ test('pending migrations are empty when the schema already matches the app versi
|
||||
assert.equal(pendingMigrations.length, 0);
|
||||
});
|
||||
|
||||
test('2.20.1 release follows the consolidated 2.20.0 migration', async () => {
|
||||
const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.19.1' });
|
||||
assert.deepEqual(pending.map(migration => migration.version), ['2.20.0', '2.20.1']);
|
||||
});
|
||||
|
||||
test('2.20.1 removes retired security setting rows', async () => {
|
||||
const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.20.0' });
|
||||
const migration = pending.find(item => item.version === '2.20.1');
|
||||
assert.ok(migration, 'Expected the 2.20.1 security settings cleanup migration');
|
||||
|
||||
const pool = createPool([]);
|
||||
await migration.run(pool);
|
||||
|
||||
assert.deepEqual(pool.queries, [[
|
||||
'DELETE FROM o_app_settings WHERE setting_key IN (?, ?, ?, ?, ?)',
|
||||
[
|
||||
'security.session_lifetime_days',
|
||||
'security.max_active_sessions',
|
||||
'security.login_max_attempts',
|
||||
'security.login_lockout_minutes',
|
||||
'security.login_rate_limit_scope'
|
||||
]
|
||||
]]);
|
||||
});
|
||||
|
||||
test('Better Auth migration extends app users and sessions with auth account tables', async () => {
|
||||
const pool = createPool([
|
||||
{
|
||||
match(sql, params) {
|
||||
return sql.includes('information_schema.COLUMNS') && params && params[0] === 'a_users' && params[1] === 'email_verified_at';
|
||||
},
|
||||
result: [[{ column_count: 1 }]]
|
||||
},
|
||||
{
|
||||
match(sql) {
|
||||
return sql.includes('SELECT id, email, email_verified_at FROM a_users');
|
||||
},
|
||||
result: [[
|
||||
{ id: 1, email: 'first@example.com', email_verified_at: new Date() },
|
||||
{ id: 2, email: 'second@example.com', email_verified_at: new Date() },
|
||||
{ id: 3, email: 'verified@example.com', email_verified_at: new Date() },
|
||||
{ id: 4, email: 'pending@example.com', email_verified_at: null }
|
||||
]]
|
||||
}
|
||||
]);
|
||||
const authMigration = await getAuthMigrationStep(pool);
|
||||
await authMigration.run(pool);
|
||||
|
||||
const statements = pool.queries.map(([sql]) => String(sql));
|
||||
const schema = require('node:fs').readFileSync(require.resolve('../src/db/index.js'), 'utf8');
|
||||
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_accounts/);
|
||||
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_verifications/);
|
||||
assert.equal(statements.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS a_accounts')), false);
|
||||
assert.equal(statements.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS a_verifications')), false);
|
||||
assert.ok(statements.some(sql => sql.includes('ADD COLUMN email_verified')));
|
||||
assert.ok(statements.some(sql => sql.includes('ADD COLUMN last_request')));
|
||||
assert.ok(statements.includes('DELETE FROM a_sessions'));
|
||||
assert.equal(statements.some(sql => sql.includes('CREATE TRIGGER')), false);
|
||||
const emailUpdates = pool.queries
|
||||
.filter(([sql]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?'))
|
||||
.map(([, params]) => params);
|
||||
assert.deepEqual(emailUpdates, [
|
||||
['first@example.com', true, 1],
|
||||
['second@example.com', true, 2],
|
||||
['verified@example.com', true, 3],
|
||||
['pending@example.com', false, 4]
|
||||
]);
|
||||
assert.equal(statements.some(sql => sql.includes('auth_email')), false);
|
||||
assert.equal(statements.some(sql => sql.includes('uq_a_users_auth_email')), false);
|
||||
assert.equal(statements.some(sql => sql.includes('app-user:%')), false);
|
||||
assert.doesNotMatch(schema, /auth_email/);
|
||||
});
|
||||
|
||||
test('consolidated Better Auth migration removes legacy password columns after credential migration', async () => {
|
||||
const pool = createPool([
|
||||
{ match: sql => sql.includes('information_schema.COLUMNS'), result: [[{ column_count: 1 }]] }
|
||||
]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await migration.run(pool);
|
||||
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE a_users DROP COLUMN password_hash, DROP COLUMN password_salt, DROP COLUMN password_iterations')));
|
||||
assert.equal(pool.queries.some(([sql]) => String(sql).includes('MODIFY COLUMN password_hash CHAR(64) NULL')), false);
|
||||
});
|
||||
|
||||
test('Better Auth passwordless migration tolerates fresh schemas without legacy password columns', async () => {
|
||||
const pool = createPool([
|
||||
{
|
||||
match(sql, params) {
|
||||
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users';
|
||||
},
|
||||
result: [[{ column_count: 0 }]]
|
||||
},
|
||||
{ match(sql) { return sql.includes('information_schema.COLUMNS'); }, result: [[{ column_count: 1 }]] }
|
||||
]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await migration.run(pool);
|
||||
assert.equal(pool.queries.some(([sql]) => String(sql).includes('MODIFY COLUMN password_hash')), false);
|
||||
});
|
||||
|
||||
test('Email OTP migration removes obsolete link and lockout storage only', async () => {
|
||||
const pool = createPool([
|
||||
{
|
||||
match(sql) { return sql.includes('information_schema.COLUMNS'); },
|
||||
result: [[{ column_count: 1 }]]
|
||||
},
|
||||
{
|
||||
match(_sql, params) { return Array.isArray(params) && params[0] === 'a_account_tokens'; },
|
||||
result: [[{ table_count: 1 }]]
|
||||
},
|
||||
{
|
||||
match(sql) { return sql.includes('SELECT LOWER(TRIM(email)) AS email_key'); },
|
||||
result: [[]]
|
||||
},
|
||||
{
|
||||
match() { return true; },
|
||||
result: [[{ table_count: 0 }]]
|
||||
}
|
||||
]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await migration.run(pool);
|
||||
|
||||
const statements = pool.queries.map(([sql]) => String(sql));
|
||||
assert.ok(statements.some(sql => sql.includes('DROP COLUMN pending_email_token_hash')));
|
||||
assert.ok(statements.some(sql => sql.includes('DROP COLUMN pending_email_expires_at')));
|
||||
assert.ok(statements.some(sql => sql.includes('DROP COLUMN last_login_at')));
|
||||
assert.ok(statements.some(sql => sql.includes('DROP COLUMN last_login_ip')));
|
||||
assert.ok(statements.some(sql => sql.includes('DROP COLUMN last_failed_at')));
|
||||
assert.ok(statements.some(sql => sql.includes('DROP TABLE a_account_tokens')));
|
||||
assert.ok(statements.some(sql => sql.includes("DELETE FROM o_app_settings WHERE setting_key IN ('email.verification_button_alignment'")));
|
||||
assert.ok(!statements.some(sql => sql.includes('DROP TABLE a_verifications')));
|
||||
});
|
||||
|
||||
test('follow-up migration removes unused admin last-login fields', async () => {
|
||||
const pool = createPool([
|
||||
{
|
||||
match(sql) { return sql.includes('information_schema.COLUMNS'); },
|
||||
result: [[{ column_count: 1 }]]
|
||||
}
|
||||
]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await migration.run(pool);
|
||||
|
||||
const alterations = pool.queries.map(([sql]) => String(sql));
|
||||
assert.ok(alterations.includes('ALTER TABLE a_users DROP COLUMN last_login_at'));
|
||||
assert.ok(alterations.includes('ALTER TABLE a_users DROP COLUMN last_login_ip'));
|
||||
});
|
||||
|
||||
test('OTP expiry migration consolidates old settings and removes unused session creator', async () => {
|
||||
const pool = createPool([
|
||||
{
|
||||
match(sql, params) {
|
||||
return sql.includes('SELECT setting_key, setting_value FROM o_app_settings') && params[0] === 'email.otp_expiry_minutes';
|
||||
},
|
||||
result: [[
|
||||
{ setting_key: 'email.verification_expiry_minutes', setting_value: 8 },
|
||||
{ setting_key: 'email.reset_expiry_minutes', setting_value: 15 }
|
||||
]]
|
||||
},
|
||||
{
|
||||
match(sql, params) {
|
||||
return sql.includes('information_schema.COLUMNS') && params[0] === 'a_sessions' && params[1] === 'created_by';
|
||||
},
|
||||
result: [[{ column_count: 1 }]]
|
||||
}
|
||||
]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await migration.run(pool);
|
||||
|
||||
assert.ok(pool.queries.some(([sql, params]) => String(sql).includes('INSERT INTO o_app_settings') && params[0] === 'email.otp_expiry_minutes' && params[1] === '8'));
|
||||
assert.ok(pool.queries.some(([sql]) => String(sql).includes('DELETE FROM o_app_settings WHERE setting_key IN (?, ?)')));
|
||||
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE a_sessions DROP COLUMN created_by')));
|
||||
});
|
||||
|
||||
test('per-user password policy migrations remove both old global settings', async () => {
|
||||
const pool = createPool([]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await migration.run(pool);
|
||||
const deletedSettingKeys = pool.queries.filter(([sql]) => String(sql).includes('DELETE FROM o_app_settings WHERE setting_key = ?')).map(([, params]) => params[0]);
|
||||
assert.ok(deletedSettingKeys.includes('security.require_password_change_after_admin_reset'));
|
||||
assert.ok(deletedSettingKeys.includes('security.require_password_change_for_new_users'));
|
||||
});
|
||||
|
||||
test('canonical email migration backfills synthetic addresses and drops duplicate email fields', async () => {
|
||||
const pool = createPool([
|
||||
{
|
||||
match(sql) { return sql.includes('information_schema.COLUMNS'); },
|
||||
result: [[{ column_count: 1 }]]
|
||||
},
|
||||
{
|
||||
match(sql) {
|
||||
return sql.includes('SELECT LOWER(TRIM(email)) AS email_key');
|
||||
},
|
||||
result: [[]]
|
||||
},
|
||||
{
|
||||
match(sql, params) {
|
||||
return sql.includes('SELECT id, email, email_verified_at FROM a_users') && params === undefined;
|
||||
},
|
||||
result: [[
|
||||
{ id: 5, email: null, email_verified_at: null },
|
||||
{ id: 6, email: 'verified@example.test', email_verified_at: new Date() }
|
||||
]]
|
||||
},
|
||||
{
|
||||
match(sql) { return sql.includes('information_schema.STATISTICS'); },
|
||||
result: [[{ index_count: 0 }]]
|
||||
}
|
||||
]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await migration.run(pool);
|
||||
|
||||
const queries = pool.queries;
|
||||
assert.ok(queries.some(([sql, params]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?') && params[0] === 'app-user-5@identity.invalid' && params[1] === false));
|
||||
assert.ok(queries.some(([sql, params]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?') && params[0] === 'verified@example.test' && params[1] === true));
|
||||
assert.equal(queries.some(([sql]) => String(sql).includes('auth_email')), false);
|
||||
assert.ok(queries.some(([sql]) => String(sql).includes('ALTER TABLE a_users DROP COLUMN email_verified_at')));
|
||||
});
|
||||
|
||||
test('canonical email migration refuses duplicate real email addresses', async () => {
|
||||
const pool = createPool([
|
||||
{ match: sql => sql.includes('information_schema.COLUMNS'), result: [[{ column_count: 1 }]] },
|
||||
{
|
||||
match: sql => sql.includes('SELECT LOWER(TRIM(email)) AS email_key'),
|
||||
result: [[{ email_key: 'duplicate@example.test', duplicate_count: 2 }]]
|
||||
}
|
||||
]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await assert.rejects(migration.run(pool), /duplicate user email addresses/);
|
||||
});
|
||||
|
||||
test('canonical email migration preserves email_verified on fresh schemas', async () => {
|
||||
const pool = createPool([
|
||||
{
|
||||
match(sql, params) {
|
||||
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users' && params[1] === 'email_verified_at';
|
||||
},
|
||||
result: [[{ column_count: 0 }]]
|
||||
},
|
||||
{
|
||||
match(sql, params) {
|
||||
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users' && params[1] === 'auth_email';
|
||||
},
|
||||
result: [[{ column_count: 0 }]]
|
||||
},
|
||||
{
|
||||
match(sql) { return sql.includes('information_schema.COLUMNS'); },
|
||||
result: [[{ column_count: 1 }]]
|
||||
},
|
||||
{
|
||||
match(sql) { return sql.includes('SELECT LOWER(TRIM(email)) AS email_key'); },
|
||||
result: [[]]
|
||||
},
|
||||
{
|
||||
match(sql) { return sql.includes('SELECT id, email, email_verified FROM a_users'); },
|
||||
result: [[{ id: 9, email: 'canonical@example.test', email_verified: 1 }]]
|
||||
},
|
||||
{
|
||||
match(sql, params) { return sql.includes('information_schema.STATISTICS') && Array.isArray(params) && params[1] === 'uq_a_users_auth_email'; },
|
||||
result: [[{ index_count: 0 }]]
|
||||
},
|
||||
{
|
||||
match(sql) { return sql.includes("COLUMN_NAME = 'email'"); },
|
||||
result: [[{ index_count: 1 }]]
|
||||
}
|
||||
]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await migration.run(pool);
|
||||
|
||||
assert.ok(pool.queries.some(([sql, params]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?') && params[0] === 'canonical@example.test' && params[1] === true));
|
||||
assert.equal(pool.queries.some(([sql]) => String(sql).includes('DROP COLUMN email_verified_at')), false);
|
||||
});
|
||||
|
||||
test('credential migration moves existing password records into Better Auth accounts', async () => {
|
||||
const passwordHash = 'a'.repeat(64);
|
||||
const passwordSalt = 'b'.repeat(32);
|
||||
const pool = createPool([
|
||||
{ match: sql => sql.includes('information_schema.COLUMNS'), result: [[{ column_count: 1 }]] },
|
||||
{
|
||||
match: sql => sql.includes('SELECT id, password_hash, password_salt, password_iterations, created_at, modified_at FROM a_users'),
|
||||
result: [[
|
||||
{ id: 12, password_hash: passwordHash, password_salt: passwordSalt, password_iterations: 310000, created_at: new Date(), modified_at: new Date() },
|
||||
{ id: 13, password_hash: null, password_salt: null, password_iterations: null, created_at: new Date(), modified_at: new Date() }
|
||||
]]
|
||||
}
|
||||
]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await migration.run(pool);
|
||||
|
||||
assert.ok(pool.queries.some(([sql, params]) => String(sql).includes('INSERT INTO a_accounts') && params[3] === 'pbkdf2-sha256$310000$' + passwordSalt + '$' + passwordHash));
|
||||
assert.equal(pool.queries.some(([sql, params]) => String(sql).includes('INSERT INTO a_accounts') && params[2] === 13), false);
|
||||
assert.ok(pool.queries.some(([sql]) => String(sql).includes('DROP COLUMN password_hash, DROP COLUMN password_salt, DROP COLUMN password_iterations')));
|
||||
});
|
||||
|
||||
test('credential migration leaves fresh Better Auth schemas without legacy password columns intact', async () => {
|
||||
const pool = createPool([
|
||||
{
|
||||
match(sql, params) {
|
||||
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users';
|
||||
},
|
||||
result: [[{ column_count: 0 }]]
|
||||
},
|
||||
{ match(sql) { return sql.includes('information_schema.COLUMNS'); }, result: [[{ column_count: 1 }]] }
|
||||
]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await migration.run(pool);
|
||||
assert.equal(pool.queries.some(([sql]) => String(sql).includes('SELECT id, password_hash, password_salt')), false);
|
||||
assert.equal(pool.queries.some(([sql]) => String(sql).includes('DROP COLUMN password_hash')), false);
|
||||
});
|
||||
|
||||
test('invitation removal migration drops the legacy table, permissions, and settings', async () => {
|
||||
const pool = createPool([]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await migration.run(pool);
|
||||
|
||||
const statements = pool.queries.map(([sql]) => String(sql));
|
||||
assert.ok(statements.includes('DROP TABLE IF EXISTS a_user_invitations'));
|
||||
assert.ok(statements.some(sql => sql.includes('DELETE FROM a_role_permissions') && sql.includes("permission_key LIKE 'invitations.%'")));
|
||||
assert.ok(statements.some(sql => sql.includes("DELETE FROM a_permissions WHERE permission_key LIKE 'invitations.%'")));
|
||||
assert.ok(statements.some(sql => sql.includes("DELETE FROM o_app_settings WHERE setting_key LIKE 'email.invitation_%'")));
|
||||
});
|
||||
|
||||
test('Better Auth two-factor migration adds user state while the table comes from the schema snapshot', async () => {
|
||||
const pool = createPool([]);
|
||||
const migration = await getAuthMigrationStep(pool);
|
||||
await migration.run(pool);
|
||||
|
||||
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ADD COLUMN two_factor_enabled')));
|
||||
assert.equal(pool.queries.some(([sql]) => String(sql).includes('CREATE TABLE IF NOT EXISTS a_two_factors')), false);
|
||||
assert.match(require('node:fs').readFileSync(require.resolve('../src/db/index.js'), 'utf8'), /CREATE TABLE IF NOT EXISTS a_user_two_factors/);
|
||||
});
|
||||
|
||||
test('query index migration upgrades a 2.17.4 installation', async () => {
|
||||
const queries = [];
|
||||
const pool = {
|
||||
|
||||
Reference in new issue
Block a user