Skip to content

Commit daab0f8

Browse files
authored
fix(storage): drop stale unique email/phone constraints on upgrade (#628)
* fix(storage): drop stale unique email/phone constraints on upgrade v1 declared email and phone_number UNIQUE on authorizer_users and authorizer_otps; v2 keeps non-unique indexes and enforces uniqueness in the application layer. On an upgraded SQL database GORM AutoMigrate emits DROP CONSTRAINT "uni_<table>_<col>" — a name that does not match what the DB created — and aborts with SQLSTATE 42704, so the server fails to start ("failed to create storage provider"). Clear the legacy uniqueness before AutoMigrate for all four columns. The old form is a constraint (<table>_<col>_key / uni_<table>_<col>) on some v1 releases and a standalone unique index (idx_<table>_<col>) on others, so handle both: drop the named constraints, then drop any single-column UNIQUE index on email/phone_number via GetIndexes (name-agnostic, leaves the current non-unique indexes intact). Non-fatal; fresh installs are unaffected. Generalises the prior phone_number-only handling. * test(storage): cover email/phone uniqueness and stale-constraint migration Two SQL provider tests: - TestProviderEmailPhoneUpdatesAndUniqueness (all SQL backends): a user can update their own email and phone number seamlessly, duplicates across different users are still rejected (app-layer uniqueness), and OTPs key independently on email and phone. - TestStaleUniqueConstraintMigration (Postgres): seeds a legacy UNIQUE constraint on users.email and a legacy UNIQUE index on otps.phone_number, then asserts startup drops them (no SQLSTATE 42704 abort), the search index is preserved as non-unique, and a post-migration phone update succeeds. Honors TEST_DBS; defaults to sqlite locally, Postgres path runs in CI.
1 parent 7ea42bc commit daab0f8

2 files changed

Lines changed: 303 additions & 6 deletions

File tree

internal/storage/db/sql/provider.go

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,69 @@ func NewProvider(
7474
return nil, err
7575
}
7676

77-
// For sqlserver, handle uniqueness of phone_number manually via extra db call
78-
// during create and update mutation.
79-
if sqlDB.Migrator().HasConstraint(&schemas.User{}, "authorizer_users_phone_number_key") {
80-
err = sqlDB.Migrator().DropConstraint(&schemas.User{}, "authorizer_users_phone_number_key")
81-
if err != nil {
82-
deps.Log.Debug().Err(err).Msg("failed to drop unique constraint on phone_number")
77+
// v1 declared the email and phone_number columns of authorizer_users and
78+
// authorizer_otps as UNIQUE. v2 keeps plain (non-unique) indexes and enforces
79+
// uniqueness in the application layer (see AddUser/UpdateUser) so behaviour is
80+
// identical across all supported databases and these fields can stay optional
81+
// (email-only or phone-only signups).
82+
//
83+
// On an upgraded SQL database those columns are still unique, so GORM
84+
// AutoMigrate tries to DROP CONSTRAINT "uni_<table>_<col>" — a name that does
85+
// not match what the database actually created — failing with SQLSTATE 42704
86+
// ("constraint does not exist") and aborting the whole migration. Depending on
87+
// the v1 release, the old uniqueness exists either as a CONSTRAINT
88+
// (<table>_<col>_key) or as a standalone UNIQUE INDEX (idx_<table>_<col>), so
89+
// we clear both forms before AutoMigrate. Non-fatal: fresh installs have
90+
// nothing to drop.
91+
staleUniqueColumns := []struct {
92+
model any
93+
table, col string
94+
}{
95+
{&schemas.User{}, "authorizer_users", "email"},
96+
{&schemas.User{}, "authorizer_users", "phone_number"},
97+
{&schemas.OTP{}, "authorizer_otps", "email"},
98+
{&schemas.OTP{}, "authorizer_otps", "phone_number"},
99+
}
100+
101+
// 1) Drop stale unique CONSTRAINTs by their well-known names.
102+
// "<table>_<col>_key" is the Postgres/MySQL default; "uni_<table>_<col>" is
103+
// GORM's default unique-constraint name.
104+
for _, c := range staleUniqueColumns {
105+
for _, name := range []string{c.table + "_" + c.col + "_key", "uni_" + c.table + "_" + c.col} {
106+
if sqlDB.Migrator().HasConstraint(c.model, name) {
107+
if dropErr := sqlDB.Migrator().DropConstraint(c.model, name); dropErr != nil {
108+
deps.Log.Debug().Err(dropErr).Str("constraint", name).Msg("failed to drop stale unique constraint")
109+
}
110+
}
111+
}
112+
}
113+
114+
// 2) Drop stale standalone UNIQUE INDEXes on the same columns (e.g.
115+
// idx_authorizer_otps_phone_number created by a v1 gorm:"uniqueIndex").
116+
// This is name-agnostic: only single-column UNIQUE indexes on email /
117+
// phone_number are removed, so the current schema's non-unique indexes are
118+
// left intact and AutoMigrate recreates anything it needs as non-unique.
119+
uniqueColTargets := map[string]bool{"email": true, "phone_number": true}
120+
for _, model := range []any{&schemas.User{}, &schemas.OTP{}} {
121+
indexes, idxErr := sqlDB.Migrator().GetIndexes(model)
122+
if idxErr != nil {
123+
deps.Log.Debug().Err(idxErr).Msg("failed to list indexes while clearing stale unique indexes")
124+
continue
125+
}
126+
for _, idx := range indexes {
127+
unique, ok := idx.Unique()
128+
if !ok || !unique {
129+
continue
130+
}
131+
cols := idx.Columns()
132+
if len(cols) != 1 || !uniqueColTargets[cols[0]] {
133+
continue
134+
}
135+
if dropErr := sqlDB.Migrator().DropIndex(model, idx.Name()); dropErr != nil {
136+
// Constraint-backed indexes (handled in step 1) can't be dropped
137+
// directly on Postgres — that's expected and harmless here.
138+
deps.Log.Debug().Err(dropErr).Str("index", idx.Name()).Msg("failed to drop stale unique index")
139+
}
83140
}
84141
}
85142

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package sql
2+
3+
import (
4+
"context"
5+
"net"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/google/uuid"
13+
"github.com/rs/zerolog"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
17+
"github.com/authorizerdev/authorizer/internal/config"
18+
"github.com/authorizerdev/authorizer/internal/constants"
19+
"github.com/authorizerdev/authorizer/internal/refs"
20+
"github.com/authorizerdev/authorizer/internal/storage/schemas"
21+
)
22+
23+
func sqlTestDeps(t *testing.T) *Dependencies {
24+
t.Helper()
25+
logger := zerolog.New(zerolog.NewTestWriter(t)).With().Timestamp().Logger()
26+
return &Dependencies{Log: &logger}
27+
}
28+
29+
// sqlMigrationTestDBTypes returns the SQL backends to exercise. Honors TEST_DBS
30+
// (comma-separated); defaults to sqlite so local runs stay light. CI sets
31+
// TEST_DBS=postgres to cover the engine where the original failure was reported.
32+
func sqlMigrationTestDBTypes() []string {
33+
supported := map[string]bool{
34+
constants.DbTypePostgres: true,
35+
constants.DbTypeSqlite: true,
36+
constants.DbTypeMysql: true,
37+
constants.DbTypeMariaDB: true,
38+
}
39+
env := os.Getenv("TEST_DBS")
40+
if env == "" {
41+
return []string{constants.DbTypeSqlite}
42+
}
43+
var out []string
44+
for _, p := range strings.Split(env, ",") {
45+
p = strings.TrimSpace(p)
46+
if supported[p] {
47+
out = append(out, p)
48+
}
49+
}
50+
return out
51+
}
52+
53+
// sqlMigrationTestConfig builds a config for dbType, skipping the test when the
54+
// backend is not reachable in this environment.
55+
func sqlMigrationTestConfig(t *testing.T, dbType string) *config.Config {
56+
t.Helper()
57+
cfg := &config.Config{
58+
DatabaseType: dbType,
59+
DatabaseName: "authorizer_test",
60+
DefaultRoles: []string{"user"},
61+
}
62+
switch dbType {
63+
case constants.DbTypeSqlite:
64+
cfg.DatabaseURL = filepath.Join(t.TempDir(), "migration_test.db")
65+
case constants.DbTypePostgres:
66+
cfg.DatabaseURL = "postgres://postgres:postgres@localhost:5434/postgres"
67+
skipIfTCPClosed(t, "localhost:5434")
68+
case constants.DbTypeMysql, constants.DbTypeMariaDB:
69+
cfg.DatabaseURL = "root:password@tcp(localhost:3306)/authorizer_test"
70+
skipIfTCPClosed(t, "localhost:3306")
71+
default:
72+
t.Skipf("unsupported SQL db type for migration test: %s", dbType)
73+
}
74+
return cfg
75+
}
76+
77+
func skipIfTCPClosed(t *testing.T, addr string) {
78+
t.Helper()
79+
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
80+
if err != nil {
81+
t.Skipf("skipping: %s not reachable: %v", addr, err)
82+
}
83+
_ = conn.Close()
84+
}
85+
86+
// TestProviderEmailPhoneUpdatesAndUniqueness covers v2's application-layer
87+
// uniqueness for users and OTPs (email and phone_number are non-unique indexes
88+
// in the DB). It asserts that a user can update their own email/phone number
89+
// seamlessly, that duplicates across different users are still rejected, and
90+
// that OTPs key independently on email and phone.
91+
func TestProviderEmailPhoneUpdatesAndUniqueness(t *testing.T) {
92+
for _, dbType := range sqlMigrationTestDBTypes() {
93+
t.Run(dbType, func(t *testing.T) {
94+
cfg := sqlMigrationTestConfig(t, dbType)
95+
p, err := NewProvider(cfg, sqlTestDeps(t))
96+
require.NoError(t, err)
97+
require.NotNil(t, p)
98+
ctx := context.Background()
99+
100+
uniq := strings.ReplaceAll(uuid.New().String(), "-", "")
101+
emailA := "a_" + uniq + "@test.com"
102+
phoneA := "+1100" + uniq[:9]
103+
104+
userA := &schemas.User{
105+
ID: uuid.New().String(),
106+
Email: refs.NewStringRef(emailA),
107+
PhoneNumber: refs.NewStringRef(phoneA),
108+
SignupMethods: "basic_auth",
109+
}
110+
userA, err = p.AddUser(ctx, userA)
111+
require.NoError(t, err)
112+
113+
// Seamless self-update: change A's phone number to a new value.
114+
newPhoneA := "+1900" + uniq[:9]
115+
userA.PhoneNumber = refs.NewStringRef(newPhoneA)
116+
_, err = p.UpdateUser(ctx, userA)
117+
require.NoError(t, err, "updating own phone number should succeed")
118+
gotByPhone, err := p.GetUserByPhoneNumber(ctx, newPhoneA)
119+
require.NoError(t, err)
120+
assert.Equal(t, userA.ID, gotByPhone.ID)
121+
122+
// Seamless self-update: change A's email, then re-save (idempotent).
123+
newEmailA := "a2_" + uniq + "@test.com"
124+
userA.Email = refs.NewStringRef(newEmailA)
125+
_, err = p.UpdateUser(ctx, userA)
126+
require.NoError(t, err, "updating own email should succeed")
127+
_, err = p.UpdateUser(ctx, userA)
128+
require.NoError(t, err, "re-saving the same user should succeed")
129+
gotByEmail, err := p.GetUserByEmail(ctx, newEmailA)
130+
require.NoError(t, err)
131+
assert.Equal(t, userA.ID, gotByEmail.ID)
132+
133+
// A different user cannot claim A's email or phone via AddUser.
134+
_, err = p.AddUser(ctx, &schemas.User{
135+
ID: uuid.New().String(), Email: refs.NewStringRef(newEmailA), SignupMethods: "basic_auth",
136+
})
137+
assert.Error(t, err, "duplicate email should be rejected")
138+
_, err = p.AddUser(ctx, &schemas.User{
139+
ID: uuid.New().String(), PhoneNumber: refs.NewStringRef(newPhoneA), SignupMethods: "basic_auth",
140+
})
141+
assert.Error(t, err, "duplicate phone should be rejected")
142+
143+
// OTPs key on email and phone independently; upsert by the same email
144+
// updates in place.
145+
otpEmail := &schemas.OTP{Email: newEmailA, Otp: "111111", ExpiresAt: time.Now().Add(5 * time.Minute).Unix()}
146+
_, err = p.UpsertOTP(ctx, otpEmail)
147+
require.NoError(t, err)
148+
otpEmail.Otp = "222222"
149+
_, err = p.UpsertOTP(ctx, otpEmail)
150+
require.NoError(t, err)
151+
fetchedOTP, err := p.GetOTPByEmail(ctx, newEmailA)
152+
require.NoError(t, err)
153+
assert.Equal(t, "222222", fetchedOTP.Otp)
154+
155+
otpPhone := &schemas.OTP{PhoneNumber: newPhoneA, Otp: "333333", ExpiresAt: time.Now().Add(5 * time.Minute).Unix()}
156+
_, err = p.UpsertOTP(ctx, otpPhone)
157+
require.NoError(t, err)
158+
fetchedPhoneOTP, err := p.GetOTPByPhoneNumber(ctx, newPhoneA)
159+
require.NoError(t, err)
160+
assert.Equal(t, "333333", fetchedPhoneOTP.Otp)
161+
162+
// Cleanup.
163+
_ = p.DeleteOTP(ctx, fetchedOTP)
164+
_ = p.DeleteOTP(ctx, fetchedPhoneOTP)
165+
_ = p.DeleteUser(ctx, userA)
166+
})
167+
}
168+
}
169+
170+
// TestStaleUniqueConstraintMigration reproduces an upgraded v1 database whose
171+
// email/phone columns still carry UNIQUE objects, and asserts the provider
172+
// clears them on startup instead of aborting with SQLSTATE 42704
173+
// (regression introduced by the GORM 1.25.10 bump in 2.3.0-rc.1). Postgres only:
174+
// it has named UNIQUE constraints and standalone unique indexes with stable
175+
// DDL; sqlite rebuilds tables and has no comparable failure mode.
176+
func TestStaleUniqueConstraintMigration(t *testing.T) {
177+
for _, dbType := range sqlMigrationTestDBTypes() {
178+
if dbType != constants.DbTypePostgres {
179+
continue
180+
}
181+
t.Run(dbType, func(t *testing.T) {
182+
cfg := sqlMigrationTestConfig(t, dbType)
183+
deps := sqlTestDeps(t)
184+
ctx := context.Background()
185+
186+
// First boot creates the v2 schema (non-unique indexes).
187+
p1, err := NewProvider(cfg, deps)
188+
require.NoError(t, err)
189+
190+
// Simulate a v1 database: a UNIQUE CONSTRAINT on users.email and a
191+
// standalone UNIQUE INDEX on otps.phone_number — the two real-world
192+
// forms reported in the field. Idempotent so reruns against the shared
193+
// test DB don't fail on pre-existing objects.
194+
require.NoError(t, p1.db.Exec(`ALTER TABLE authorizer_users DROP CONSTRAINT IF EXISTS authorizer_users_email_key`).Error)
195+
require.NoError(t, p1.db.Exec(`ALTER TABLE authorizer_users ADD CONSTRAINT authorizer_users_email_key UNIQUE (email)`).Error)
196+
require.NoError(t, p1.db.Exec(`DROP INDEX IF EXISTS idx_authorizer_otps_phone_number`).Error)
197+
require.NoError(t, p1.db.Exec(`CREATE UNIQUE INDEX idx_authorizer_otps_phone_number ON authorizer_otps (phone_number)`).Error)
198+
require.True(t, p1.db.Migrator().HasConstraint(&schemas.User{}, "authorizer_users_email_key"),
199+
"precondition: stale unique constraint seeded")
200+
201+
// Second boot must clear the stale uniqueness and complete migration.
202+
p2, err := NewProvider(cfg, deps)
203+
require.NoError(t, err, "startup must not abort with SQLSTATE 42704")
204+
205+
assert.False(t, p2.db.Migrator().HasConstraint(&schemas.User{}, "authorizer_users_email_key"),
206+
"stale unique constraint should be dropped")
207+
208+
// The search index is preserved, just no longer unique: AutoMigrate
209+
// recreates idx_authorizer_otps_phone_number as a non-unique index.
210+
indexes, err := p2.db.Migrator().GetIndexes(&schemas.OTP{})
211+
require.NoError(t, err)
212+
var phoneIdx string
213+
for _, idx := range indexes {
214+
if cols := idx.Columns(); len(cols) == 1 && cols[0] == "phone_number" {
215+
phoneIdx = idx.Name()
216+
if unique, ok := idx.Unique(); ok {
217+
assert.False(t, unique, "otps.phone_number index should no longer be unique")
218+
}
219+
}
220+
}
221+
assert.NotEmpty(t, phoneIdx, "non-unique search index on otps.phone_number should still exist")
222+
223+
// After migrating a legacy DB, a user can still update their phone
224+
// number seamlessly (no stale constraint in the way).
225+
uniq := strings.ReplaceAll(uuid.New().String(), "-", "")
226+
u := &schemas.User{
227+
ID: uuid.New().String(),
228+
Email: refs.NewStringRef("m_" + uniq + "@test.com"),
229+
PhoneNumber: refs.NewStringRef("+1700" + uniq[:9]),
230+
SignupMethods: "basic_auth",
231+
}
232+
u, err = p2.AddUser(ctx, u)
233+
require.NoError(t, err)
234+
u.PhoneNumber = refs.NewStringRef("+1600" + uniq[:9])
235+
_, err = p2.UpdateUser(ctx, u)
236+
require.NoError(t, err, "updating phone after migration should succeed")
237+
_ = p2.DeleteUser(ctx, u)
238+
})
239+
}
240+
}

0 commit comments

Comments
 (0)