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
6 changes: 6 additions & 0 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 15 additions & 1 deletion internal/api/handler_plans.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
124 changes: 124 additions & 0 deletions internal/api/handler_plans_start_date_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
15 changes: 14 additions & 1 deletion internal/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -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
Expand Down
Loading