From 5db6ba6428037c354c0dce330f7e331ffcd322b8 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 27 May 2026 12:25:06 +0200 Subject: [PATCH 1/6] feat(api/plans): require non-empty target_accounts on plan creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates the "universal plans" class — purchase_plans rows with no matching plan_accounts row — that the historical "leave Target Account blank to mean all-accounts-of-this-provider" UX created. Plans now must be tied to at least one cloud_account. Changes: - PlanRequest gains target_accounts ([]string), validated non-empty + per- entry UUID-formed at POST /api/plans. Rejected requests return 400 with a clear message; the store is never touched. - createPlan inserts the plan_accounts rows immediately after CreatePurchasePlan inside the same handler, rolling the plan row back if provider-match validation or the SetPlanAccounts write fails. This keeps the invariant "every purchase_plans row has at least one plan_accounts row" end-to-end so a fresh universal plan can no longer appear via a partial failure. - PUT /api/plans/:id/accounts now rejects an empty account_ids body. Closes the back-door for re-creating a universal plan by clearing the list on an existing plan. - OpenAPI schema marks target_accounts as required with minItems: 1. Tests: - TestHandler_createPlan_RejectsEmptyTargetAccounts (missing field + empty array): both return 400; CreatePurchasePlan never reached. - TestHandler_createPlan_RejectsInvalidTargetAccountUUID: malformed UUID rejected before any DB write. - TestSetPlanAccounts_RejectsEmpty: PUT-with-empty-list returns 400, SetPlanAccounts never reached. - Existing TestHandler_createPlan + TestHandler_HandleRequest_CreatePlan extended to include target_accounts + GetCloudAccount stub for the new provider-match path. - Two router dispatch tests that used empty account_ids switched to a single valid UUID — they were never asserting on body content. Existing universal plans in the DB are NOT touched by this PR — those require operator review (delete / fan-out / manual reassignment). See scripts/list_universal_plans.sql in a follow-up commit for the diagnostic. --- internal/api/handler_accounts.go | 8 ++ internal/api/handler_accounts_router_test.go | 7 +- internal/api/handler_accounts_test.go | 27 ++++++ internal/api/handler_plans.go | 43 ++++++++++ internal/api/handler_plans_test.go | 87 +++++++++++++++++++- internal/api/handler_test.go | 12 ++- internal/api/openapi.yaml | 12 ++- internal/api/router_handlers_test.go | 5 +- internal/api/types.go | 8 ++ 9 files changed, 204 insertions(+), 5 deletions(-) diff --git a/internal/api/handler_accounts.go b/internal/api/handler_accounts.go index 637ec2a2d..780e83814 100644 --- a/internal/api/handler_accounts.go +++ b/internal/api/handler_accounts.go @@ -1224,6 +1224,14 @@ func (h *Handler) setPlanAccounts(ctx context.Context, httpReq *events.LambdaFun return nil, NewClientError(400, "invalid request body") } + // Reject empty account_ids: a plan must remain tied to at least one + // cloud_account row. Allowing the PUT to clear all rows would recreate + // the universal-plan bug class (purchase_plans row with no matching + // plan_accounts row) that createPlan now refuses at insert time. + if len(body.AccountIDs) == 0 { + return nil, NewClientError(400, "account_ids is required: a plan must be tied to at least one account") + } + for _, aid := range body.AccountIDs { if err := validateUUID(aid); err != nil { return nil, NewClientError(400, fmt.Sprintf("invalid account_id %q: must be a valid UUID", aid)) diff --git a/internal/api/handler_accounts_router_test.go b/internal/api/handler_accounts_router_test.go index 880322980..7dcf66d94 100644 --- a/internal/api/handler_accounts_router_test.go +++ b/internal/api/handler_accounts_router_test.go @@ -112,7 +112,12 @@ func TestRouterDispatch_PlanAccountsPUT_CorrectDispatch(t *testing.T) { r := setupRouterForDispatch(ctx) planID := "22222222-2222-2222-2222-222222222222" - req, method, path := routerReq("PUT", "/api/plans/"+planID+"/accounts", `{"account_ids":[]}`) + // Body uses a single valid UUID — universal-plans fix rejects empty + // account_ids as a 400, which would mask a routing-misdispatch bug we + // actually want this test to catch. Dispatch verification still works + // with any well-formed body. + body := `{"account_ids":["11111111-1111-1111-1111-111111111111"]}` + req, method, path := routerReq("PUT", "/api/plans/"+planID+"/accounts", body) _, err := r.Route(ctx, method, path, req) require.NoError(t, err, "plan/accounts PUT should not error — invalid ID means wrong handler") } diff --git a/internal/api/handler_accounts_test.go b/internal/api/handler_accounts_test.go index 1aebeddf7..ffd94c9d5 100644 --- a/internal/api/handler_accounts_test.go +++ b/internal/api/handler_accounts_test.go @@ -1028,6 +1028,33 @@ func TestSetPlanAccounts_EmptyServicesSkipsValidation(t *testing.T) { assert.Equal(t, []string{azureAcct1}, capturedIDs, "empty services map → validation skipped → write proceeds") } +// TestSetPlanAccounts_RejectsEmpty verifies the universal-plans fix: +// PUT /api/plans/:id/accounts with an empty account_ids returns HTTP 400 +// and never reaches the store. Eliminates the back-door for re-creating a +// universal plan by updating an existing one to have zero target accounts. +func TestSetPlanAccounts_RejectsEmpty(t *testing.T) { + ctx := context.Background() + mockAuth := new(MockAuthService) + setupAdminAuth(ctx, mockAuth) + + setCalled := false + store := setupAdminMock(ctx) + store.SetPlanAccountsFn = func(_ context.Context, _ string, _ []string) error { + setCalled = true + return nil + } + handler := &Handler{auth: mockAuth, config: store} + + body := `{"account_ids":[]}` + _, err := handler.setPlanAccounts(ctx, adminRequest(body), planID209) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok) + assert.Equal(t, 400, ce.code) + assert.Contains(t, ce.Error(), "account_ids is required") + assert.False(t, setCalled, "SetPlanAccounts must NOT be called when account_ids is empty") +} + func TestListPlanAccounts_Success(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) diff --git a/internal/api/handler_plans.go b/internal/api/handler_plans.go index 6e07ba464..d4249bd39 100644 --- a/internal/api/handler_plans.go +++ b/internal/api/handler_plans.go @@ -72,6 +72,16 @@ func (h *Handler) createPlan(ctx context.Context, httpReq *events.LambdaFunction return nil, NewClientError(400, "invalid request body") } + // target_accounts is required: a plan must be tied to at least one + // cloud_account row. The historical "leave blank to mean all accounts of + // this provider" behaviour created "universal plans" (rows in + // purchase_plans with no matching plan_accounts row) that were hard to + // scope, hard to filter, and hard to govern. Reject early with a clear + // 400 so the frontend can surface the error before any DB write. + if err := validateTargetAccounts(req.TargetAccounts); err != nil { + return nil, err + } + plan := req.toPurchasePlan() // Validate the plan @@ -83,9 +93,42 @@ func (h *Handler) createPlan(ctx context.Context, httpReq *events.LambdaFunction return nil, err } + // Provider-match validation + plan_accounts insert. SetPlanAccounts is + // transactional internally, but the plan-row insert above is not part of + // that tx. If either step here fails we roll the plan row back so the + // invariant "every purchase_plans row has at least one plan_accounts + // row" holds end-to-end — otherwise a validation failure would leave a + // fresh universal plan behind, which is exactly the bug class we're + // eliminating. + if err := h.validatePlanAccountProviders(ctx, plan.ID, req.TargetAccounts); err != nil { + _ = h.config.DeletePurchasePlan(ctx, plan.ID) + return nil, err + } + if err := h.config.SetPlanAccounts(ctx, plan.ID, req.TargetAccounts); err != nil { + _ = h.config.DeletePurchasePlan(ctx, plan.ID) + return nil, fmt.Errorf("accounts: %w", err) + } + return plan, nil } +// validateTargetAccounts rejects a missing/empty target_accounts payload and +// rejects entries that are not valid UUIDs. Mirrors the validation the +// dedicated PUT /plans/:id/accounts endpoint already performs (see +// setPlanAccounts in handler_accounts.go) so a request that gets past +// createPlan would also get past that endpoint. +func validateTargetAccounts(ids []string) error { + if len(ids) == 0 { + return NewClientError(400, "target_accounts is required: a plan must be tied to at least one account") + } + for _, aid := range ids { + if err := validateUUID(aid); err != nil { + return NewClientError(400, fmt.Sprintf("invalid target_account %q: must be a valid UUID", aid)) + } + } + return nil +} + func (h *Handler) getPlan(ctx context.Context, req *events.LambdaFunctionURLRequest, planID string) (any, error) { // Validate UUID format to prevent injection attacks if err := validateUUID(planID); err != nil { diff --git a/internal/api/handler_plans_test.go b/internal/api/handler_plans_test.go index 7cd1f5877..d25990da5 100644 --- a/internal/api/handler_plans_test.go +++ b/internal/api/handler_plans_test.go @@ -93,12 +93,22 @@ func TestHandler_createPlan(t *testing.T) { Role: "admin", } + targetAccountID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockStore.On("CreatePurchasePlan", ctx, mock.AnythingOfType("*config.PurchasePlan")).Return(nil) + mockStore.On("SetPlanAccounts", ctx, mock.AnythingOfType("string"), []string{targetAccountID}).Return(nil) + + // validatePlanAccountProviders looks up each target account and checks + // its provider matches the plan's. Stub GetCloudAccount to return an + // aws account so the provider-match passes (plan service is aws:rds). + mockStore.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { + return &config.CloudAccount{ID: id, Name: "test-aws", Provider: "aws"}, nil + } handler := &Handler{config: mockStore, auth: mockAuth} - body := `{"name": "New Plan", "enabled": true, "auto_purchase": false, "provider": "aws", "service": "rds"}` + body := `{"name": "New Plan", "enabled": true, "auto_purchase": false, "provider": "aws", "service": "rds", "target_accounts": ["` + targetAccountID + `"]}` req := &events.LambdaFunctionURLRequest{ Headers: map[string]string{ "Authorization": "Bearer admin-token", @@ -111,6 +121,7 @@ func TestHandler_createPlan(t *testing.T) { plan := result.(*config.PurchasePlan) assert.Equal(t, "New Plan", plan.Name) assert.True(t, plan.Enabled) + mockStore.AssertCalled(t, "SetPlanAccounts", ctx, mock.AnythingOfType("string"), []string{targetAccountID}) } func TestHandler_createPlan_InvalidBody(t *testing.T) { @@ -139,6 +150,80 @@ func TestHandler_createPlan_InvalidBody(t *testing.T) { assert.Nil(t, result) } +// TestHandler_createPlan_RejectsEmptyTargetAccounts verifies the universal-plan +// fix: a POST /plans without target_accounts (or with an empty list) returns +// HTTP 400 and never reaches CreatePurchasePlan. This is the design invariant +// every purchase_plans row must have at least one matching plan_accounts row. +func TestHandler_createPlan_RejectsEmptyTargetAccounts(t *testing.T) { + ctx := context.Background() + mockAuth := new(MockAuthService) + + adminSession := &Session{ + UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + Email: "admin@example.com", + Role: "admin", + } + mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + + cases := []struct { + name string + body string + }{ + {"missing field", `{"name": "P", "provider": "aws", "service": "rds"}`}, + {"empty array", `{"name": "P", "provider": "aws", "service": "rds", "target_accounts": []}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Fresh store per case: AssertNotCalled below would otherwise + // see the previous case's setup; tests share nothing. + mockStore := new(MockConfigStore) + handler := &Handler{config: mockStore, auth: mockAuth} + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"Authorization": "Bearer admin-token"}, + Body: tc.body, + } + result, err := handler.createPlan(ctx, req) + assert.Nil(t, result) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok, "expected ClientError, got %T: %v", err, err) + assert.Equal(t, 400, ce.code) + assert.Contains(t, ce.Error(), "target_accounts") + mockStore.AssertNotCalled(t, "CreatePurchasePlan", mock.Anything, mock.Anything) + }) + } +} + +// TestHandler_createPlan_RejectsInvalidTargetAccountUUID verifies that a +// malformed UUID in target_accounts is rejected before any DB write — same +// validation contract as PUT /plans/:id/accounts (handler_accounts.go). +func TestHandler_createPlan_RejectsInvalidTargetAccountUUID(t *testing.T) { + ctx := context.Background() + mockStore := new(MockConfigStore) + mockAuth := new(MockAuthService) + + adminSession := &Session{ + UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + Email: "admin@example.com", + Role: "admin", + } + mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + + handler := &Handler{config: mockStore, auth: mockAuth} + body := `{"name": "P", "provider": "aws", "service": "rds", "target_accounts": ["not-a-uuid"]}` + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"Authorization": "Bearer admin-token"}, + Body: body, + } + result, err := handler.createPlan(ctx, req) + assert.Nil(t, result) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok, "expected ClientError, got %T: %v", err, err) + assert.Equal(t, 400, ce.code) + mockStore.AssertNotCalled(t, "CreatePurchasePlan", mock.Anything, mock.Anything) +} + func TestHandler_getPlan(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index 5f8e3b3f0..3eedae9ba 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -576,6 +576,13 @@ func TestHandler_HandleRequest_CreatePlan(t *testing.T) { mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) mockStore.On("CreatePurchasePlan", mock.Anything, mock.AnythingOfType("*config.PurchasePlan")).Return(nil) + mockStore.On("SetPlanAccounts", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("[]string")).Return(nil) + // Universal-plans fix: createPlan now validates target_accounts' + // providers match the plan's. Stub the cloud-account lookup so the + // provider-match passes (plan has aws:rds, account returns aws). + mockStore.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { + return &config.CloudAccount{ID: id, Name: "test-aws", Provider: "aws"}, nil + } handler := &Handler{config: mockStore, auth: mockAuth, apiKey: "test-key"} @@ -586,7 +593,10 @@ func TestHandler_HandleRequest_CreatePlan(t *testing.T) { "X-CSRF-Token": "test-csrf", "Content-Type": "application/json", }, - Body: `{"name": "New Plan"}`, + // target_accounts is required (universal-plans fix). Provider must + // also be set so DerivePlanProviders returns non-empty; otherwise + // the validation skip-branch would mask the contract. + Body: `{"name": "New Plan", "provider": "aws", "service": "rds", "target_accounts": ["11111111-1111-1111-1111-111111111111"]}`, RequestContext: events.LambdaFunctionURLRequestContext{ HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{ Method: "POST", diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 01e8fdc33..29f497955 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -1977,7 +1977,7 @@ components: PlanRequest: type: object - required: [name] + required: [name, target_accounts] properties: name: type: string @@ -2006,6 +2006,16 @@ components: type: integer custom_interval_days: type: integer + target_accounts: + type: array + minItems: 1 + description: > + UUIDs of cloud accounts the plan will purchase for. Required on + POST /plans — a plan must be tied to at least one account. Sending + an empty array returns HTTP 400. + items: + type: string + format: uuid PurchasePlan: type: object diff --git a/internal/api/router_handlers_test.go b/internal/api/router_handlers_test.go index 9698c72ca..0383e30ab 100644 --- a/internal/api/router_handlers_test.go +++ b/internal/api/router_handlers_test.go @@ -364,9 +364,12 @@ func TestRouter_setPlanAccountsHandler(t *testing.T) { h := &Handler{auth: mockAuth, config: store} r := newTestRouter(h) + // Universal-plans fix: setPlanAccounts now rejects empty account_ids. + // This router-dispatch test uses a single valid UUID to exercise the + // success path — empty body is covered by handler-level rejection tests. req := &events.LambdaFunctionURLRequest{ Headers: map[string]string{"Authorization": "Bearer admin-token"}, - Body: `{"account_ids": []}`, + Body: `{"account_ids": ["11111111-1111-1111-1111-111111111111"]}`, } _, err := r.setPlanAccountsHandler(ctx, req, map[string]string{"id": "22222222-2222-2222-2222-222222222222"}) require.NoError(t, err) diff --git a/internal/api/types.go b/internal/api/types.go index 6b4ec4f64..067a4e2d2 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -596,6 +596,14 @@ type PlanRequest struct { RampSchedule string `json:"ramp_schedule,omitempty"` CustomStepPercent int `json:"custom_step_percent,omitempty"` CustomIntervalDays int `json:"custom_interval_days,omitempty"` + + // TargetAccounts is the list of cloud_account UUIDs the plan will purchase + // for. Required (non-empty) on POST /plans — a plan with no rows in + // plan_accounts is a "universal plan", which the design no longer allows: + // every plan must be tied to at least one explicit account. The handler + // inserts the plan_accounts rows immediately after CreatePurchasePlan so + // the two writes are observed together by downstream consumers. + TargetAccounts []string `json:"target_accounts,omitempty"` } // toPurchasePlan converts a PlanRequest to a config.PurchasePlan From d8b1318196f9394ae845c0647ad5df135c5cb285 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 27 May 2026 12:25:43 +0200 Subject: [PATCH 2/6] fix(frontend/plans): require Target Account in New Purchase Plan modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the new backend contract (POST /api/plans rejects empty target_accounts). Without this change the user could fill in every other plan field and only see the failure as a toast after the POST round-trip landed. Changes: - Modal help text + section heading mark Target Accounts as required (was "Leave empty to target all enabled accounts" — that semantics no longer exists). - savePlan rejects an empty plan-account-ids field with a clear toast before the createPlan/updatePlan call ever fires. - Selected account IDs are stamped onto the request as target_accounts so the backend can validate + persist atomically on POST. The follow-up PUT /api/plans/:id/accounts call is preserved as defence-in- depth for the update flow (which still uses the 2-step write). - New refreshPlanSaveButtonState() disables the Save Plan submit button whenever the chip list is empty and re-enables it as soon as the user adds one. The button title surfaces the reason to mouseover. - CreatePlanRequest.target_accounts added to the API types so the request shape is checked at compile time. Tests: - "rejects submit and never calls createPlan when Target Accounts is empty (universal-plans fix)": empty hidden field → no createPlan call, no setPlanAccounts call, toast surfaces the requirement. - "forwards selected target_accounts on createPlan (universal-plans fix)": multi-account chip list flows through to the request body. - DOM setup gained #plan-accounts-selected, #plan-account-ids, and a submit button so the new code paths have something to drive. - Existing tests in the savePlan describe block now stamp a single UUID in beforeEach; tests that re-open the modal (form.reset() clears the field) call a stampAccountIds() helper to re-stamp before submit. --- frontend/src/__tests__/api.test.ts | 2 + frontend/src/__tests__/plans.test.ts | 68 ++++++++++++++++++++++++++++ frontend/src/api/types.ts | 5 ++ frontend/src/index.html | 4 +- frontend/src/plans.ts | 42 ++++++++++++++++- frontend/src/types.ts | 5 ++ 6 files changed, 122 insertions(+), 4 deletions(-) diff --git a/frontend/src/__tests__/api.test.ts b/frontend/src/__tests__/api.test.ts index 521c81b01..458435380 100644 --- a/frontend/src/__tests__/api.test.ts +++ b/frontend/src/__tests__/api.test.ts @@ -767,6 +767,7 @@ describe('Plans API', () => { notification_days_before: 3, services: { 'aws:ec2': { provider: 'aws', service: 'ec2', enabled: true, term: 3, payment: 'all-upfront', coverage: 80 } }, ramp_schedule: { type: 'immediate', percent_per_step: 100, step_interval_days: 0, current_step: 0, total_steps: 1 }, + target_accounts: ['11111111-1111-1111-1111-111111111111'], }; await createPlan(plan); @@ -789,6 +790,7 @@ describe('Plans API', () => { notification_days_before: 5, services: { 'aws:rds': { provider: 'aws', service: 'rds', enabled: true, term: 1, payment: 'no-upfront', coverage: 70 } }, ramp_schedule: { type: 'weekly', percent_per_step: 25, step_interval_days: 7, current_step: 0, total_steps: 4 }, + target_accounts: ['22222222-2222-2222-2222-222222222222'], }; await updatePlan('plan-123', plan); diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index 5c728a240..333805fbe 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -148,6 +148,13 @@ describe('Plans Module', () => { + +
+ + @@ -885,6 +892,11 @@ describe('Plans Module', () => { (document.getElementById('plan-auto-purchase') as HTMLInputElement).checked = true; (document.getElementById('plan-notify-days') as HTMLInputElement).value = '3'; (document.getElementById('plan-enabled') as HTMLInputElement).checked = true; + // Universal-plans fix: savePlan rejects an empty Target Accounts list, + // so default the hidden field to a single account UUID for every test + // in this block. Tests that exercise the empty-accounts rejection set + // it back to '' explicitly inside the test. + (document.getElementById('plan-account-ids') as HTMLInputElement).value = '11111111-1111-1111-1111-111111111111'; }); test('prevents default form submission', async () => { @@ -943,6 +955,15 @@ describe('Plans Module', () => { })); }); + // Helper: openCreatePlanModal/openNewPlanModal call form.reset(), which + // clears the hidden plan-account-ids field stamped by the beforeEach. + // Universal-plans fix requires that field to be non-empty at savePlan + // time, so any test that opens the modal must re-stamp it before submit. + const stampAccountIds = () => { + (document.getElementById('plan-account-ids') as HTMLInputElement).value + = '11111111-1111-1111-1111-111111111111'; + }; + test('includes the snapshot stamped by openCreatePlanModal (#273 CR)', async () => { // #273 CR follow-up: savePlan now reads the snapshot stamped at // Plan-button click time via openCreatePlanModal(snapshot), instead @@ -956,6 +977,7 @@ describe('Plans Module', () => { { id: 'rec-2', service: 'rds' }, ] as unknown as readonly api.Recommendation[]; openCreatePlanModal(snapshot); + stampAccountIds(); (api.createPlan as jest.Mock).mockResolvedValue({}); (api.getPlans as jest.Mock).mockResolvedValue({ plans: [] }); @@ -980,6 +1002,7 @@ describe('Plans Module', () => { { id: 'rec-2', service: 'rds' }, ] as unknown as readonly api.Recommendation[]; openCreatePlanModal(snapshotAtClickTime); + stampAccountIds(); // Now simulate post-modal-open state mutations: deselection, // refresh-replaced visible set, etc. None of these should affect @@ -1015,6 +1038,7 @@ describe('Plans Module', () => { // (the New-Plan-from-scratch path explicitly clears the cache so a // subsequent New-Plan submit doesn't inherit a previous flow's recs). openNewPlanModal(); + stampAccountIds(); (api.createPlan as jest.Mock).mockResolvedValue({}); (api.getPlans as jest.Mock).mockResolvedValue({ plans: [] }); (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); @@ -1032,6 +1056,7 @@ describe('Plans Module', () => { // time was empty for some reason), savePlan must still submit without // a recommendations field, not blow up. openCreatePlanModal([] as unknown as readonly api.Recommendation[]); + stampAccountIds(); (api.createPlan as jest.Mock).mockResolvedValue({}); (api.getPlans as jest.Mock).mockResolvedValue({ plans: [] }); (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); @@ -1130,6 +1155,49 @@ describe('Plans Module', () => { expect(api.updatePlan).toHaveBeenCalledWith('plan-123', expect.any(Object)); expect(mockOpenArcheraOfferModal).not.toHaveBeenCalled(); }); + + test('rejects submit and never calls createPlan when Target Accounts is empty (universal-plans fix)', async () => { + // Universal plans (purchase_plans rows with no plan_accounts row) are + // no longer allowed. The Save Plan button is also disabled in this + // state via refreshPlanSaveButtonState; this assertion is the defence- + // in-depth at the savePlan layer for scripted submissions or any + // future regression that bypasses the disabled UI. + (document.getElementById('plan-account-ids') as HTMLInputElement).value = ''; + (api.createPlan as jest.Mock).mockResolvedValue({ id: 'p1' }); + + const event = { preventDefault: jest.fn() } as unknown as Event; + await savePlan(event); + + expect(api.createPlan).not.toHaveBeenCalled(); + expect(api.setPlanAccounts).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ + kind: 'error', + message: expect.stringContaining('Target Accounts'), + })); + }); + + test('forwards selected target_accounts on createPlan (universal-plans fix)', async () => { + // Verifies the new wire contract: savePlan stamps the selected account + // chip IDs onto the request body so the backend can validate and + // persist plan_accounts in the same call. The 2-step PUT remains a + // belt-and-suspenders write for update flows, but the create path + // must include target_accounts inline. + (document.getElementById('plan-account-ids') as HTMLInputElement).value + = '11111111-1111-1111-1111-111111111111,22222222-2222-2222-2222-222222222222'; + (api.createPlan as jest.Mock).mockResolvedValue({ id: 'p1' }); + (api.getPlans as jest.Mock).mockResolvedValue({ plans: [] }); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + + const event = { preventDefault: jest.fn() } as unknown as Event; + await savePlan(event); + + expect(api.createPlan).toHaveBeenCalledWith(expect.objectContaining({ + target_accounts: [ + '11111111-1111-1111-1111-111111111111', + '22222222-2222-2222-2222-222222222222', + ], + })); + }); }); describe('closePlanModal', () => { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f9194ab0d..39a694d17 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -168,6 +168,11 @@ export interface CreatePlanRequest { notification_days_before: number; services: Record; ramp_schedule: PlanRampSchedule; + // Required server-side (universal-plans fix): a plan must be tied to at + // least one cloud account. The frontend bundles the selected account IDs + // here so the backend receives plan creation + account assignment in a + // single request and can validate atomically. + target_accounts: string[]; } // History types diff --git a/frontend/src/index.html b/frontend/src/index.html index 181d5b036..59813a16c 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -869,8 +869,8 @@

Automation Settings

-

Target Accounts

-

Leave empty to target all enabled accounts for the selected provider.

+

Target Accounts

+

Required. Select at least one account this plan will purchase for. The Save button stays disabled until you add one.