Files
kandev/apps/backend/internal/sentry/migration_test.go
T

591 lines
24 KiB
Go

package sentry
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/kandev/kandev/internal/common/logger"
)
// workspaceScopedConfigsDDL is PR #1572's one-config-per-workspace shape
// (workspace_id PRIMARY KEY, no id column) that migrateConfigsTableToInstances
// upgrades.
const workspaceScopedConfigsDDL = `
CREATE TABLE sentry_configs (
workspace_id TEXT PRIMARY KEY,
auth_method TEXT NOT NULL,
url TEXT NOT NULL DEFAULT 'https://sentry.io',
last_checked_at DATETIME,
last_ok INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);`
// workspaceScopedWatchesDDL is the pre-instance sentry_issue_watches shape
// (workspace-scoped, no sentry_instance_id) that migrateWatchesAddInstanceColumn
// rebuilds, plus its child dedup table.
const workspaceScopedWatchesDDL = `
CREATE TABLE sentry_issue_watches (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
workflow_id TEXT NOT NULL,
workflow_step_id TEXT NOT NULL,
repository_id TEXT NOT NULL DEFAULT '',
base_branch TEXT NOT NULL DEFAULT '',
filter_json TEXT NOT NULL DEFAULT '{}',
agent_profile_id TEXT NOT NULL DEFAULT '',
executor_profile_id TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT 1,
poll_interval_seconds INTEGER NOT NULL DEFAULT 300,
max_inflight_tasks INTEGER DEFAULT 5,
last_polled_at DATETIME,
last_error TEXT NOT NULL DEFAULT '',
last_error_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE sentry_issue_watch_tasks (
id TEXT PRIMARY KEY,
issue_watch_id TEXT NOT NULL,
issue_short_id TEXT NOT NULL,
issue_url TEXT NOT NULL,
task_id TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
UNIQUE(issue_watch_id, issue_short_id),
FOREIGN KEY(issue_watch_id) REFERENCES sentry_issue_watches(id) ON DELETE CASCADE
);`
func openMigrationDB(t *testing.T) *sqlx.DB {
t.Helper()
db, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
t.Fatalf("enable foreign keys: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// seedWorkspaceScopedSchema builds the pre-instance workspace-scoped tables and
// seeds configs for ws-1 (self-hosted) and ws-2 (SaaS), watches for ws-1, ws-2
// and ws-3 (which has NO config), plus one dedup task row for the ws-1 watch.
// Returns the ws-1/ws-2/ws-3 watch IDs.
func seedWorkspaceScopedSchema(t *testing.T, db *sqlx.DB) (w1, w2, w3 string) {
t.Helper()
now := time.Now().UTC().Truncate(time.Second)
if _, err := db.Exec(workspaceScopedConfigsDDL + workspaceScopedWatchesDDL); err != nil {
t.Fatalf("seed schema: %v", err)
}
if _, err := db.Exec(`
INSERT INTO sentry_configs (workspace_id, auth_method, url, last_checked_at, last_ok, last_error, created_at, updated_at)
VALUES ('ws-1', ?, 'https://sentry.example.com', ?, 1, 'healthy', ?, ?),
('ws-2', ?, 'https://sentry.io', NULL, 0, '', ?, ?)`,
AuthMethodAuthToken, now, now, now, AuthMethodAuthToken, now, now); err != nil {
t.Fatalf("seed configs: %v", err)
}
w1, w2, w3 = uuid.New().String(), uuid.New().String(), uuid.New().String()
for _, watch := range []struct{ id, ws string }{{w1, "ws-1"}, {w2, "ws-2"}, {w3, "ws-3"}} {
if _, err := db.Exec(`
INSERT INTO sentry_issue_watches (id, workspace_id, workflow_id, workflow_step_id, filter_json, created_at, updated_at)
VALUES (?, ?, 'wf', 'step', '{"orgSlug":"acme","projectSlug":"fe"}', ?, ?)`,
watch.id, watch.ws, now, now); err != nil {
t.Fatalf("seed watch %s: %v", watch.ws, err)
}
}
if _, err := db.Exec(`
INSERT INTO sentry_issue_watch_tasks (id, issue_watch_id, issue_short_id, issue_url, task_id, created_at)
VALUES (?, ?, 'PROJ-1', 'https://sentry.example.com/issues/PROJ-1', 'task-1', ?)`,
uuid.New().String(), w1, now); err != nil {
t.Fatalf("seed dedup task: %v", err)
}
return w1, w2, w3
}
// TestMigration_WorkspaceConfigsToInstances pins acceptance (a): the
// workspace-scoped model upgrades to id-keyed instances, the per-workspace
// secret is rekeyed to the per-instance key, and every watch's
// sentry_instance_id is backfilled when its workspace has exactly one config.
func TestMigration_WorkspaceConfigsToInstances(t *testing.T) {
db := openMigrationDB(t)
w1, w2, w3 := seedWorkspaceScopedSchema(t, db)
secrets := newFakeSecretStore()
ctx := context.Background()
_ = secrets.Set(ctx, SecretKeyForWorkspace("ws-1"), "tok", "tok-1")
_ = secrets.Set(ctx, SecretKeyForWorkspace("ws-2"), "tok", "tok-2")
svc, _, err := Provide(db, db, secrets, nil, logger.Default())
if err != nil {
t.Fatalf("Provide (migrate): %v", err)
}
store := svc.Store()
// One instance per workspace config, host-derived names, health preserved.
inst1List, _ := store.ListInstances(ctx, "ws-1")
inst2List, _ := store.ListInstances(ctx, "ws-2")
if len(inst1List) != 1 || len(inst2List) != 1 {
t.Fatalf("expected one instance per workspace, got ws-1=%d ws-2=%d", len(inst1List), len(inst2List))
}
inst1, inst2 := inst1List[0], inst2List[0]
if inst1.Name != "sentry.example.com" || inst1.URL != "https://sentry.example.com" || !inst1.LastOk {
t.Errorf("ws-1 instance not migrated faithfully: %+v", inst1)
}
if inst2.Name != "sentry.io" {
t.Errorf("ws-2 instance name = %q, want host-derived 'sentry.io'", inst2.Name)
}
// Watches backfilled to their workspace's instance; the config-less
// workspace's watch stays unbound (NULL).
gw1, _ := store.GetIssueWatch(ctx, w1)
gw2, _ := store.GetIssueWatch(ctx, w2)
gw3, _ := store.GetIssueWatch(ctx, w3)
if gw1.SentryInstanceID != inst1.ID {
t.Errorf("ws-1 watch bound to %q, want %q", gw1.SentryInstanceID, inst1.ID)
}
if gw2.SentryInstanceID != inst2.ID {
t.Errorf("ws-2 watch bound to %q, want %q", gw2.SentryInstanceID, inst2.ID)
}
if gw3.SentryInstanceID != "" {
t.Errorf("config-less workspace watch should stay unbound, got %q", gw3.SentryInstanceID)
}
// The dedup child row survived the watches table rebuild (FK preserved).
ids, _ := store.ListIssueWatchTaskIDs(ctx, w1)
if len(ids) != 1 || ids[0] != "task-1" {
t.Errorf("expected dedup task preserved through rebuild, got %v", ids)
}
// Secret rekeyed from the workspace key to the per-instance key.
if got, _ := secrets.Reveal(ctx, secretKeyForInstance(inst1.ID)); got != "tok-1" {
t.Errorf("ws-1 secret not rekeyed to instance key, got %q", got)
}
if got, _ := secrets.Reveal(ctx, secretKeyForInstance(inst2.ID)); got != "tok-2" {
t.Errorf("ws-2 secret not rekeyed to instance key, got %q", got)
}
}
// TestMigration_RerunIsIdempotent pins acceptance (c): re-running the whole
// migration + secret rekey after a completed run changes nothing.
func TestMigration_RerunIsIdempotent(t *testing.T) {
db := openMigrationDB(t)
w1, _, _ := seedWorkspaceScopedSchema(t, db)
secrets := newFakeSecretStore()
ctx := context.Background()
_ = secrets.Set(ctx, SecretKeyForWorkspace("ws-1"), "tok", "tok-1")
_ = secrets.Set(ctx, SecretKeyForWorkspace("ws-2"), "tok", "tok-2")
svc1, _, err := Provide(db, db, secrets, nil, logger.Default())
if err != nil {
t.Fatalf("first Provide: %v", err)
}
before, _ := svc1.Store().ListAllInstances(ctx)
boundBefore, _ := svc1.Store().GetIssueWatch(ctx, w1)
// Re-run: a fresh NewStore + secret rekey over the same (now-migrated) DB.
store2, err := NewStore(db, db)
if err != nil {
t.Fatalf("second NewStore: %v", err)
}
migrateInstanceSecrets(store2, secrets, logger.Default())
after, _ := store2.ListAllInstances(ctx)
if len(after) != len(before) || len(after) != 2 {
t.Fatalf("instance count changed on rerun: before=%d after=%d", len(before), len(after))
}
if after[0].ID != before[0].ID || after[1].ID != before[1].ID {
t.Error("instance IDs changed on rerun")
}
boundAfter, _ := store2.GetIssueWatch(ctx, w1)
if boundAfter.SentryInstanceID != boundBefore.SentryInstanceID {
t.Errorf("watch binding changed on rerun: %q -> %q", boundBefore.SentryInstanceID, boundAfter.SentryInstanceID)
}
if got, _ := secrets.Reveal(ctx, secretKeyForInstance(boundBefore.SentryInstanceID)); got != "tok-1" {
t.Errorf("secret changed on rerun, got %q", got)
}
}
// TestMigration_CrashAfterConfigsRebuild pins the other half of acceptance (c):
// a crash after the configs table was rebuilt to the id-keyed shape but before
// the watches table gained its FK column. The next boot must complete the watch
// rebuild + backfill against the already-migrated configs.
func TestMigration_CrashAfterConfigsRebuild(t *testing.T) {
db := openMigrationDB(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
// Simulate the post-configs-rebuild / pre-watches-rebuild state: configs are
// already id-keyed, watches are still workspace-scoped without the FK column.
inst1, inst2 := uuid.New().String(), uuid.New().String()
if _, err := db.Exec(`CREATE TABLE sentry_configs (` + sentryConfigsColumns + `)`); err != nil {
t.Fatalf("create id-keyed configs: %v", err)
}
if _, err := db.Exec(`
INSERT INTO sentry_configs (id, workspace_id, name, auth_method, url, last_ok, last_error, created_at, updated_at)
VALUES (?, 'ws-1', 'sentry.example.com', ?, 'https://sentry.example.com', 0, '', ?, ?),
(?, 'ws-2', 'sentry.io', ?, 'https://sentry.io', 0, '', ?, ?)`,
inst1, AuthMethodAuthToken, now, now, inst2, AuthMethodAuthToken, now, now); err != nil {
t.Fatalf("seed id-keyed configs: %v", err)
}
w1, w2, w3 := uuid.New().String(), uuid.New().String(), uuid.New().String()
if _, err := db.Exec(workspaceScopedWatchesDDL); err != nil {
t.Fatalf("seed workspace-scoped watches: %v", err)
}
for _, watch := range []struct{ id, ws string }{{w1, "ws-1"}, {w2, "ws-2"}, {w3, "ws-3"}} {
if _, err := db.Exec(`
INSERT INTO sentry_issue_watches (id, workspace_id, workflow_id, workflow_step_id, filter_json, created_at, updated_at)
VALUES (?, ?, 'wf', 'step', '{}', ?, ?)`, watch.id, watch.ws, now, now); err != nil {
t.Fatalf("seed watch %s: %v", watch.ws, err)
}
}
store, err := NewStore(db, db)
if err != nil {
t.Fatalf("resume migration: %v", err)
}
// Configs are left untouched (no second rebuild → IDs preserved).
gi1, _ := store.GetInstance(ctx, inst1)
if gi1 == nil {
t.Fatal("existing id-keyed config was clobbered")
}
// Watches now carry the FK column, backfilled from the existing configs.
if gw1, _ := store.GetIssueWatch(ctx, w1); gw1.SentryInstanceID != inst1 {
t.Errorf("ws-1 watch bound to %q, want %q", gw1.SentryInstanceID, inst1)
}
if gw2, _ := store.GetIssueWatch(ctx, w2); gw2.SentryInstanceID != inst2 {
t.Errorf("ws-2 watch bound to %q, want %q", gw2.SentryInstanceID, inst2)
}
if gw3, _ := store.GetIssueWatch(ctx, w3); gw3.SentryInstanceID != "" {
t.Errorf("config-less workspace watch should stay unbound, got %q", gw3.SentryInstanceID)
}
}
// experimentalWatchesDDL is the unmerged-#1469 experimental shape:
// sentry_issue_watches carries sentry_instance_id as NOT NULL DEFAULT ” with
// NO foreign key. It only ever existed on dev machines that ran that build;
// migrateWatchesAddInstanceColumn must still converge it to the nullable-FK
// shape rather than skip it because the column name happens to be present.
const experimentalWatchesDDL = `
CREATE TABLE sentry_issue_watches (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
sentry_instance_id TEXT NOT NULL DEFAULT '',
workflow_id TEXT NOT NULL,
workflow_step_id TEXT NOT NULL,
repository_id TEXT NOT NULL DEFAULT '',
base_branch TEXT NOT NULL DEFAULT '',
filter_json TEXT NOT NULL DEFAULT '{}',
agent_profile_id TEXT NOT NULL DEFAULT '',
executor_profile_id TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT 1,
poll_interval_seconds INTEGER NOT NULL DEFAULT 300,
max_inflight_tasks INTEGER DEFAULT 5,
last_polled_at DATETIME,
last_error TEXT NOT NULL DEFAULT '',
last_error_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE sentry_issue_watch_tasks (
id TEXT PRIMARY KEY,
issue_watch_id TEXT NOT NULL,
issue_short_id TEXT NOT NULL,
issue_url TEXT NOT NULL,
task_id TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
UNIQUE(issue_watch_id, issue_short_id),
FOREIGN KEY(issue_watch_id) REFERENCES sentry_issue_watches(id) ON DELETE CASCADE
);`
// TestMigration_ExperimentalNonNullInstanceColumnRebuilt pins the hardened
// guard: a watches table that already has sentry_instance_id but as
// NOT NULL DEFAULT ” with no foreign key is rebuilt into the nullable-FK shape
// (not skipped), converting the ” sentinel to a real backfill / NULL.
func TestMigration_ExperimentalNonNullInstanceColumnRebuilt(t *testing.T) {
db := openMigrationDB(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
inst1 := uuid.New().String()
if _, err := db.Exec(`CREATE TABLE sentry_configs (` + sentryConfigsColumns + `)`); err != nil {
t.Fatalf("create id-keyed configs: %v", err)
}
if _, err := db.Exec(`
INSERT INTO sentry_configs (id, workspace_id, name, auth_method, url, last_ok, last_error, created_at, updated_at)
VALUES (?, 'ws-1', 'sentry.example.com', ?, 'https://sentry.example.com', 0, '', ?, ?)`,
inst1, AuthMethodAuthToken, now, now); err != nil {
t.Fatalf("seed configs: %v", err)
}
if _, err := db.Exec(experimentalWatchesDDL); err != nil {
t.Fatalf("seed experimental watches: %v", err)
}
w1, w3 := uuid.New().String(), uuid.New().String()
// Experimental rows store the '' sentinel for the unbound case.
for _, watch := range []struct{ id, ws string }{{w1, "ws-1"}, {w3, "ws-3"}} {
if _, err := db.Exec(`
INSERT INTO sentry_issue_watches (id, workspace_id, sentry_instance_id, workflow_id, workflow_step_id, filter_json, created_at, updated_at)
VALUES (?, ?, '', 'wf', 'step', '{}', ?, ?)`, watch.id, watch.ws, now, now); err != nil {
t.Fatalf("seed watch %s: %v", watch.ws, err)
}
}
store, err := NewStore(db, db)
if err != nil {
t.Fatalf("migrate experimental schema: %v", err)
}
// Column is now nullable and backed by the ON DELETE RESTRICT FK to id.
nullable, err := store.columnIsNullable("sentry_issue_watches", "sentry_instance_id")
if err != nil || !nullable {
t.Errorf("sentry_instance_id nullable=%v err=%v, want nullable", nullable, err)
}
hasFK, err := store.hasForeignKey("sentry_issue_watches", "sentry_instance_id", "sentry_configs", "id", "RESTRICT")
if err != nil || !hasFK {
t.Errorf("sentry_instance_id FK present=%v err=%v, want RESTRICT FK to id", hasFK, err)
}
// Rows re-backfilled: ws-1 to its sole instance, config-less ws-3 to NULL.
if gw1, _ := store.GetIssueWatch(ctx, w1); gw1.SentryInstanceID != inst1 {
t.Errorf("ws-1 watch bound to %q, want %q", gw1.SentryInstanceID, inst1)
}
if gw3, _ := store.GetIssueWatch(ctx, w3); gw3.SentryInstanceID != "" {
t.Errorf("config-less watch should be unbound, got %q", gw3.SentryInstanceID)
}
// FK now enforced: deleting an in-use instance is blocked.
if _, err := db.Exec(`DELETE FROM sentry_configs WHERE id = ?`, inst1); err == nil {
t.Error("expected FK RESTRICT to block deleting an in-use instance after rebuild")
}
}
// TestMigration_RekeyDoesNotSeedLaterSecretlessInstance ensures a completed
// legacy secret migration cannot grant an ordinary later instance the stale
// workspace or singleton token on a subsequent process start.
func TestMigration_RekeyDoesNotSeedLaterSecretlessInstance(t *testing.T) {
db := openMigrationDB(t)
seedWorkspaceScopedSchema(t, db)
ctx := context.Background()
secrets := newFakeSecretStore()
if err := secrets.Set(ctx, SecretKeyForWorkspace("ws-1"), "tok", "workspace-token"); err != nil {
t.Fatalf("seed workspace token: %v", err)
}
if err := secrets.Set(ctx, SecretKey, "tok", "singleton-token"); err != nil {
t.Fatalf("seed singleton token: %v", err)
}
svc, _, err := Provide(db, db, secrets, nil, logger.Default())
if err != nil {
t.Fatalf("first Provide: %v", err)
}
later := testInstance("ws-1", "Later")
if err := svc.Store().CreateInstance(ctx, later); err != nil {
t.Fatalf("create later secretless instance: %v", err)
}
if _, _, err := Provide(db, db, secrets, nil, logger.Default()); err != nil {
t.Fatalf("restart Provide: %v", err)
}
if exists, err := secrets.Exists(ctx, secretKeyForInstance(later.ID)); err != nil {
t.Fatalf("check later instance secret: %v", err)
} else if exists {
t.Fatal("later secretless instance inherited a stale legacy token")
}
}
// TestMigration_RekeyRetriesOriginalInstanceDespiteLaterAddition ensures a
// transient secret-store failure on the original migrated instance's rekey
// does not permanently strand it once a later, unrelated instance is added to
// the same workspace before the next restart: only the later instance is
// excluded from ever inheriting the legacy token, not the legitimate original.
func TestMigration_RekeyRetriesOriginalInstanceDespiteLaterAddition(t *testing.T) {
db := openMigrationDB(t)
seedWorkspaceScopedSchema(t, db)
ctx := context.Background()
secrets := newFakeSecretStore()
if err := secrets.Set(ctx, SecretKeyForWorkspace("ws-1"), "tok", "workspace-token"); err != nil {
t.Fatalf("seed workspace token: %v", err)
}
secrets.setErr = errors.New("secret store unavailable")
svc, _, err := Provide(db, db, secrets, nil, logger.Default())
if err != nil {
t.Fatalf("first Provide: %v", err)
}
instances, err := svc.Store().ListInstances(ctx, "ws-1")
if err != nil || len(instances) != 1 {
t.Fatalf("list ws-1 instances: %v %+v", err, instances)
}
original := instances[0]
if exists, _ := secrets.Exists(ctx, secretKeyForInstance(original.ID)); exists {
t.Fatal("expected the first rekey attempt to fail, but the instance key already exists")
}
later := testInstance("ws-1", "Later")
if err := svc.Store().CreateInstance(ctx, later); err != nil {
t.Fatalf("create later secretless instance: %v", err)
}
secrets.setErr = nil
if _, _, err := Provide(db, db, secrets, nil, logger.Default()); err != nil {
t.Fatalf("restart Provide: %v", err)
}
if exists, err := secrets.Exists(ctx, secretKeyForInstance(original.ID)); err != nil || !exists {
t.Fatalf("expected the original instance to recover its legacy token once the transient failure cleared, exists=%v err=%v", exists, err)
}
if exists, err := secrets.Exists(ctx, secretKeyForInstance(later.ID)); err != nil || exists {
t.Fatalf("later secretless instance must still never inherit the legacy token, exists=%v err=%v", exists, err)
}
}
// TestMigration_MultipleInstancesLeaveLegacyWatchUnbound ensures the watch
// rebuild preserves the unbound fallback when a workspace has several possible
// instance targets rather than guessing the oldest one.
func TestMigration_MultipleInstancesLeaveLegacyWatchUnbound(t *testing.T) {
db := openMigrationDB(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
first, second := uuid.New().String(), uuid.New().String()
if _, err := db.Exec(`CREATE TABLE sentry_configs (` + sentryConfigsColumns + `)`); err != nil {
t.Fatalf("create id-keyed configs: %v", err)
}
if _, err := db.Exec(`
INSERT INTO sentry_configs (id, workspace_id, name, auth_method, url, last_ok, last_error, created_at, updated_at)
VALUES (?, 'ws-1', 'First', ?, 'https://first.example.com', 0, '', ?, ?),
(?, 'ws-1', 'Second', ?, 'https://second.example.com', 0, '', ?, ?)`,
first, AuthMethodAuthToken, now, now,
second, AuthMethodAuthToken, now.Add(time.Minute), now.Add(time.Minute)); err != nil {
t.Fatalf("seed instances: %v", err)
}
if _, err := db.Exec(workspaceScopedWatchesDDL); err != nil {
t.Fatalf("create legacy watches: %v", err)
}
watchID := uuid.New().String()
if _, err := db.Exec(`
INSERT INTO sentry_issue_watches (id, workspace_id, workflow_id, workflow_step_id, filter_json, created_at, updated_at)
VALUES (?, 'ws-1', 'wf', 'step', '{}', ?, ?)`, watchID, now, now); err != nil {
t.Fatalf("seed legacy watch: %v", err)
}
store, err := NewStore(db, db)
if err != nil {
t.Fatalf("migrate watches: %v", err)
}
watch, err := store.GetIssueWatch(ctx, watchID)
if err != nil {
t.Fatalf("get migrated watch: %v", err)
}
if watch.SentryInstanceID != "" {
t.Fatalf("multi-instance legacy watch bound to %q, want unbound", watch.SentryInstanceID)
}
}
// TestMigration_ExperimentalBindingSurvivesRebuild ensures a valid explicit
// binding from the experimental no-FK shape is not overwritten by backfill.
func TestMigration_ExperimentalBindingSurvivesRebuild(t *testing.T) {
db := openMigrationDB(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
first, selected := uuid.New().String(), uuid.New().String()
if _, err := db.Exec(`CREATE TABLE sentry_configs (` + sentryConfigsColumns + `)`); err != nil {
t.Fatalf("create id-keyed configs: %v", err)
}
if _, err := db.Exec(`
INSERT INTO sentry_configs (id, workspace_id, name, auth_method, url, last_ok, last_error, created_at, updated_at)
VALUES (?, 'ws-1', 'First', ?, 'https://first.example.com', 0, '', ?, ?),
(?, 'ws-1', 'Selected', ?, 'https://selected.example.com', 0, '', ?, ?)`,
first, AuthMethodAuthToken, now, now,
selected, AuthMethodAuthToken, now.Add(time.Minute), now.Add(time.Minute)); err != nil {
t.Fatalf("seed instances: %v", err)
}
if _, err := db.Exec(experimentalWatchesDDL); err != nil {
t.Fatalf("create experimental watches: %v", err)
}
watchID := uuid.New().String()
if _, err := db.Exec(`
INSERT INTO sentry_issue_watches (id, workspace_id, sentry_instance_id, workflow_id, workflow_step_id, filter_json, created_at, updated_at)
VALUES (?, 'ws-1', ?, 'wf', 'step', '{}', ?, ?)`, watchID, selected, now, now); err != nil {
t.Fatalf("seed experimental watch: %v", err)
}
store, err := NewStore(db, db)
if err != nil {
t.Fatalf("migrate watches: %v", err)
}
watch, err := store.GetIssueWatch(ctx, watchID)
if err != nil {
t.Fatalf("get migrated watch: %v", err)
}
if watch.SentryInstanceID != selected {
t.Fatalf("experimental binding = %q, want %q", watch.SentryInstanceID, selected)
}
}
// TestHasForeignKey_ImplicitPrimaryKeyReference pins the NULL-`to` edge case: a
// FK declared as `REFERENCES sentry_configs` (implicit primary key, no column
// named) is reported by SQLite's foreign_key_list with a NULL `to`. hasForeignKey
// must resolve that to the single-column PK (`id`) and recognize the FK rather
// than treat the NULL as a non-match and force an unnecessary table rebuild.
func TestHasForeignKey_ImplicitPrimaryKeyReference(t *testing.T) {
db := openMigrationDB(t)
if _, err := db.Exec(`CREATE TABLE sentry_configs (` + sentryConfigsColumns + `)`); err != nil {
t.Fatalf("create configs: %v", err)
}
if _, err := db.Exec(`
CREATE TABLE child (
id TEXT PRIMARY KEY,
sentry_instance_id TEXT,
FOREIGN KEY(sentry_instance_id) REFERENCES sentry_configs ON DELETE RESTRICT
)`); err != nil {
t.Fatalf("create child with implicit-PK FK: %v", err)
}
store := &Store{db: db, ro: db}
ok, err := store.hasForeignKey("child", "sentry_instance_id", "sentry_configs", "id", "RESTRICT")
if err != nil {
t.Fatalf("hasForeignKey error: %v", err)
}
if !ok {
t.Error("hasForeignKey = false, want true for an implicit-PK reference (REFERENCES sentry_configs)")
}
}
// TestHasForeignKey_ImplicitCompositePrimaryKeyNoFalseMatch guards the composite
// case: an implicit reference to a table whose PRIMARY KEY spans several columns
// targets the whole key, so it must not be reported as matching a single wanted
// column.
func TestHasForeignKey_ImplicitCompositePrimaryKeyNoFalseMatch(t *testing.T) {
db := openMigrationDB(t)
if _, err := db.Exec(`CREATE TABLE parent (a TEXT, b TEXT, PRIMARY KEY(a, b))`); err != nil {
t.Fatalf("create parent: %v", err)
}
if _, err := db.Exec(`
CREATE TABLE child (
a TEXT,
b TEXT,
FOREIGN KEY(a, b) REFERENCES parent ON DELETE RESTRICT
)`); err != nil {
t.Fatalf("create child with composite implicit-PK FK: %v", err)
}
store := &Store{db: db, ro: db}
ok, err := store.hasForeignKey("child", "a", "parent", "a", "RESTRICT")
if err != nil {
t.Fatalf("hasForeignKey error: %v", err)
}
if ok {
t.Error("hasForeignKey = true, want false: an implicit composite-PK reference must not match a single wanted column")
}
}