From a9c89e48288b3a1a863c0763f5fdfcbce84d0711 Mon Sep 17 00:00:00 2001 From: Kevin Gatera Date: Sun, 2 Aug 2026 18:39:42 -0400 Subject: [PATCH] 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. --- .../v2.35.0-add-last-refresh-token.js | 12 ++- .../v2.35.0-add-last-refresh-token.test.js | 98 +++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 test/server/migrations/v2.35.0-add-last-refresh-token.test.js diff --git a/server/migrations/v2.35.0-add-last-refresh-token.js b/server/migrations/v2.35.0-add-last-refresh-token.js index 0ad190e9a..a689c2914 100644 --- a/server/migrations/v2.35.0-add-last-refresh-token.js +++ b/server/migrations/v2.35.0-add-last-refresh-token.js @@ -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 { diff --git a/test/server/migrations/v2.35.0-add-last-refresh-token.test.js b/test/server/migrations/v2.35.0-add-last-refresh-token.test.js new file mode 100644 index 000000000..7074c9577 --- /dev/null +++ b/test/server/migrations/v2.35.0-add-last-refresh-token.test.js @@ -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) + }) + }) +})