diff --git a/internal/api/handler.go b/internal/api/handler.go index c4086844e..485b51997 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -117,6 +117,12 @@ type Handler struct { // via the credentials resolved for the org-root account. discoverOrgFn func(context.Context, aws.Config) (*accounts.OrgDiscoveryResult, error) + // now is the wall clock for the start_date guard in + // validatePurchaseScheduling. Nil (the production default) means + // time.Now; tests inject a fixed instant so date-boundary cases are + // deterministic. Read it through h.clock(). + now func() time.Time + // lambdaInvoker is the async-invoke client used by postRefreshRecommendations // and triggerColdStartCollect. In production it is constructed lazily from the // cached awsCfg. Tests inject a stub to avoid live Lambda calls. diff --git a/internal/api/handler_plans.go b/internal/api/handler_plans.go index 46784cd78..9115c3701 100644 --- a/internal/api/handler_plans.go +++ b/internal/api/handler_plans.go @@ -551,12 +551,26 @@ func (h *Handler) validatePurchaseScheduling(ctx context.Context, plan *config.P return err } } - if !startDate.After(time.Now()) { + // start_date is a date-only value parsed as midnight UTC, so today's + // date is already in the past and is refused along with any earlier + // day: tomorrow (UTC) is the first accepted date. The check runs here, + // under the plan lock, because a request can wait on that lock past + // UTC midnight (#873, #50). + if !startDate.After(h.clock()) { return NewClientError(400, "start_date must be in the future") } return nil } +// clock returns the handler's current time: the injected now func when a +// test set one, time.Now otherwise. +func (h *Handler) clock() time.Time { + if h.now != nil { + return h.now() + } + return time.Now() +} + func validateScheduledRamp(plan *config.PurchasePlan, count int) error { if err := plan.Validate(); err != nil { return NewClientError(409, fmt.Sprintf("plan cannot be scheduled: %v", err)) diff --git a/internal/api/handler_plans_start_date_test.go b/internal/api/handler_plans_start_date_test.go new file mode 100644 index 000000000..c865ef639 --- /dev/null +++ b/internal/api/handler_plans_start_date_test.go @@ -0,0 +1,124 @@ +package api + +import ( + "context" + "testing" + "time" + + "github.com/aws/aws-lambda-go/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" +) + +// startDateProbePlanID is the plan the start_date regression tests post to. +const startDateProbePlanID = "11111111-1111-1111-1111-111111111111" + +// postPlannedPurchaseAt sends POST /api/plans/{id}/purchases through the real +// router with the handler clock pinned to now, and returns the response plus +// the store mock so callers can check whether any execution row was written. +func postPlannedPurchaseAt(t *testing.T, now time.Time, startDate string) (*events.LambdaFunctionURLResponse, *MockConfigStore) { + t.Helper() + ctx := context.Background() + mockStore := new(MockConfigStore) + mockAuth := new(MockAuthService) + + mockAuth.On("ValidateSession", ctx, "test-token").Return(&Session{UserID: "admin-id", Email: "admin@example.com"}, nil) + mockAuth.grantAdmin() + mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) + + plan := bareStepAPIPlan() + plan.ID = startDateProbePlanID + plan.AutoPurchase = false + mockStore.On("GetPurchasePlan", mock.Anything, startDateProbePlanID).Return(plan, nil) + mockStore.On("GetPlanAccounts", mock.Anything, startDateProbePlanID).Return([]config.CloudAccount{{Provider: "azure"}}, nil) + mockStore.On("SavePurchaseExecution", mock.Anything, mock.Anything).Return(nil) + mockStore.On("UpdatePurchasePlan", mock.Anything, mock.Anything).Return(nil) + + handler := &Handler{ + config: mockStore, + auth: mockAuth, + corsAllowedOrigin: "*", + apiKey: "test-key", + now: func() time.Time { return now }, + } + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{ + "X-API-Key": "test-key", + "Content-Type": "application/json", + "Authorization": "Bearer test-token", + "X-CSRF-Token": "test-csrf", + }, + Body: `{"count": 1, "start_date": "` + startDate + `"}`, + RequestContext: events.LambdaFunctionURLRequestContext{ + HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{ + Method: "POST", + Path: "/api/plans/" + startDateProbePlanID + "/purchases", + }, + }, + } + + resp, err := handler.HandleRequest(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + return resp, mockStore +} + +// TestCreatePlannedPurchases_StartDateMustBeInTheFuture pins the server-side +// start_date guard added in #873 (issue #50). start_date is a date-only value +// parsed as midnight UTC and must be after the current instant, so today's +// date (UTC) and anything earlier are refused with a 400; tomorrow (UTC) is +// the first accepted date. The clock is injected so the UTC-midnight boundary +// is exercised deterministically. +func TestCreatePlannedPurchases_StartDateMustBeInTheFuture(t *testing.T) { + midday := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) + losAngeles, err := time.LoadLocation("America/Los_Angeles") + require.NoError(t, err) + + cases := []struct { + name string + now time.Time + startDate string + status int + message string + }{ + {"yesterday is refused", midday, "2026-03-09", 400, "start_date must be in the future"}, + {"long past date is refused", midday, "2020-01-01", 400, "start_date must be in the future"}, + {"today is refused", midday, "2026-03-10", 400, "start_date must be in the future"}, + {"tomorrow is accepted", midday, "2026-03-11", 200, ""}, + {"malformed date keeps the format error", midday, "03/11/2026", 400, "invalid start_date format, expected YYYY-MM-DD"}, + {"today is refused one second after UTC midnight", time.Date(2026, 3, 10, 0, 0, 1, 0, time.UTC), "2026-03-10", 400, "start_date must be in the future"}, + {"tomorrow is accepted one second before UTC midnight", time.Date(2026, 3, 10, 23, 59, 59, 0, time.UTC), "2026-03-11", 200, ""}, + {"the date is refused exactly at its own UTC midnight", time.Date(2026, 3, 11, 0, 0, 0, 0, time.UTC), "2026-03-11", 400, "start_date must be in the future"}, + // 20:00 on 10 March in Los Angeles is 03:00 UTC on 11 March. The + // caller's local "tomorrow" is already today in UTC, so it is refused: + // the comparison is in UTC, not in the clock's or the server's zone. + {"UTC decides, not the local zone", time.Date(2026, 3, 10, 20, 0, 0, 0, losAngeles), "2026-03-11", 400, "start_date must be in the future"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp, store := postPlannedPurchaseAt(t, tc.now, tc.startDate) + assert.Equal(t, tc.status, resp.StatusCode, "body: %s", resp.Body) + if tc.status == 200 { + assert.JSONEq(t, `{"created":1}`, resp.Body) + store.AssertNumberOfCalls(t, "SavePurchaseExecution", 1) + return + } + assert.JSONEq(t, `{"error":"`+tc.message+`"}`, resp.Body) + store.AssertNotCalled(t, "SavePurchaseExecution", mock.Anything, mock.Anything) + store.AssertNotCalled(t, "UpdatePurchasePlan", mock.Anything, mock.Anything) + }) + } +} + +// TestHandlerClock_DefaultsToWallClock checks that a Handler built without an +// injected clock (every production constructor) still uses time.Now. +func TestHandlerClock_DefaultsToWallClock(t *testing.T) { + before := time.Now() + got := (&Handler{}).clock() + assert.False(t, got.Before(before), "clock() must not be earlier than time.Now() taken before it") + assert.WithinDuration(t, time.Now(), got, time.Minute) +} diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 517b21e47..7e272a593 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -437,7 +437,16 @@ paths: schema: $ref: '#/components/schemas/CreatePlannedPurchasesResponse' '400': - $ref: '#/components/responses/BadRequest' + description: > + Invalid request body, count outside 1-52, start_date not in + YYYY-MM-DD form, or start_date not in the future + ("start_date must be in the future"). start_date is read as + midnight UTC, so today's date and earlier are refused; the + earliest accepted date is tomorrow in UTC. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -3341,6 +3350,10 @@ components: start_date: type: string format: date + description: > + First purchase date (YYYY-MM-DD), interpreted as midnight UTC. + Must be after the current time, so the earliest accepted value + is tomorrow's date in UTC. CreatePlannedPurchasesResponse: type: object