mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 20:27:16 +00:00
Two migration files shipped as version 28: 0028_add_device_secret_lookup (08121f3) and 0028_create_stripe_processed_events (12c55ed). runMigrations keys applied migrations by the integer parsed from the filename and skips any version already present in schema_migrations, so the first file to load records 28 and the second is skipped in silence — no error, no log. loadMigrations enumerates via fs.Glob, which sorts lexically, so add_device_secret_lookup won and stripe_processed_events never ran. On an existing database that leaves stripe_processed_events missing, and StripeEventSeen then fails closed: the error wraps into ErrWebhookProcessing, the webhook returns 500, and Stripe redelivers into the same missing table forever. No subscription, tier, or dispute event is ever applied — defeating the exact idempotency12c55edwas written to add. Fresh installs were unaffected, which is why no test caught it: they take the applySchema path where schema.sql already has the table and both version-28 rows are merely recorded as applied. Renumbered to 0029 rather than renumbering the device migration, so a database that already ran this build (28 recorded, devices.secret_lookup present, stripe table missing) picks the migration up on next boot instead of staying broken. Renumbering the other file would have left that database with the table still missing and re-run its ALTER on a column that exists. Adds two guards: one asserting migration versions are distinct, and one exercising the upgrade path where a duplicate manifests as a missing schema_migrations row. Both fail on a planted duplicate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
87 lines
3.1 KiB
Go
87 lines
3.1 KiB
Go
package db
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
// TestMigrationVersionsAreUnique guards the failure mode that shipped two
|
|
// migrations numbered 0028.
|
|
//
|
|
// runMigrations keys applied migrations by the integer parsed from the
|
|
// filename and skips any version already present in schema_migrations. Two
|
|
// files sharing an ordinal therefore mean the first one applied records the
|
|
// version and the second is skipped in silence — no error, no log. Fresh
|
|
// installs hide it completely, because they take the applySchema path where
|
|
// schema.sql already contains everything and the migrations are only recorded.
|
|
// Only an upgrade of an existing database is affected, which is the one case
|
|
// with no test coverage and the most to lose.
|
|
func TestMigrationVersionsAreUnique(t *testing.T) {
|
|
migrations, err := loadMigrations()
|
|
if err != nil {
|
|
t.Fatalf("loadMigrations: %v", err)
|
|
}
|
|
if len(migrations) == 0 {
|
|
t.Fatal("loadMigrations returned no migrations")
|
|
}
|
|
|
|
seen := make(map[int]string, len(migrations))
|
|
for _, m := range migrations {
|
|
if prev, dup := seen[m.Version]; dup {
|
|
t.Errorf("duplicate migration version %04d: %q and %q — one of them will be silently skipped on an existing database; renumber the later one",
|
|
m.Version, prev, m.Name)
|
|
continue
|
|
}
|
|
seen[m.Version] = m.Name
|
|
}
|
|
}
|
|
|
|
// TestMigrationsApplyToExistingDatabase exercises the upgrade path rather than
|
|
// the fresh-install path: it forces every migration to be treated as pending
|
|
// and asserts they all apply. A migration that is skipped because it collides
|
|
// with another version fails here even though InitDB on a fresh database would
|
|
// report success.
|
|
func TestMigrationsApplyToExistingDatabase(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
if _, err := db.Exec("DELETE FROM schema_migrations"); err != nil {
|
|
t.Fatalf("clear schema_migrations: %v", err)
|
|
}
|
|
|
|
migrations, err := loadMigrations()
|
|
if err != nil {
|
|
t.Fatalf("loadMigrations: %v", err)
|
|
}
|
|
|
|
// freshDB=true records each pending migration without executing its DDL,
|
|
// which is what we want here: this database already has schema.sql applied,
|
|
// and re-running additive ALTERs would fail for reasons unrelated to the
|
|
// collision. The duplicate-version skip happens before the freshDB branch,
|
|
// so a collision still shows up as a missing row below.
|
|
if err := runMigrations(db, true); err != nil {
|
|
t.Fatalf("runMigrations: %v", err)
|
|
}
|
|
|
|
for _, m := range migrations {
|
|
var count int
|
|
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.Version).Scan(&count); err != nil {
|
|
t.Fatalf("query schema_migrations for %d: %v", m.Version, err)
|
|
}
|
|
if count == 0 {
|
|
t.Errorf("migration %04d (%s) was never recorded as applied", m.Version, m.Name)
|
|
}
|
|
}
|
|
|
|
var recorded int
|
|
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&recorded); err != nil {
|
|
t.Fatalf("count schema_migrations: %v", err)
|
|
}
|
|
if recorded != len(migrations) {
|
|
t.Errorf("recorded %d migrations but have %d files; a shortfall means two files share a version",
|
|
recorded, len(migrations))
|
|
}
|
|
}
|