Skip to content

Commit f576db2

Browse files
committed
feat(api): validate plan account provider matches plan provider on assignment (closes #209)
Backend gate for PUT /api/plans/:id/accounts. Every account assigned to a plan must have its provider match one of the providers derived from the plan's services map (key format "provider:service", e.g. "aws:ec2"). Mismatches return a single HTTP 400 listing every offender; the underlying SetPlanAccounts store write is never invoked on failure. Per spec acceptance criterion E-4 in specs/multi-account-execution/acceptance.md, this is the backend hardening of the existing frontend single-provider-per-plan rule. Implementation -------------- - New `derivePlanProviders(plan)` helper extracts the distinct provider set from `plan.Services` keys (sorted slice for stable error messages). - New `Handler.validatePlanAccountProviders(ctx, planID, accountIDs)` helper holds the validation block — extracted from setPlanAccounts to stay under the gocyclo budget (limit 10). - `setPlanAccounts` loads the plan, derives providers, then for each account_id in the request loads the account and checks its provider against the derived set. All offenders are collected and reported in a single error rather than failing fast — clients fix everything in one round-trip. - Plan not found → 404 with the plan ID. - Account not found → 404 with the account ID. - Mismatch(es) → 400 "plan provider mismatch: account "<name>" has provider="<got>", expected one of [<sorted list>]; ..." (single line, parseable). - Empty services map → defensive skip of validation; production plans always have ≥1 service (frontend enforces this), and the test pins the behaviour so a future change is conscious. Mocks ----- - `MockConfigStore.GetPurchasePlan` now resolves to (in order): `GetPurchasePlanFn` override, registered testify expectation, or a default minimal `{ID: planID}` plan with empty Services. The default fallback lets pre-existing tests like `TestSetPlanAccounts_Success` keep working without setting up the new mock call — empty Services trips the defensive skip-validation branch. - `MockConfigStore.SetPlanAccounts` now uses `SetPlanAccountsFn` when set, so tests can capture and assert on the call (mismatch tests verify the underlying store write is NOT invoked on failure). Tests ----- Seven new tests in `handler_accounts_test.go`: - TestSetPlanAccounts_SingleMismatch — one Azure account vs aws plan → 400 - TestSetPlanAccounts_MultipleMismatches — Azure + GCP vs aws plan → 400 listing both - TestSetPlanAccounts_ValidHappyPath — AWS account vs aws plan → 200, store write captured - TestSetPlanAccounts_PlanNotFound — GetPurchasePlan returns (nil, nil) → 404 - TestSetPlanAccounts_AccountNotFound — GetCloudAccount returns (nil, nil) → 404 referencing the account ID - TestSetPlanAccounts_MixedValidAndMismatch — only the offender named in error; store write NOT called - TestSetPlanAccounts_EmptyServicesSkipsValidation — defensive behaviour pinned
1 parent c84fd02 commit f576db2

3 files changed

Lines changed: 381 additions & 0 deletions

File tree

internal/api/handler_accounts.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"slices"
8+
"sort"
79
"strings"
810
"time"
911

@@ -1037,7 +1039,100 @@ func (h *Handler) deleteAccountServiceOverride(ctx context.Context, req *events.
10371039
return nil, nil
10381040
}
10391041

1042+
// derivePlanProviders extracts the distinct set of providers a plan
1043+
// targets by parsing the keys of plan.Services (format "provider:service",
1044+
// e.g. "aws:ec2"). Returns a sorted slice for stable error messages.
1045+
// An empty result means the plan has no parseable services — production
1046+
// plans always carry at least one (frontend enforces this), so an empty
1047+
// return is a defensive case that signals to skip provider validation.
1048+
func derivePlanProviders(plan *config.PurchasePlan) []string {
1049+
if plan == nil {
1050+
return nil
1051+
}
1052+
seen := make(map[string]struct{}, len(plan.Services))
1053+
for k := range plan.Services {
1054+
// Keys are "provider:service"; skip malformed keys rather than guess.
1055+
parts := strings.SplitN(k, ":", 2)
1056+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
1057+
continue
1058+
}
1059+
seen[parts[0]] = struct{}{}
1060+
}
1061+
out := make([]string, 0, len(seen))
1062+
for p := range seen {
1063+
out = append(out, p)
1064+
}
1065+
sort.Strings(out)
1066+
return out
1067+
}
1068+
1069+
// validatePlanAccountProviders enforces the issue-#209 / spec E-4 rule
1070+
// that every account assigned to a plan must have its provider match
1071+
// one of the plan's derived providers. Returns:
1072+
// - 404 ClientError when the plan does not exist
1073+
// - 404 ClientError when an account_id does not exist (referencing
1074+
// the offending ID)
1075+
// - 400 ClientError listing every provider mismatch in one message
1076+
// (so clients fix everything in one round-trip rather than
1077+
// resubmitting to discover the next)
1078+
// - nil when all accounts match (or when the plan has no parseable
1079+
// services — defensive skip; production plans always carry at least
1080+
// one service, frontend enforces this)
1081+
//
1082+
// Pulled out of setPlanAccounts to keep that function under the gocyclo
1083+
// budget (limit 10). No business logic lives here that isn't otherwise
1084+
// described in setPlanAccounts' doc comment.
1085+
func (h *Handler) validatePlanAccountProviders(ctx context.Context, planID string, accountIDs []string) error {
1086+
plan, err := h.config.GetPurchasePlan(ctx, planID)
1087+
if err != nil {
1088+
return fmt.Errorf("accounts: failed to get plan: %w", err)
1089+
}
1090+
if plan == nil {
1091+
return NewClientError(404, fmt.Sprintf("plan not found: %s", planID))
1092+
}
1093+
1094+
expected := derivePlanProviders(plan)
1095+
if len(expected) == 0 {
1096+
return nil
1097+
}
1098+
1099+
type mismatch struct {
1100+
ID string
1101+
Name string
1102+
Provider string
1103+
}
1104+
var mismatches []mismatch
1105+
for _, aid := range accountIDs {
1106+
acct, getErr := h.config.GetCloudAccount(ctx, aid)
1107+
if getErr != nil {
1108+
return fmt.Errorf("accounts: failed to get account %s: %w", aid, getErr)
1109+
}
1110+
if acct == nil {
1111+
return NewClientError(404, fmt.Sprintf("account not found: %s", aid))
1112+
}
1113+
if !slices.Contains(expected, acct.Provider) {
1114+
mismatches = append(mismatches, mismatch{ID: aid, Name: acct.Name, Provider: acct.Provider})
1115+
}
1116+
}
1117+
if len(mismatches) == 0 {
1118+
return nil
1119+
}
1120+
1121+
parts := make([]string, len(mismatches))
1122+
for i, m := range mismatches {
1123+
parts[i] = fmt.Sprintf("account %q has provider=%q, expected one of %v",
1124+
m.Name, m.Provider, expected)
1125+
}
1126+
return NewClientError(400, "plan provider mismatch: "+strings.Join(parts, "; "))
1127+
}
1128+
10401129
// setPlanAccounts handles PUT /api/plans/:id/accounts.
1130+
//
1131+
// Per issue #209 / spec acceptance criterion E-4, every account assigned
1132+
// to a plan must have its provider match one of the plan's derived
1133+
// providers (extracted from plan.Services keys). Mismatches return 400
1134+
// listing every offender; the assignment is rejected atomically (no
1135+
// partial writes).
10411136
func (h *Handler) setPlanAccounts(ctx context.Context, httpReq *events.LambdaFunctionURLRequest, id string) (any, error) {
10421137
if err := validateUUID(id); err != nil {
10431138
return nil, err
@@ -1060,6 +1155,12 @@ func (h *Handler) setPlanAccounts(ctx context.Context, httpReq *events.LambdaFun
10601155
}
10611156
}
10621157

1158+
// Provider-match validation (issue #209). Extracted to keep
1159+
// setPlanAccounts under the gocyclo budget (10).
1160+
if err := h.validatePlanAccountProviders(ctx, id, body.AccountIDs); err != nil {
1161+
return nil, err
1162+
}
1163+
10631164
if err := h.config.SetPlanAccounts(ctx, id, body.AccountIDs); err != nil {
10641165
return nil, fmt.Errorf("accounts: %w", err)
10651166
}

internal/api/handler_accounts_test.go

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,253 @@ func TestSetPlanAccounts_Success(t *testing.T) {
485485
assert.Nil(t, result)
486486
}
487487

488+
// ── Provider-validation tests for setPlanAccounts (issue #209)
489+
// Every account assigned to a plan must have its provider match one of
490+
// the providers derived from the plan's services map (key format
491+
// "provider:service"). Mismatches return a single 400 listing every
492+
// offender; the underlying store write is never invoked on failure.
493+
494+
const (
495+
planID209 = "22222222-2222-2222-2222-222222222222"
496+
awsAcct209 = "11111111-1111-1111-1111-111111111111"
497+
azureAcct1 = "33333333-3333-3333-3333-333333333333"
498+
azureAcct2 = "44444444-4444-4444-4444-444444444444"
499+
missingAcct = "55555555-5555-5555-5555-555555555555"
500+
)
501+
502+
// awsPlan209 returns a plan whose services map yields a single derived
503+
// provider ("aws"). Used as the default fixture across the mismatch
504+
// tests below.
505+
func awsPlan209() *config.PurchasePlan {
506+
return &config.PurchasePlan{
507+
ID: planID209,
508+
Name: "AWS-only plan",
509+
Services: map[string]config.ServiceConfig{"aws:ec2": {}},
510+
}
511+
}
512+
513+
func TestSetPlanAccounts_SingleMismatch(t *testing.T) {
514+
ctx := context.Background()
515+
mockAuth := new(MockAuthService)
516+
setupAdminAuth(ctx, mockAuth)
517+
518+
setCalled := false
519+
store := setupAdminMock(ctx)
520+
store.GetPurchasePlanFn = func(_ context.Context, _ string) (*config.PurchasePlan, error) {
521+
return awsPlan209(), nil
522+
}
523+
store.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) {
524+
return &config.CloudAccount{ID: id, Name: "prod-azure", Provider: "azure"}, nil
525+
}
526+
store.SetPlanAccountsFn = func(_ context.Context, _ string, _ []string) error {
527+
setCalled = true
528+
return nil
529+
}
530+
handler := &Handler{auth: mockAuth, config: store}
531+
532+
body := `{"account_ids":["` + azureAcct1 + `"]}`
533+
_, err := handler.setPlanAccounts(ctx, adminRequest(body), planID209)
534+
require.Error(t, err)
535+
ce, ok := IsClientError(err)
536+
require.True(t, ok, "expected a clientError, got %T", err)
537+
assert.Equal(t, 400, ce.code)
538+
assert.Contains(t, ce.Error(), "prod-azure")
539+
assert.Contains(t, ce.Error(), "azure")
540+
assert.Contains(t, ce.Error(), "aws")
541+
assert.False(t, setCalled, "SetPlanAccounts must NOT be called when validation fails")
542+
}
543+
544+
func TestSetPlanAccounts_MultipleMismatches(t *testing.T) {
545+
ctx := context.Background()
546+
mockAuth := new(MockAuthService)
547+
setupAdminAuth(ctx, mockAuth)
548+
549+
setCalled := false
550+
store := setupAdminMock(ctx)
551+
store.GetPurchasePlanFn = func(_ context.Context, _ string) (*config.PurchasePlan, error) {
552+
return awsPlan209(), nil
553+
}
554+
store.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) {
555+
switch id {
556+
case azureAcct1:
557+
return &config.CloudAccount{ID: id, Name: "prod-azure", Provider: "azure"}, nil
558+
case azureAcct2:
559+
return &config.CloudAccount{ID: id, Name: "stage-gcp", Provider: "gcp"}, nil
560+
}
561+
return nil, nil
562+
}
563+
store.SetPlanAccountsFn = func(_ context.Context, _ string, _ []string) error {
564+
setCalled = true
565+
return nil
566+
}
567+
handler := &Handler{auth: mockAuth, config: store}
568+
569+
body := `{"account_ids":["` + azureAcct1 + `","` + azureAcct2 + `"]}`
570+
_, err := handler.setPlanAccounts(ctx, adminRequest(body), planID209)
571+
require.Error(t, err)
572+
ce, ok := IsClientError(err)
573+
require.True(t, ok)
574+
assert.Equal(t, 400, ce.code)
575+
// Both offenders named in a single error so the client gets the full
576+
// picture in one round-trip.
577+
assert.Contains(t, ce.Error(), "prod-azure")
578+
assert.Contains(t, ce.Error(), "stage-gcp")
579+
assert.Contains(t, ce.Error(), "azure")
580+
assert.Contains(t, ce.Error(), "gcp")
581+
assert.False(t, setCalled, "SetPlanAccounts must NOT be called when validation fails")
582+
}
583+
584+
func TestSetPlanAccounts_ValidHappyPath(t *testing.T) {
585+
ctx := context.Background()
586+
mockAuth := new(MockAuthService)
587+
setupAdminAuth(ctx, mockAuth)
588+
589+
var capturedIDs []string
590+
store := setupAdminMock(ctx)
591+
store.GetPurchasePlanFn = func(_ context.Context, _ string) (*config.PurchasePlan, error) {
592+
return awsPlan209(), nil
593+
}
594+
store.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) {
595+
return &config.CloudAccount{ID: id, Name: "prod-aws", Provider: "aws"}, nil
596+
}
597+
store.SetPlanAccountsFn = func(_ context.Context, _ string, ids []string) error {
598+
capturedIDs = ids
599+
return nil
600+
}
601+
handler := &Handler{auth: mockAuth, config: store}
602+
603+
body := `{"account_ids":["` + awsAcct209 + `"]}`
604+
result, err := handler.setPlanAccounts(ctx, adminRequest(body), planID209)
605+
require.NoError(t, err)
606+
assert.Nil(t, result)
607+
assert.Equal(t, []string{awsAcct209}, capturedIDs, "SetPlanAccounts should be called with the validated IDs")
608+
}
609+
610+
func TestSetPlanAccounts_PlanNotFound(t *testing.T) {
611+
ctx := context.Background()
612+
mockAuth := new(MockAuthService)
613+
setupAdminAuth(ctx, mockAuth)
614+
615+
setCalled := false
616+
store := setupAdminMock(ctx)
617+
store.GetPurchasePlanFn = func(_ context.Context, _ string) (*config.PurchasePlan, error) {
618+
return nil, nil // store-style "not found": (nil, nil)
619+
}
620+
store.SetPlanAccountsFn = func(_ context.Context, _ string, _ []string) error {
621+
setCalled = true
622+
return nil
623+
}
624+
handler := &Handler{auth: mockAuth, config: store}
625+
626+
body := `{"account_ids":["` + awsAcct209 + `"]}`
627+
_, err := handler.setPlanAccounts(ctx, adminRequest(body), planID209)
628+
require.Error(t, err)
629+
ce, ok := IsClientError(err)
630+
require.True(t, ok)
631+
assert.Equal(t, 404, ce.code)
632+
assert.Contains(t, ce.Error(), planID209)
633+
assert.False(t, setCalled, "SetPlanAccounts must NOT be called when the plan is not found")
634+
}
635+
636+
func TestSetPlanAccounts_AccountNotFound(t *testing.T) {
637+
ctx := context.Background()
638+
mockAuth := new(MockAuthService)
639+
setupAdminAuth(ctx, mockAuth)
640+
641+
setCalled := false
642+
store := setupAdminMock(ctx)
643+
store.GetPurchasePlanFn = func(_ context.Context, _ string) (*config.PurchasePlan, error) {
644+
return awsPlan209(), nil
645+
}
646+
store.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) {
647+
if id == missingAcct {
648+
return nil, nil // store-style not-found
649+
}
650+
return &config.CloudAccount{ID: id, Name: "prod-aws", Provider: "aws"}, nil
651+
}
652+
store.SetPlanAccountsFn = func(_ context.Context, _ string, _ []string) error {
653+
setCalled = true
654+
return nil
655+
}
656+
handler := &Handler{auth: mockAuth, config: store}
657+
658+
body := `{"account_ids":["` + missingAcct + `"]}`
659+
_, err := handler.setPlanAccounts(ctx, adminRequest(body), planID209)
660+
require.Error(t, err)
661+
ce, ok := IsClientError(err)
662+
require.True(t, ok)
663+
assert.Equal(t, 404, ce.code)
664+
assert.Contains(t, ce.Error(), missingAcct, "404 should reference the missing account ID")
665+
assert.False(t, setCalled, "SetPlanAccounts must NOT be called when an account is not found")
666+
}
667+
668+
func TestSetPlanAccounts_MixedValidAndMismatch(t *testing.T) {
669+
ctx := context.Background()
670+
mockAuth := new(MockAuthService)
671+
setupAdminAuth(ctx, mockAuth)
672+
673+
setCalled := false
674+
store := setupAdminMock(ctx)
675+
store.GetPurchasePlanFn = func(_ context.Context, _ string) (*config.PurchasePlan, error) {
676+
return awsPlan209(), nil
677+
}
678+
store.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) {
679+
switch id {
680+
case awsAcct209:
681+
return &config.CloudAccount{ID: id, Name: "prod-aws", Provider: "aws"}, nil
682+
case azureAcct1:
683+
return &config.CloudAccount{ID: id, Name: "prod-azure", Provider: "azure"}, nil
684+
}
685+
return nil, nil
686+
}
687+
store.SetPlanAccountsFn = func(_ context.Context, _ string, _ []string) error {
688+
setCalled = true
689+
return nil
690+
}
691+
handler := &Handler{auth: mockAuth, config: store}
692+
693+
body := `{"account_ids":["` + awsAcct209 + `","` + azureAcct1 + `"]}`
694+
_, err := handler.setPlanAccounts(ctx, adminRequest(body), planID209)
695+
require.Error(t, err)
696+
ce, ok := IsClientError(err)
697+
require.True(t, ok)
698+
assert.Equal(t, 400, ce.code)
699+
// Only the Azure account is the offender; the AWS account does not
700+
// appear in the error.
701+
assert.Contains(t, ce.Error(), "prod-azure")
702+
assert.NotContains(t, ce.Error(), "prod-aws")
703+
assert.False(t, setCalled, "SetPlanAccounts must NOT be called when even one account fails validation")
704+
}
705+
706+
func TestSetPlanAccounts_EmptyServicesSkipsValidation(t *testing.T) {
707+
ctx := context.Background()
708+
mockAuth := new(MockAuthService)
709+
setupAdminAuth(ctx, mockAuth)
710+
711+
var capturedIDs []string
712+
store := setupAdminMock(ctx)
713+
// Plan with an empty services map — derived provider set is empty;
714+
// the validation block skips and the assignment passes through.
715+
// Pins the defensive behaviour so a future change is conscious.
716+
store.GetPurchasePlanFn = func(_ context.Context, _ string) (*config.PurchasePlan, error) {
717+
return &config.PurchasePlan{ID: planID209, Name: "no-services"}, nil
718+
}
719+
store.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) {
720+
return &config.CloudAccount{ID: id, Name: "prod-azure", Provider: "azure"}, nil
721+
}
722+
store.SetPlanAccountsFn = func(_ context.Context, _ string, ids []string) error {
723+
capturedIDs = ids
724+
return nil
725+
}
726+
handler := &Handler{auth: mockAuth, config: store}
727+
728+
body := `{"account_ids":["` + azureAcct1 + `"]}`
729+
result, err := handler.setPlanAccounts(ctx, adminRequest(body), planID209)
730+
require.NoError(t, err)
731+
assert.Nil(t, result)
732+
assert.Equal(t, []string{azureAcct1}, capturedIDs, "empty services map → validation skipped → write proceeds")
733+
}
734+
488735
func TestListPlanAccounts_Success(t *testing.T) {
489736
ctx := context.Background()
490737
mockAuth := new(MockAuthService)

0 commit comments

Comments
 (0)