From 360a0dc7c203605a56748dc29b82c2df3da55dbb Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Mon, 19 Oct 2026 12:43:34 +0100 Subject: [PATCH] fix(ri-exchange): run unattended exchanges as manual while 4-eyes is on With RIExchangeEnabled and a non-manual RIExchangeMode, the scheduled ri_exchange_reshape task called exchange.RunAutoExchange in auto mode and executed exchanges with no approver, even with RequireDifferentApprover on. The direct execute endpoints already refuse under 4-eyes (#673), so auto mode was the remaining path that moved money with no second person. With 4-eyes on, every non-manual mode ("auto", "" and typos, which pkg/exchange all treats as auto) now runs as "manual": the run only raises pending records with approval tokens, sends the pending-approval email, and each exchange then goes through the approve endpoint and its 4-eyes check. The stored mode is unchanged, so turning 4-eyes off brings auto back. The ladder exchange runner gets the same downgrade; it reads the global config itself and fails closed (no AWS call) when it cannot. Also tightens the #673 tests: the AWS direct-execute 4-eyes tests now spy on the exchange executor and assert it is never called, and the Azure global-config lookup failure has a fail-closed test. Closes #679 Co-Authored-By: Claude Opus 5.5 --- .../api/handler_ri_exchange_azure_test.go | 27 ++ internal/api/handler_ri_exchange_test.go | 20 +- internal/api/openapi.yaml | 6 +- internal/config/types.go | 3 + internal/server/handler_ri_exchange.go | 2 +- internal/server/ladder_write.go | 40 ++- internal/server/ri_exchange_four_eyes.go | 33 +++ internal/server/ri_exchange_four_eyes_test.go | 248 ++++++++++++++++++ 8 files changed, 367 insertions(+), 12 deletions(-) create mode 100644 internal/server/ri_exchange_four_eyes.go create mode 100644 internal/server/ri_exchange_four_eyes_test.go diff --git a/internal/api/handler_ri_exchange_azure_test.go b/internal/api/handler_ri_exchange_azure_test.go index e8f084f32..9046f8c5e 100644 --- a/internal/api/handler_ri_exchange_azure_test.go +++ b/internal/api/handler_ri_exchange_azure_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "errors" "fmt" "math" "math/big" @@ -2252,3 +2253,29 @@ func TestExecuteAzureExchange_FourEyesRefusesDirectExecute(t *testing.T) { opsClient.AssertNotCalled(t, "ExecuteExchange", mock.Anything, mock.Anything) opsClient.AssertNotCalled(t, "CalculateExchange", mock.Anything, mock.Anything, mock.Anything) } + +// TestExecuteAzureExchange_FourEyesPolicyLookupErrorFailsClosed (issue #679): +// an unreadable global config must not be treated as "4-eyes off". The +// handler returns a server error before any quote or exchange call. +func TestExecuteAzureExchange_FourEyesPolicyLookupErrorFailsClosed(t *testing.T) { + ctx := context.Background() + opsClient := new(mockAzureExchangeOpsClient) + ownsAzureSource(opsClient) + t.Cleanup(func() { opsClient.AssertExpectations(t) }) + + h := newAzureExecuteSourceGateHandler(t, opsClient) + store, ok := h.config.(*MockConfigStore) + require.True(t, ok) + store.On("GetGlobalConfig", mock.Anything).Return(nil, errors.New("db down")) + + _, err := h.executeAzureExchange(ctx, &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"authorization": "Bearer tok"}, + Body: validAzureExecuteBody, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "4-eyes policy check") + _, isClient := IsClientError(err) + assert.False(t, isClient, "a lookup failure is a server error, not a client refusal") + opsClient.AssertNotCalled(t, "ExecuteExchange", mock.Anything, mock.Anything) + opsClient.AssertNotCalled(t, "CalculateExchange", mock.Anything, mock.Anything, mock.Anything) +} diff --git a/internal/api/handler_ri_exchange_test.go b/internal/api/handler_ri_exchange_test.go index 9e3818c71..e0f1e67e7 100644 --- a/internal/api/handler_ri_exchange_test.go +++ b/internal/api/handler_ri_exchange_test.go @@ -2592,20 +2592,34 @@ func newFourEyesExecuteExchangeHandler(t *testing.T, store *MockConfigStore) *Ha } } +// spyOnAWSExchange installs an executeExchangeFn that counts calls and +// reports success, so a 4-eyes test asserts that no exchange was submitted. +// The real executor would fail on missing AWS credentials, so an error alone +// would not prove the gate held (issue #679). +func spyOnAWSExchange(h *Handler) *int { + calls := 0 + h.executeExchangeFn = func(context.Context, exchange.ExchangeExecuteRequest) (string, *exchange.ExchangeQuoteSummary, error) { + calls++ + return "exch-must-not-run", &exchange.ExchangeQuoteSummary{PaymentDueUSDStr: "0"}, nil + } + return &calls +} + // TestExecuteExchange_FourEyesRefusesDirectExecute pins issue #517: with // RequireDifferentApprover on, the direct AWS execute endpoint must refuse -// before any exchange is attempted. The handler has no exchange client or -// idempotency store wired, so any code path past the gate would panic. +// before any exchange is attempted. A spy executor records any exchange call. func TestExecuteExchange_FourEyesRefusesDirectExecute(t *testing.T) { ctx := context.Background() store := &MockConfigStore{} store.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{RequireDifferentApprover: true}, nil) h := newFourEyesExecuteExchangeHandler(t, store) + exchangeCalls := spyOnAWSExchange(h) _, err := h.executeExchange(ctx, &events.LambdaFunctionURLRequest{ Headers: map[string]string{"authorization": "Bearer exchange-token"}, Body: fourEyesExchangeBody, }) + assert.Equal(t, 0, *exchangeCalls, "no exchange may be submitted while 4-eyes is on") require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok, "expected a ClientError, got: %v", err) @@ -2620,11 +2634,13 @@ func TestExecuteExchange_FourEyesPolicyLookupErrorFailsClosed(t *testing.T) { store := &MockConfigStore{} store.On("GetGlobalConfig", mock.Anything).Return(nil, errors.New("db down")) h := newFourEyesExecuteExchangeHandler(t, store) + exchangeCalls := spyOnAWSExchange(h) _, err := h.executeExchange(ctx, &events.LambdaFunctionURLRequest{ Headers: map[string]string{"authorization": "Bearer exchange-token"}, Body: fourEyesExchangeBody, }) + assert.Equal(t, 0, *exchangeCalls, "an unreadable 4-eyes policy must not let the exchange run") require.Error(t, err) assert.Contains(t, err.Error(), "4-eyes policy check") _, isClient := IsClientError(err) diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 5ad33ce0a..f257532ac 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -1354,7 +1354,11 @@ paths: operationId: updateRIExchangeConfig tags: [RIExchange] summary: Update RI exchange configuration - description: Requires `update:config` permission. + description: >- + Requires `update:config` permission. While 4-eyes approval mode + (`require_different_approver`) is on, the scheduler runs `auto` mode + as `manual`: it only raises pending exchanges, which need approval. + The stored mode is not changed. parameters: - $ref: '#/components/parameters/CSRFToken' requestBody: diff --git a/internal/config/types.go b/internal/config/types.go index 18d5c1916..de54bb64f 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -114,6 +114,9 @@ type GlobalConfig struct { // a standard SOX / SOC2 segregation-of-duties control. Default: false. // Admins who created an execution and need to approve it must disable this // mode first (the admin wildcard is NOT exempt from the restriction). + // While it is on, unattended RI exchange runs (RIExchangeMode "auto" and + // ladder reshapes) run as "manual" and only raise pending records + // (issue #679). RequireDifferentApprover bool `json:"require_different_approver" dynamodbav:"require_different_approver"` } diff --git a/internal/server/handler_ri_exchange.go b/internal/server/handler_ri_exchange.go index 73406b5bb..8e4fb7793 100644 --- a/internal/server/handler_ri_exchange.go +++ b/internal/server/handler_ri_exchange.go @@ -114,7 +114,7 @@ func (app *Application) executeRIExchangeReshape(ctx context.Context, cfg *confi RIs: riInfos, Utilization: utilInfos, Config: exchange.RIExchangeConfig{ - Mode: cfg.RIExchangeMode, + Mode: fourEyesExchangeMode(cfg, cfg.RIExchangeMode), UtilizationThreshold: cfg.RIExchangeUtilizationThreshold, MaxPaymentPerExchangeUSD: cfg.RIExchangeMaxPerExchangeUSD, MaxPaymentDailyUSD: cfg.RIExchangeMaxDailyUSD, diff --git a/internal/server/ladder_write.go b/internal/server/ladder_write.go index afce36e10..566eec493 100644 --- a/internal/server/ladder_write.go +++ b/internal/server/ladder_write.go @@ -11,6 +11,7 @@ import ( pkgladder "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder" awsprovider "github.com/LeanerCloud/cloud-commitments-go/providers/aws" awsladder "github.com/LeanerCloud/cloud-commitments-go/providers/aws/ladder" + "github.com/LeanerCloud/cloud-commitments-go/providers/aws/recommendations" ) // exchangeRunnerAdapter bridges internal/server wiring (exchange store, EC2 exchange @@ -31,8 +32,7 @@ type exchangeRunnerAdapter struct { } // RunAutoExchange implements providers/aws/ladder.exchangeRunner. It constructs -// fresh AWS clients, lists convertible RIs and utilization, converts them for the -// exchange package, then delegates to exchange.RunAutoExchange. +// fresh AWS clients and hands them to run. // // ladderRunID is forwarded to RunAutoExchangeParams.LadderRunID so exchange scopes // its pending-cancellation to the ladder origin (issue #1348 / gap G10). DryRun @@ -46,12 +46,36 @@ func (a *exchangeRunnerAdapter) RunAutoExchange(ctx context.Context, cfg exchang ec2Client := awsprovider.NewEC2ClientDirect(awsCfg) recsClient := awsprovider.NewRecommendationsClientDirect(awsCfg) + clients := riExchangeClients{ + listConvertibleRIs: ec2Client.ListConvertibleReservedInstances, + getRIUtilization: func(ctx context.Context, lookbackDays int) ([]recommendations.RIUtilization, error) { + return recsClient.GetRIUtilization(ctx, lookbackDays, a.region) + }, + exchangeClient: exchange.NewExchangeClient(awsCfg), + lookupOffering: newConvertibleOfferingLookup(ec2Client), + accountID: a.accountID, + region: a.region, + } + return a.run(ctx, cfg, clients, ladderRunID, dryRun) +} + +// run applies the 4-eyes policy to cfg.Mode, lists convertible RIs and +// utilization, converts them for the exchange package, then delegates to +// exchange.RunAutoExchange. The ladder always asks for auto mode; under 4-eyes +// it runs as manual, so exchanges wait for approval (issue #679). A global +// config that cannot be read fails closed: the run stops before any AWS call. +func (a *exchangeRunnerAdapter) run(ctx context.Context, cfg exchange.RIExchangeConfig, clients riExchangeClients, ladderRunID *string, dryRun bool) (*exchange.AutoExchangeResult, error) { + globalCfg, err := a.app.Config.GetGlobalConfig(ctx) + if err != nil { + return nil, fmt.Errorf("exchangeRunnerAdapter: 4-eyes policy check: load global config: %w", err) + } + cfg.Mode = fourEyesExchangeMode(globalCfg, cfg.Mode) - instances, err := ec2Client.ListConvertibleReservedInstances(ctx) + instances, err := clients.listConvertibleRIs(ctx) if err != nil { return nil, fmt.Errorf("exchangeRunnerAdapter: list convertible RIs: %w", err) } - utilData, err := recsClient.GetRIUtilization(ctx, cfg.LookbackDays, a.region) + utilData, err := clients.getRIUtilization(ctx, cfg.LookbackDays) if err != nil { return nil, fmt.Errorf("exchangeRunnerAdapter: get RI utilization: %w", err) } @@ -61,13 +85,13 @@ func (a *exchangeRunnerAdapter) RunAutoExchange(ctx context.Context, cfg exchang return exchange.RunAutoExchange(ctx, exchange.RunAutoExchangeParams{ Store: store, - ExchangeClient: exchange.NewExchangeClient(awsCfg), - LookupOffering: newConvertibleOfferingLookup(ec2Client), + ExchangeClient: clients.exchangeClient, + LookupOffering: clients.lookupOffering, RIs: riInfos, Utilization: utilInfos, Config: cfg, - AccountID: a.accountID, - Region: a.region, + AccountID: clients.accountID, + Region: clients.region, DashboardURL: a.app.appConfig.DashboardURL, RIMetadata: riMetadata, LadderRunID: ladderRunID, diff --git a/internal/server/ri_exchange_four_eyes.go b/internal/server/ri_exchange_four_eyes.go new file mode 100644 index 000000000..fac7a7415 --- /dev/null +++ b/internal/server/ri_exchange_four_eyes.go @@ -0,0 +1,33 @@ +package server + +import ( + "log" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/exchange" + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" +) + +// fourEyesExchangeMode returns the mode an unattended RI exchange run (the +// scheduled reshape task or a ladder reshape) may use under the current +// 4-eyes policy (issue #679). +// +// In any mode but "manual", pkg/exchange executes exchanges on the spot with +// no human approval. That bypasses RequireDifferentApprover, which the direct +// execute endpoints already refuse (#517). So with 4-eyes on, every mode but +// "manual" is downgraded to "manual": the run only raises pending records, +// and those go through the approve endpoint and its 4-eyes check. "auto", "" +// and typos all get the downgrade, because pkg/exchange treats every +// non-"manual" value as auto. +// +// A nil global config fails closed: it is treated as 4-eyes on, never as off. +func fourEyesExchangeMode(cfg *config.GlobalConfig, mode string) string { + manual := string(exchange.ExchangeModeManual) + if mode == manual { + return mode + } + if cfg != nil && !cfg.RequireDifferentApprover { + return mode + } + log.Printf("RI exchange: 4-eyes approval mode is on; running mode %q as %q so every exchange waits for approval", mode, manual) + return manual +} diff --git a/internal/server/ri_exchange_four_eyes_test.go b/internal/server/ri_exchange_four_eyes_test.go new file mode 100644 index 000000000..8e4269790 --- /dev/null +++ b/internal/server/ri_exchange_four_eyes_test.go @@ -0,0 +1,248 @@ +package server + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/exchange" + "github.com/LeanerCloud/cloud-commitments-go/providers/aws/recommendations" + ec2svc "github.com/LeanerCloud/cloud-commitments-go/providers/aws/services/ec2" + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/LeanerCloud/cloud-commitments-platform/internal/email" + "github.com/LeanerCloud/cloud-commitments-platform/internal/testutil" +) + +// fourEyesExchangeFixture is one underutilized convertible RI that the +// reshape analysis turns into a single exchange, wired to spies that record +// whether the exchange was executed and which records were saved. +type fourEyesExchangeFixture struct { + store *mockConfigStoreForExchange + clients riExchangeClients + executed int + listed int + saved []*config.RIExchangeRecord +} + +func newFourEyesExchangeFixture(globalCfg *config.GlobalConfig, globalCfgErr error) *fourEyesExchangeFixture { + f := &fourEyesExchangeFixture{} + f.store = &mockConfigStoreForExchange{ + globalConfig: globalCfg, + globalConfigErr: globalCfgErr, + saveRIExchangeRecordFunc: func(_ context.Context, record *config.RIExchangeRecord) error { + f.saved = append(f.saved, record) + return nil + }, + reserveRIExchangeFunc: func(_ context.Context, record *config.RIExchangeRecord, _, _ string) (string, error) { + record.ID = "four-eyes-reservation" + f.saved = append(f.saved, record) + return "100.000000", nil + }, + } + quote := func() *exchange.ExchangeQuoteSummary { + return &exchange.ExchangeQuoteSummary{ + IsValidExchange: true, + PaymentDueUSD: new(big.Rat).SetFloat64(3.50), + PaymentDueUSDStr: "3.50", + CurrencyCode: "USD", + } + } + f.clients = riExchangeClients{ + listConvertibleRIs: func(context.Context) ([]ec2svc.ConvertibleRI, error) { + f.listed++ + return []ec2svc.ConvertibleRI{{ + ReservedInstanceID: "ri-underutilized", + InstanceType: "c5.xlarge", + InstanceCount: 1, + NormalizationFactor: 8.0, + ProductDescription: "Linux/UNIX", + InstanceTenancy: "default", + Scope: "Region", + Duration: 31536000, + }}, nil + }, + getRIUtilization: func(context.Context, int) ([]recommendations.RIUtilization, error) { + return []recommendations.RIUtilization{{ReservedInstanceID: "ri-underutilized", UtilizationPercent: 30.0}}, nil + }, + exchangeClient: &mockExchangeClient{ + getQuoteFunc: func(context.Context, exchange.ExchangeQuoteRequest) (*exchange.ExchangeQuoteSummary, error) { + return quote(), nil + }, + executeFunc: func(context.Context, exchange.ExchangeExecuteRequest) (string, *exchange.ExchangeQuoteSummary, error) { + f.executed++ + return "exch-unattended", quote(), nil + }, + }, + lookupOffering: func(context.Context, exchange.LookupOfferingParams) (string, error) { + return "offering-456", nil + }, + accountID: "acct-four-eyes", + region: "us-east-1", + } + return f +} + +func reshapeGlobalConfig(mode string, fourEyes bool) *config.GlobalConfig { + return &config.GlobalConfig{ + RIExchangeEnabled: true, + RIExchangeMode: mode, + RIExchangeUtilizationThreshold: 95.0, + RIExchangeMaxPerExchangeUSD: 100.0, + RIExchangeMaxDailyUSD: 500.0, + RIExchangeLookbackDays: 30, + RequireDifferentApprover: fourEyes, + } +} + +// assertDowngradedToPending checks that a run raised one pending manual record +// and executed nothing. +func assertDowngradedToPending(t *testing.T, f *fourEyesExchangeFixture, result *exchange.AutoExchangeResult) { + t.Helper() + assert.Equal(t, 0, f.executed, "no exchange may execute without approval while 4-eyes is on") + require.NotNil(t, result) + assert.Equal(t, "manual", result.Mode) + assert.Empty(t, result.Completed) + require.Len(t, result.Pending, 1) + require.Len(t, f.saved, 1) + assert.Equal(t, "pending", f.saved[0].Status) + assert.Equal(t, "manual", f.saved[0].Mode) + assert.NotEmpty(t, f.saved[0].ApprovalToken, "the pending record must be approvable through the approval flow") +} + +// TestExecuteRIExchangeReshape_FourEyesDowngradesAutoToManual pins issue #679 +// (A): with RequireDifferentApprover on, the scheduled reshape must not run +// exchanges unattended, whatever non-manual mode is stored. It raises pending +// records and sends the approval email instead. +func TestExecuteRIExchangeReshape_FourEyesDowngradesAutoToManual(t *testing.T) { + // "" and "Auto" are included because pkg/exchange treats every + // non-"manual" value as auto. + for _, mode := range []string{"auto", "", "Auto"} { + t.Run("mode="+mode, func(t *testing.T) { + ctx := testutil.TestContext(t) + cfg := reshapeGlobalConfig(mode, true) + f := newFourEyesExchangeFixture(cfg, nil) + + approvalSent, completedSent := false, false + app := &Application{ + Config: f.store, + Email: &mockEmailSender{ + sendApprovalFunc: func(context.Context, email.RIExchangeNotificationData) error { + approvalSent = true + return nil + }, + sendCompletedFunc: func(context.Context, email.RIExchangeNotificationData) error { + completedSent = true + return nil + }, + }, + } + + result, err := app.executeRIExchangeReshape(ctx, cfg, f.clients) + require.NoError(t, err) + assertDowngradedToPending(t, f, result) + assert.True(t, approvalSent, "the downgraded run must send the pending-approval email") + assert.False(t, completedSent) + }) + } +} + +// TestExecuteRIExchangeReshape_FourEyesOffAutoStillExecutes is the control: +// with 4-eyes off, auto mode keeps executing exchanges. +func TestExecuteRIExchangeReshape_FourEyesOffAutoStillExecutes(t *testing.T) { + ctx := testutil.TestContext(t) + cfg := reshapeGlobalConfig("auto", false) + f := newFourEyesExchangeFixture(cfg, nil) + app := &Application{Config: f.store} + + result, err := app.executeRIExchangeReshape(ctx, cfg, f.clients) + require.NoError(t, err) + assert.Equal(t, 1, f.executed) + assert.Equal(t, "auto", result.Mode) + assert.Len(t, result.Completed, 1) + assert.Empty(t, result.Pending) +} + +// TestExchangeRunnerAdapter_FourEyesDowngradesLadderReshape: the ladder +// always asks for auto mode, and under 4-eyes it must run as manual too. +func TestExchangeRunnerAdapter_FourEyesDowngradesLadderReshape(t *testing.T) { + ctx := testutil.TestContext(t) + f := newFourEyesExchangeFixture(&config.GlobalConfig{RequireDifferentApprover: true}, nil) + adapter := &exchangeRunnerAdapter{app: &Application{Config: f.store}, region: "us-east-1", accountID: "acct-four-eyes"} + ladderRunID := "ladder-run-1" + + result, err := adapter.run(ctx, ladderExchangeConfig(), f.clients, &ladderRunID, false) + require.NoError(t, err) + assertDowngradedToPending(t, f, result) + require.NotNil(t, f.saved[0].LadderRunID) + assert.Equal(t, ladderRunID, *f.saved[0].LadderRunID) +} + +// TestExchangeRunnerAdapter_FourEyesOffLadderStillExecutes is the control for +// the ladder path. +func TestExchangeRunnerAdapter_FourEyesOffLadderStillExecutes(t *testing.T) { + ctx := testutil.TestContext(t) + f := newFourEyesExchangeFixture(&config.GlobalConfig{}, nil) + adapter := &exchangeRunnerAdapter{app: &Application{Config: f.store}, region: "us-east-1", accountID: "acct-four-eyes"} + ladderRunID := "ladder-run-1" + + result, err := adapter.run(ctx, ladderExchangeConfig(), f.clients, &ladderRunID, false) + require.NoError(t, err) + assert.Equal(t, 1, f.executed) + assert.Equal(t, "auto", result.Mode) + assert.Len(t, result.Completed, 1) +} + +// TestExchangeRunnerAdapter_PolicyLookupErrorFailsClosed: an unreadable +// global config must stop the ladder reshape before any AWS call, never be +// treated as "4-eyes off". +func TestExchangeRunnerAdapter_PolicyLookupErrorFailsClosed(t *testing.T) { + ctx := testutil.TestContext(t) + f := newFourEyesExchangeFixture(nil, errors.New("db down")) + adapter := &exchangeRunnerAdapter{app: &Application{Config: f.store}, region: "us-east-1", accountID: "acct-four-eyes"} + + result, err := adapter.run(ctx, ladderExchangeConfig(), f.clients, nil, false) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "4-eyes policy check") + assert.Equal(t, 0, f.listed, "no AWS call may happen when the policy cannot be read") + assert.Equal(t, 0, f.executed) + assert.Empty(t, f.saved) +} + +func ladderExchangeConfig() exchange.RIExchangeConfig { + return exchange.RIExchangeConfig{ + Mode: string(exchange.ExchangeModeAuto), + UtilizationThreshold: 95.0, + MaxPaymentPerExchangeUSD: 100.0, + MaxPaymentDailyUSD: 500.0, + LookbackDays: 30, + } +} + +func TestFourEyesExchangeMode(t *testing.T) { + on := &config.GlobalConfig{RequireDifferentApprover: true} + off := &config.GlobalConfig{} + tests := []struct { + name string + cfg *config.GlobalConfig + mode string + want string + }{ + {"4-eyes on downgrades auto", on, "auto", "manual"}, + {"4-eyes on downgrades an empty mode", on, "", "manual"}, + {"4-eyes on downgrades an unknown mode", on, "Auto", "manual"}, + {"4-eyes on keeps manual", on, "manual", "manual"}, + {"4-eyes off keeps auto", off, "auto", "auto"}, + {"4-eyes off keeps manual", off, "manual", "manual"}, + {"nil config fails closed", nil, "auto", "manual"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, fourEyesExchangeMode(tt.cfg, tt.mode)) + }) + } +}