discourse/spec/lib/migration/column_dropper_spec.rb
Régis Hanol 2623d7a607
FIX: Add table_schema filter to mark_readonly default check (#37037)
## Problem

`Migration::ColumnDropper.mark_readonly` could incorrectly detect a default value from a non-public schema table.

The query checking for column defaults did not filter by `table_schema`:

```sql
SELECT column_default IS NOT NULL
FROM information_schema.columns
WHERE table_name = :table_name
AND column_name = :column_name
```

When multiple schemas contain tables with the same name (e.g., from backup/restore operations), this query returns multiple rows. The code uses `.first`, making behavior dependent on PostgreSQL's row ordering.

## Root Cause

Don's diagnostic queries on an affected instance confirmed the issue:

```plain
table_schema |        column_name        | column_default
--------------+---------------------------+----------------
backup       | discourse_rewind_disabled | false    ← returned first
public       | discourse_rewind_disabled | false
```

The `backup` schema (from a previous `pg_dump`) contained a copy of `user_options` with the old default. PostgreSQL returned this row first, causing `mark_readonly` to fail with "You must drop a column's default value before marking it as readonly".

## Fix

Just a oneline -> adding `table_schema = 'public'` to the `WHERE` clause to ensure only the `public` schema is considered.

## Why it couldn't be reproduced locally?

- Fresh dev databases don't have `backup` schemas
- CI environments use clean databases
- Row ordering depends on database history (OIDs, backup/restore cycles)

Ref - https://meta.discourse.org/t/393049
2026-01-10 21:57:31 +01:00

136 lines
4.2 KiB
Ruby

# frozen_string_literal: true
RSpec.describe Migration::ColumnDropper do
def has_column?(table, column)
DB.exec(<<~SQL, table: table, column: column) == 1
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE
table_schema = 'public' AND
table_name = :table AND
column_name = :column
SQL
end
describe ".execute_drop" do
let(:columns) { %w[junk junk2] }
before { columns.each { |column| DB.exec("ALTER TABLE topics ADD COLUMN #{column} int") } }
after do
columns.each { |column| DB.exec("ALTER TABLE topics DROP COLUMN IF EXISTS #{column}") }
end
it "drops the columns" do
Migration::ColumnDropper.execute_drop("topics", columns)
columns.each { |column| expect(has_column?("topics", column)).to eq(false) }
end
end
describe ".mark_readonly" do
it "only checks the public schema for default values" do
DB.exec <<~SQL
CREATE TABLE test_public_schema (id INTEGER, col TEXT);
CREATE SCHEMA other_schema;
CREATE TABLE other_schema.test_public_schema (id INTEGER, col TEXT DEFAULT 'has_default');
SQL
# Stub to reverse query results, simulating a database where non-public
# schemas come first (e.g., due to backup/restore OID ordering).
# Without the table_schema='public' fix, this causes mark_readonly to
# incorrectly see a default value from the other schema.
original_method = DB.method(:query_single)
allow(DB).to receive(:query_single) do |*args, **kwargs|
results = original_method.call(*args, **kwargs)
results.reverse
end
expect { described_class.mark_readonly("test_public_schema", "col") }.not_to raise_error
described_class.drop_readonly("test_public_schema", "col")
ensure
DB.exec "DROP SCHEMA IF EXISTS other_schema CASCADE"
DB.exec "DROP TABLE IF EXISTS test_public_schema CASCADE"
end
end
describe ".mark_readonly" do
let(:table_name) { "table_with_readonly_column" }
before do
DB.exec <<~SQL
CREATE TABLE #{table_name} (topic_id INTEGER, email TEXT);
INSERT INTO #{table_name} (topic_id, email)
VALUES (1, 'something@email.com');
SQL
Migration::ColumnDropper.mark_readonly(table_name, "email")
end
after do
ActiveRecord::Base.connection.reset!
DB.exec <<~SQL
DROP TABLE IF EXISTS #{table_name};
DROP FUNCTION IF EXISTS #{Migration::BaseDropper.readonly_function_name(table_name, "email")} CASCADE;
SQL
end
it "should be droppable" do
Migration::ColumnDropper.execute_drop(table_name, ["email"])
expect(has_trigger?(Migration::BaseDropper.readonly_trigger_name(table_name, "email"))).to eq(
false,
)
expect(has_column?(table_name, "email")).to eq(false)
end
it "should prevent updates to the readonly column" do
begin
DB.exec <<~SQL
UPDATE #{table_name}
SET email = 'testing@email.com'
WHERE topic_id = 1;
SQL
rescue PG::RaiseException => e
[
"Discourse: email in #{table_name} is readonly",
"discourse_functions.raise_table_with_readonly_column_email_readonly()",
].each { |message| expect(e.message).to include(message) }
end
end
it "should allow updates to the other columns" do
DB.exec <<~SQL
UPDATE #{table_name}
SET topic_id = 2
WHERE topic_id = 1
SQL
expect(DB.query("SELECT * FROM #{table_name};").first.values).to include 2,
"something@email.com"
end
it "should prevent insertions to the readonly column" do
expect do ActiveRecord::Base.connection.raw_connection.exec <<~SQL end.to raise_error(
INSERT INTO #{table_name} (topic_id, email)
VALUES (2, 'something@email.com');
SQL
PG::RaiseException,
/Discourse: email in table_with_readonly_column is readonly/,
)
end
it "should allow insertions to the other columns" do
DB.exec <<~SQL
INSERT INTO #{table_name} (topic_id)
VALUES (2);
SQL
expect(DB.query_single("SELECT topic_id FROM #{table_name} WHERE topic_id = 2")).to eq([2])
end
end
end