Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions internal/config/store_postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
`
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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()
`

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 3 additions & 0 deletions internal/config/store_postgres_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
60 changes: 57 additions & 3 deletions internal/config/store_postgres_pgxmock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -80,6 +81,7 @@ func TestPGXMock_GetGlobalConfig_Success(t *testing.T) {
"convertible",
false,
false,
nil, nil, nil,
)
mock.ExpectQuery("SELECT").WillReturnRows(rows)

Expand Down Expand Up @@ -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{
Expand All @@ -143,6 +146,7 @@ func TestPGXMock_GetGlobalConfig_GracePeriodDays(t *testing.T) {
"convertible",
false,
false,
nil, nil, nil,
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()

Expand Down Expand Up @@ -294,6 +300,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_ApplyErrorRollsBack(t *testing.T) {
"convertible",
false,
false,
nil, nil, nil,
)

mock.ExpectBegin()
Expand Down Expand Up @@ -331,6 +338,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_PropagatesOnlyChangedDefaults(t *testi
"convertible",
false,
false,
nil, nil, nil,
)
}
setCoverage := func(existing *GlobalConfig) error {
Expand All @@ -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).
Expand All @@ -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)
Expand Down Expand Up @@ -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())
}
8 changes: 8 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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")
}
Loading