diff --git a/internal/config/store_postgres.go b/internal/config/store_postgres.go index 9e8c5dd6..5eb49e51 100644 --- a/internal/config/store_postgres.go +++ b/internal/config/store_postgres.go @@ -93,7 +93,8 @@ func getGlobalConfigFrom(ctx context.Context, q globalConfigExecutor) (*GlobalCo COALESCE(ladder_execution_enabled, false), offering_class, require_different_approver, - auto_purchase_enabled + auto_purchase_enabled, + auto_purchase_changed_by, auto_purchase_changed_at, auto_purchase_changed_via FROM global_config WHERE id = 1 ` @@ -128,6 +129,9 @@ func getGlobalConfigFrom(ctx context.Context, q globalConfigExecutor) (*GlobalCo &config.OfferingClass, &config.RequireDifferentApprover, &config.AutoPurchaseEnabled, + &config.AutoPurchaseChangedBy, + &config.AutoPurchaseChangedAt, + &config.AutoPurchaseChangedVia, ) if err != nil { @@ -261,8 +265,9 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G grace_period_days, recommendations_cache_stale_hours, recommendations_lookback_days, purchase_delay_hours, laddering_enabled, ladder_execution_enabled, offering_class, - require_different_approver, auto_purchase_enabled - ) VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25) + require_different_approver, auto_purchase_enabled, + auto_purchase_changed_by, auto_purchase_changed_at, auto_purchase_changed_via + ) VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28) ON CONFLICT (id) DO UPDATE SET enabled_providers = $1, notification_email = $2, @@ -289,6 +294,9 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G offering_class = $23, require_different_approver = $24, auto_purchase_enabled = $25, + auto_purchase_changed_by = $26, + auto_purchase_changed_at = $27, + auto_purchase_changed_via = $28, updated_at = NOW() ` @@ -358,6 +366,9 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G offeringClass, config.RequireDifferentApprover, config.AutoPurchaseEnabled, + config.AutoPurchaseChangedBy, + config.AutoPurchaseChangedAt, + config.AutoPurchaseChangedVia, ) if err != nil { diff --git a/internal/config/store_postgres_auto_purchase_stamps_integration_test.go b/internal/config/store_postgres_auto_purchase_stamps_integration_test.go new file mode 100644 index 00000000..dd15468a --- /dev/null +++ b/internal/config/store_postgres_auto_purchase_stamps_integration_test.go @@ -0,0 +1,75 @@ +//go:build integration +// +build integration + +package config + +import ( + "context" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/migrations" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPostgresStoreDB_AutoPurchaseStamps_FreshMigrateAndRIWritePreserve runs +// against a dedicated database named r1a_* (AUTO_PURCHASE_STAMPS_TEST_DATABASE_URL): +// a fresh migrate leaves the kill switch OFF with NULL stamps, and the RI-exchange +// style whole-config read-modify-write keeps the stamps instead of wiping them. +func TestPostgresStoreDB_AutoPurchaseStamps_FreshMigrateAndRIWritePreserve(t *testing.T) { + dsn := os.Getenv("AUTO_PURCHASE_STAMPS_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set AUTO_PURCHASE_STAMPS_TEST_DATABASE_URL to a dedicated r1a_* test database") + } + parsed, err := url.Parse(dsn) + require.NoError(t, err) + require.True(t, strings.HasPrefix(strings.TrimPrefix(parsed.Path, "/"), "r1a_"), "database must be dedicated to R1a verification") + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + require.NoError(t, err) + defer pool.Close() + require.NoError(t, migrations.RunMigrations(ctx, pool, getTestMigrationsPath(), "", "")) + store := &PostgresStore{db: pool} + + cfg, err := store.GetGlobalConfig(ctx) + require.NoError(t, err) + assert.False(t, cfg.AutoPurchaseEnabled, "a fresh migrate leaves the kill switch OFF") + assert.Nil(t, cfg.AutoPurchaseChangedBy) + assert.Nil(t, cfg.AutoPurchaseChangedAt) + assert.Nil(t, cfg.AutoPurchaseChangedVia) + + // R1b will write these; seed them directly, as nothing sets them yet. + _, err = pool.Exec(ctx, ` + UPDATE global_config + SET auto_purchase_enabled = TRUE, auto_purchase_changed_by = 'user-1', + auto_purchase_changed_at = '2026-10-13T01:02:03Z', auto_purchase_changed_via = 'user-session' + WHERE id = 1`) + require.NoError(t, err) + + // The RI-exchange write path: read the whole config, change RI fields, upsert. + after, err := store.UpdateGlobalConfigAtomic(ctx, func(existing *GlobalConfig) error { + existing.RIExchangeEnabled = true + existing.RIExchangeMode = "manual" + return nil + }) + require.NoError(t, err) + require.NotNil(t, after.AutoPurchaseChangedBy) + + cfg, err = store.GetGlobalConfig(ctx) + require.NoError(t, err) + assert.True(t, cfg.RIExchangeEnabled) + assert.True(t, cfg.AutoPurchaseEnabled) + require.NotNil(t, cfg.AutoPurchaseChangedBy) + assert.Equal(t, "user-1", *cfg.AutoPurchaseChangedBy) + require.NotNil(t, cfg.AutoPurchaseChangedAt) + assert.True(t, cfg.AutoPurchaseChangedAt.Equal(time.Date(2026, 10, 13, 1, 2, 3, 0, time.UTC))) + require.NotNil(t, cfg.AutoPurchaseChangedVia) + assert.Equal(t, "user-session", *cfg.AutoPurchaseChangedVia) +} diff --git a/internal/config/store_postgres_coverage_test.go b/internal/config/store_postgres_coverage_test.go index c8c14ff0..23303f85 100644 --- a/internal/config/store_postgres_coverage_test.go +++ b/internal/config/store_postgres_coverage_test.go @@ -602,6 +602,9 @@ func TestSaveGlobalConfig_OfferingClassBindsAt23(t *testing.T) { "standard", // $23 offering_class -- the field this test guards pgxmock.AnyArg(), // $24 require_different_approver pgxmock.AnyArg(), // $25 auto_purchase_enabled + pgxmock.AnyArg(), // $26 auto_purchase_changed_by + pgxmock.AnyArg(), // $27 auto_purchase_changed_at + pgxmock.AnyArg(), // $28 auto_purchase_changed_via ). WillReturnResult(pgxmock.NewResult("INSERT", 1)) diff --git a/internal/config/store_postgres_pgxmock_test.go b/internal/config/store_postgres_pgxmock_test.go index fecf8e93..5cdf4ff1 100644 --- a/internal/config/store_postgres_pgxmock_test.go +++ b/internal/config/store_postgres_pgxmock_test.go @@ -65,6 +65,7 @@ func TestPGXMock_GetGlobalConfig_Success(t *testing.T) { "offering_class", "require_different_approver", "auto_purchase_enabled", + "auto_purchase_changed_by", "auto_purchase_changed_at", "auto_purchase_changed_via", } rows := pgxmock.NewRows(cols).AddRow( []string{"aws"}, strPtr("ops@example.com"), true, @@ -80,6 +81,7 @@ func TestPGXMock_GetGlobalConfig_Success(t *testing.T) { "convertible", false, false, + nil, nil, nil, ) mock.ExpectQuery("SELECT").WillReturnRows(rows) @@ -127,6 +129,7 @@ func TestPGXMock_GetGlobalConfig_GracePeriodDays(t *testing.T) { "offering_class", "require_different_approver", "auto_purchase_enabled", + "auto_purchase_changed_by", "auto_purchase_changed_at", "auto_purchase_changed_via", } baseRow := func(graceJSON string) []any { return []any{ @@ -143,6 +146,7 @@ func TestPGXMock_GetGlobalConfig_GracePeriodDays(t *testing.T) { "convertible", false, false, + nil, nil, nil, } } @@ -204,6 +208,7 @@ var globalConfigCols = []string{ "offering_class", "require_different_approver", "auto_purchase_enabled", + "auto_purchase_changed_by", "auto_purchase_changed_at", "auto_purchase_changed_via", } // TestPGXMock_UpdateGlobalConfigAtomic_LockedReadModifyWrite proves the F2 @@ -236,6 +241,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_LockedReadModifyWrite(t *testing.T) { "convertible", // offering_class false, // require_different_approver false, // auto_purchase_enabled + nil, nil, nil, // auto_purchase_changed_by/at/via ) // Strict order: the SELECT and the UPSERT must sit between the same @@ -244,7 +250,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_LockedReadModifyWrite(t *testing.T) { mock.ExpectExec("pg_advisory_xact_lock").WithArgs(pgxmock.AnyArg()). WillReturnResult(pgxmock.NewResult("SELECT", 1)) mock.ExpectQuery("FROM global_config").WillReturnRows(seeded) - mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(25)...). + mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(28)...). WillReturnResult(pgxmock.NewResult("INSERT", 1)) mock.ExpectCommit() @@ -294,6 +300,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_ApplyErrorRollsBack(t *testing.T) { "convertible", false, false, + nil, nil, nil, ) mock.ExpectBegin() @@ -331,6 +338,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_PropagatesOnlyChangedDefaults(t *testi "convertible", false, false, + nil, nil, nil, ) } setCoverage := func(existing *GlobalConfig) error { @@ -345,7 +353,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_PropagatesOnlyChangedDefaults(t *testi mock.ExpectExec("pg_advisory_xact_lock").WithArgs(pgxmock.AnyArg()). WillReturnResult(pgxmock.NewResult("SELECT", 1)) mock.ExpectQuery("FROM global_config").WillReturnRows(seeded()) - mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(25)...). + mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(28)...). WillReturnResult(pgxmock.NewResult("INSERT", 1)) mock.ExpectExec("UPDATE service_configs"). WithArgs(false, 1, false, "no-upfront", true, 70.0, false, RampImmediate). @@ -364,7 +372,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_PropagatesOnlyChangedDefaults(t *testi mock.ExpectExec("pg_advisory_xact_lock").WithArgs(pgxmock.AnyArg()). WillReturnResult(pgxmock.NewResult("SELECT", 1)) mock.ExpectQuery("FROM global_config").WillReturnRows(seeded()) - mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(25)...). + mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(28)...). WillReturnResult(pgxmock.NewResult("INSERT", 1)) dbErr := errors.New("service_configs update failed") mock.ExpectExec("UPDATE service_configs").WithArgs(anyArgsCfg(8)...).WillReturnError(dbErr) @@ -3264,3 +3272,49 @@ func TestPGXMock_ReserveApprovedRIExchange_RejectsRowThatIsNotProcessing(t *test require.ErrorContains(t, err, "is not processing") assert.NoError(t, mock.ExpectationsWereMet()) } + +// TestPGXMock_UpdateGlobalConfigAtomic_PreservesAutoPurchaseStamps proves the +// whole-config upsert writes back the stamps it read (migration 000115). The RI +// exchange config path is a read-modify-write of the whole row, so an upsert that +// omitted them would reset them to NULL on any unrelated config save. +func TestPGXMock_UpdateGlobalConfigAtomic_PreservesAutoPurchaseStamps(t *testing.T) { + mock := newMock(t) + store := storeWith(mock) + ctx := context.Background() + + by, via := "user-1", "user-session" + at := time.Date(2026, 10, 13, 1, 2, 3, 0, time.UTC) + seeded := pgxmock.NewRows(globalConfigCols).AddRow( + []string{"aws"}, strPtr("ops@example.com"), true, + 3, "all-upfront", 80.0, RampImmediate, + false, "manual", 95.0, + 0.0, 0.0, 30, + true, "daily", 3, + "{}", + 24, 7, + 0, + false, + false, + "convertible", + false, + true, + &by, &at, &via, + ) + args := anyArgsCfg(28) + args[25], args[26], args[27] = &by, &at, &via + + mock.ExpectBegin() + mock.ExpectExec("pg_advisory_xact_lock").WithArgs(pgxmock.AnyArg()). + WillReturnResult(pgxmock.NewResult("SELECT", 1)) + mock.ExpectQuery("FROM global_config").WillReturnRows(seeded) + mock.ExpectExec("INSERT INTO global_config").WithArgs(args...). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + mock.ExpectCommit() + + _, err := store.UpdateGlobalConfigAtomic(ctx, func(existing *GlobalConfig) error { + existing.RIExchangeEnabled = true + return nil + }) + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/internal/config/types.go b/internal/config/types.go index 501a9968..65dca4c9 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -85,6 +85,14 @@ type GlobalConfig struct { // Manual purchases (Opportunities, token approval) are unaffected. AutoPurchaseEnabled bool `json:"auto_purchase_enabled" db:"auto_purchase_enabled"` + // AutoPurchaseChanged{By,At,Via} stamp the last change of the switch + // (migration 000115): actor, time, credential kind. NULL = never changed. + // json:"-" so PUT /api/config cannot forge them by unmarshalling onto this + // struct; the whole-config upsert round-trips them (read, modify, write). + AutoPurchaseChangedBy *string `json:"-" db:"auto_purchase_changed_by"` + AutoPurchaseChangedAt *time.Time `json:"-" db:"auto_purchase_changed_at"` + AutoPurchaseChangedVia *string `json:"-" db:"auto_purchase_changed_via"` + // OfferingClass controls the EC2 Reserved Instance offering class used // during purchase. Accepted values: "convertible" (default) and // "standard". Convertible RIs can be exchanged for a different diff --git a/internal/database/postgres/migrations/000115_auto_purchase_switch_stamps.down.sql b/internal/database/postgres/migrations/000115_auto_purchase_switch_stamps.down.sql new file mode 100644 index 00000000..ea68171c --- /dev/null +++ b/internal/database/postgres/migrations/000115_auto_purchase_switch_stamps.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE global_config + DROP COLUMN IF EXISTS auto_purchase_changed_by, + DROP COLUMN IF EXISTS auto_purchase_changed_at, + DROP COLUMN IF EXISTS auto_purchase_changed_via; diff --git a/internal/database/postgres/migrations/000115_auto_purchase_switch_stamps.up.sql b/internal/database/postgres/migrations/000115_auto_purchase_switch_stamps.up.sql new file mode 100644 index 00000000..b497c2a6 --- /dev/null +++ b/internal/database/postgres/migrations/000115_auto_purchase_switch_stamps.up.sql @@ -0,0 +1,8 @@ +-- Migration 000115 (platform#631 R1a): who last changed the auto-purchase +-- kill switch (global_config.auto_purchase_enabled), when, and with what kind +-- of credential. Nullable, additive, no backfill: NULL means "never changed +-- since the column existed". Nothing writes these until R1b. Idempotent. +ALTER TABLE global_config + ADD COLUMN IF NOT EXISTS auto_purchase_changed_by TEXT, + ADD COLUMN IF NOT EXISTS auto_purchase_changed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS auto_purchase_changed_via TEXT; diff --git a/internal/database/postgres/migrations/000115_auto_purchase_switch_stamps_test.go b/internal/database/postgres/migrations/000115_auto_purchase_switch_stamps_test.go new file mode 100644 index 00000000..297247f7 --- /dev/null +++ b/internal/database/postgres/migrations/000115_auto_purchase_switch_stamps_test.go @@ -0,0 +1,58 @@ +//go:build integration +// +build integration + +package migrations_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/migrations" + "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/testhelpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMigration_AutoPurchaseSwitchStamps pins migration 000115 against a real +// PostgreSQL: an existing global_config row keeps the kill switch OFF and gets +// NULL stamps, the up statement is idempotent, and the down migration drops +// the three columns. +func TestMigration_AutoPurchaseSwitchStamps(t *testing.T) { + ctx := context.Background() + migrationsPath := getMigrationsPath() + + container, err := testhelpers.SetupPostgresContainer(ctx, t) + require.NoError(t, err) + defer container.Cleanup(ctx) + pool := container.DB.Pool() + + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, previousMigrationVersion(t, 115))) + _, err = pool.Exec(ctx, `INSERT INTO global_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING`) + require.NoError(t, err) + + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 115)) + var enabled bool + var by, via *string + var hasAt bool + require.NoError(t, pool.QueryRow(ctx, ` + SELECT auto_purchase_enabled, auto_purchase_changed_by, auto_purchase_changed_at IS NOT NULL, auto_purchase_changed_via + FROM global_config WHERE id = 1`).Scan(&enabled, &by, &hasAt, &via)) + assert.False(t, enabled, "the kill switch stays OFF across the migration") + assert.Nil(t, by) + assert.False(t, hasAt) + assert.Nil(t, via) + + up, err := os.ReadFile(filepath.Join(migrationsPath, "000115_auto_purchase_switch_stamps.up.sql")) + require.NoError(t, err) + _, err = pool.Exec(ctx, string(up)) + require.NoError(t, err, "the up statement must be idempotent") + + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 114)) + var cols int + require.NoError(t, pool.QueryRow(ctx, ` + SELECT count(*) FROM information_schema.columns + WHERE table_name = 'global_config' AND column_name LIKE 'auto_purchase_changed_%'`).Scan(&cols)) + assert.Equal(t, 0, cols, "down migration must drop the columns") +}