diff --git a/internal/restapi/schedule_for_stop_handler.go b/internal/restapi/schedule_for_stop_handler.go index 8b33e2d74..adbabb122 100644 --- a/internal/restapi/schedule_for_stop_handler.go +++ b/internal/restapi/schedule_for_stop_handler.go @@ -29,6 +29,16 @@ func (api *RestAPI) scheduleForStopHandler(w http.ResponseWriter, r *http.Reques // Get the date parameter or use current date dateParam := r.URL.Query().Get("date") + // An unparseable date is a field error even when the ID resolves to nothing, so it has + // to be caught before the agency lookup below. Resolving the date to a service date + // needs that agency's timezone, so the parse itself stays where it is. + if dateParam != "" { + if err := utils.ValidateServiceDate(dateParam); err != nil { + api.validationErrorResponse(w, r, map[string][]string{"date": {err.Error()}}) + return + } + } + agency, err := api.GtfsManager.GtfsDB.Queries.GetAgency(ctx, agencyID) if err != nil { api.sendNotFound(w, r) @@ -46,12 +56,10 @@ func (api *RestAPI) scheduleForStopHandler(w http.ResponseWriter, r *http.Reques if dateParam != "" { var err error + // The format was validated above, so this only fails on an unusable agency timezone. startOfDay, err = utils.ParseDate(dateParam, loc) if err != nil { - fieldErrors := map[string][]string{ - "date": {err.Error()}, - } - api.validationErrorResponse(w, r, fieldErrors) + api.serverErrorResponse(w, r, err) return } diff --git a/internal/restapi/schedule_for_stop_handler_test.go b/internal/restapi/schedule_for_stop_handler_test.go index fa466e07e..382365fb4 100644 --- a/internal/restapi/schedule_for_stop_handler_test.go +++ b/internal/restapi/schedule_for_stop_handler_test.go @@ -347,6 +347,68 @@ func TestScheduleForStopHandlerInvalidDateFormat(t *testing.T) { } } +func TestScheduleForStopHandlerDateValidationPrecedesLookup(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + knownStopID := utils.FormCombinedID(mustGetAgencies(t, api)[0].ID, mustGetStop(t, api).ID) + + tests := []struct { + name string + stopID string + date string + expectedStatus int + expectFieldError bool + }{ + { + name: "unknown agency with an invalid date", + stopID: "99_1001", + date: "garbage", + expectedStatus: http.StatusBadRequest, + expectFieldError: true, + }, + { + name: "known agency with an invalid date", + stopID: knownStopID, + date: "garbage", + expectedStatus: http.StatusBadRequest, + expectFieldError: true, + }, + { + name: "unknown agency with a valid date", + stopID: "99_1001", + date: "2025-06-12", + expectedStatus: http.StatusNotFound, + }, + { + name: "unknown stop with a valid date", + stopID: "25_9999999", + date: "2025-06-12", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + endpoint := "/api/where/schedule-for-stop/" + tt.stopID + ".json?key=org.onebusaway.iphone&date=" + tt.date + resp, model := serveApiAndRetrieveEndpoint(t, api, endpoint) + + assert.Equal(t, tt.expectedStatus, resp.StatusCode) + assert.Equal(t, tt.expectedStatus, model.Code) + + if !tt.expectFieldError { + return + } + + data, ok := model.Data.(map[string]any) + require.True(t, ok) + fieldErrors, ok := data["fieldErrors"].(map[string]any) + require.True(t, ok) + assert.NotEmpty(t, fieldErrors["date"]) + }) + } +} + func TestScheduleForStopHandlerScheduleContent(t *testing.T) { api := createTestApi(t) defer api.Shutdown() diff --git a/internal/utils/validation.go b/internal/utils/validation.go index 34c9142c0..34bfdbe86 100644 --- a/internal/utils/validation.go +++ b/internal/utils/validation.go @@ -107,6 +107,14 @@ func ValidateDate(date string) error { return nil } +// ValidateServiceDate reports whether a service date parameter is parseable, in either +// of the forms ParseDate accepts. Handlers use it to reject a malformed date before +// looking up the agency whose timezone ParseDate then resolves the date against. +func ValidateServiceDate(date string) error { + _, err := ParseDate(date, time.UTC) + return err +} + // SanitizeInput removes HTML tags and other potentially dangerous content func SanitizeInput(input string) string { // Remove HTML tags