make v2.35.0 migration column checks case-insensitive

describeTable on a migrated postgres database returns lowercase column
names, so the camelCase existence checks never matched and a replay
attempted addColumn twice, failing the migration. Match column names
case-insensitively like the other postgres-hardened migrations.
This commit is contained in:
Kevin Gatera 2026-08-02 18:39:42 -04:00
parent 3e86d6ef50
commit 65bcc8488a
No known key found for this signature in database
GPG key ID: F0D9F5932458CFB9
2 changed files with 106 additions and 4 deletions

View file

@ -22,8 +22,10 @@ async function up({ context: { queryInterface, logger } }) {
if (await queryInterface.tableExists('sessions')) {
const tableDescription = await queryInterface.describeTable('sessions')
// Postgres folds unquoted identifiers to lowercase, so match column names case-insensitively
const hasColumn = (name) => Object.keys(tableDescription).some((column) => column.toLowerCase() === name.toLowerCase())
if (!tableDescription.lastRefreshToken) {
if (!hasColumn('lastRefreshToken')) {
logger.info(`${loggerPrefix} Adding lastRefreshToken column to sessions table`)
await queryInterface.addColumn('sessions', 'lastRefreshToken', {
type: queryInterface.sequelize.Sequelize.DataTypes.STRING,
@ -33,7 +35,7 @@ async function up({ context: { queryInterface, logger } }) {
logger.info(`${loggerPrefix} lastRefreshToken column already exists in sessions table`)
}
if (!tableDescription.lastRefreshTokenExpiresAt) {
if (!hasColumn('lastRefreshTokenExpiresAt')) {
logger.info(`${loggerPrefix} Adding lastRefreshTokenExpiresAt column to sessions table`)
await queryInterface.addColumn('sessions', 'lastRefreshTokenExpiresAt', {
type: queryInterface.sequelize.Sequelize.DataTypes.DATE,
@ -60,15 +62,17 @@ async function down({ context: { queryInterface, logger } }) {
if (await queryInterface.tableExists('sessions')) {
const tableDescription = await queryInterface.describeTable('sessions')
// Postgres folds unquoted identifiers to lowercase, so match column names case-insensitively
const hasColumn = (name) => Object.keys(tableDescription).some((column) => column.toLowerCase() === name.toLowerCase())
if (tableDescription.lastRefreshToken) {
if (hasColumn('lastRefreshToken')) {
logger.info(`${loggerPrefix} Removing lastRefreshToken column from sessions table`)
await queryInterface.removeColumn('sessions', 'lastRefreshToken')
} else {
logger.info(`${loggerPrefix} lastRefreshToken column does not exist in sessions table`)
}
if (tableDescription.lastRefreshTokenExpiresAt) {
if (hasColumn('lastRefreshTokenExpiresAt')) {
logger.info(`${loggerPrefix} Removing lastRefreshTokenExpiresAt column from sessions table`)
await queryInterface.removeColumn('sessions', 'lastRefreshTokenExpiresAt')
} else {

View file

@ -0,0 +1,98 @@
const { expect } = require('chai')
const sinon = require('sinon')
const { up, down } = require('../../../server/migrations/v2.35.0-add-last-refresh-token')
const { Sequelize } = require('sequelize')
const Logger = require('../../../server/Logger')
describe('migration-v2.35.0-add-last-refresh-token', () => {
let sequelize
let queryInterface
let loggerInfoStub
beforeEach(() => {
sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
queryInterface = sequelize.getQueryInterface()
loggerInfoStub = sinon.stub(Logger, 'info')
})
afterEach(() => {
sinon.restore()
})
describe('up', () => {
beforeEach(async () => {
await queryInterface.createTable('sessions', {
id: { type: Sequelize.UUID, primaryKey: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false }
})
})
it('should add lastRefreshToken columns when they do not exist', async () => {
await up({ context: { queryInterface, logger: Logger } })
const tableDescription = await queryInterface.describeTable('sessions')
expect(tableDescription.lastRefreshToken).to.exist
expect(tableDescription.lastRefreshTokenExpiresAt).to.exist
})
it('should not fail when the columns already exist', async () => {
await up({ context: { queryInterface, logger: Logger } })
await up({ context: { queryInterface, logger: Logger } })
const tableDescription = await queryInterface.describeTable('sessions')
expect(tableDescription.lastRefreshToken).to.exist
expect(tableDescription.lastRefreshTokenExpiresAt).to.exist
expect(loggerInfoStub.calledWithMatch('lastRefreshToken column already exists')).to.equal(true)
})
it('should detect existing columns case-insensitively for postgres identifier folding', async () => {
// Postgres folds unquoted identifiers to lowercase, so describeTable on a
// migrated postgres database returns lowercase column names
const fakeQueryInterface = {
sequelize,
tableExists: async () => true,
describeTable: async () => ({
id: {},
lastrefreshtoken: {},
lastrefreshtokenexpiresat: {}
}),
addColumn: async () => {
throw new Error('addColumn must not be called for existing lowercase columns')
}
}
await up({ context: { queryInterface: fakeQueryInterface, logger: Logger } })
expect(loggerInfoStub.calledWithMatch('lastRefreshToken column already exists')).to.equal(true)
expect(loggerInfoStub.calledWithMatch('lastRefreshTokenExpiresAt column already exists')).to.equal(true)
})
})
describe('down', () => {
beforeEach(async () => {
await queryInterface.createTable('sessions', {
id: { type: Sequelize.UUID, primaryKey: true },
lastRefreshToken: { type: Sequelize.STRING, allowNull: true },
lastRefreshTokenExpiresAt: { type: Sequelize.DATE, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false }
})
})
it('should remove lastRefreshToken columns when they exist', async () => {
await down({ context: { queryInterface, logger: Logger } })
const tableDescription = await queryInterface.describeTable('sessions')
expect(tableDescription.lastRefreshToken).to.not.exist
expect(tableDescription.lastRefreshTokenExpiresAt).to.not.exist
})
it('should not fail when the columns do not exist', async () => {
await down({ context: { queryInterface, logger: Logger } })
await down({ context: { queryInterface, logger: Logger } })
expect(loggerInfoStub.calledWithMatch('lastRefreshToken column does not exist')).to.equal(true)
})
})
})