carlos / platform Public

schedule: control.go — why-comment for TimerStore, reject trailing PUT JSON, pin tz-offset at

TimerStore's comment said what it was, not why it's a narrow
interface (activator stubs it for wiring tests; *registry.Registry
satisfies it directly). Rewrite it.

The PUT /schedule/{name} body decoder stopped at the first JSON value
and silently ignored anything appended after it. Check dec.More()
after Decode and refuse a body carrying trailing data, with a test
covering a second concatenated object, trailing prose, and a stray
comma.

Add a test pinning that a tz-offset `at` (e.g. +01:00) is accepted
and the stored row's next_at/due_at land on the UTC instant, not the
offset clock-face numbers — already correct, just untested.
Paul Campbell pushed by paul@keymail.dev 3df1e7f5ac9b30c75d90077ee9f51b9612158d06 parent 343e159
2 files changed, +57 −2
  • internal/schedule/control.go +15 −2
  • internal/schedule/control_test.go +42 −0
diff --git a/internal/schedule/control.go b/internal/schedule/control.go
index 2828e5fcf..6a0b7cf0a 100644
--- a/internal/schedule/control.go
+++ b/internal/schedule/control.go
@@ -12,7 +12,11 @@ import (
"github.com/carlosframework/platform/internal/registry"
)
-// TimerStore is the registry subset the control channel needs.
+// TimerStore is narrowed to the five methods Mount actually calls, not the
+// whole *registry.Registry: *registry.Registry satisfies it directly
+// (internal/host/agent.go), and internal/activator — the other side of the
+// package boundary this crosses (Backend.Timers) — stubs it with an
+// in-memory fake so its wiring tests never touch a real database.
type TimerStore interface {
UpsertTimer(registry.Timer) error
DeleteTimer(host, name string) error
@@ -62,10 +66,19 @@ func Mount(mux *http.ServeMux, host string, store TimerStore, token func() (stri
At string `json:"at"`
Path string `json:"path"`
}
- if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
+ dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
+ if err := dec.Decode(&body); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
+ // Decode stops at the first JSON value and silently ignores
+ // whatever follows it; a client that appended a second object (or
+ // stray bytes) meant something by that, so refuse rather than act
+ // on the first value alone.
+ if dec.More() {
+ http.Error(w, "bad request: trailing data after the JSON body", http.StatusBadRequest)
+ return
+ }
atT, err := time.Parse(time.RFC3339, body.At)
if err != nil {
http.Error(w, "at: want RFC3339, e.g. 2026-09-01T08:00:00Z", http.StatusBadRequest)
diff --git a/internal/schedule/control_test.go b/internal/schedule/control_test.go
index 13bd412b5..1c97afd3b 100644
--- a/internal/schedule/control_test.go
+++ b/internal/schedule/control_test.go
@@ -110,6 +110,48 @@ func TestControlRefusals(t *testing.T) {
}
}
+// json.Decoder.Decode stops at the first value and silently drops whatever
+// follows it, so a naive Decode(&body) would accept a body like
+// `{"at":...}{"at":...}` or `{"at":...}garbage` and act on the first
+// object alone. The PUT handler must refuse it instead.
+func TestControlPutRejectsTrailingJSON(t *testing.T) {
+ _, mux := controlFixture(t)
+ for _, body := range []string{
+ `{"at":"2026-09-01T08:00:00Z","path":"/p"}{"at":"2026-09-02T08:00:00Z","path":"/p"}`,
+ `{"at":"2026-09-01T08:00:00Z","path":"/p"} garbage`,
+ `{"at":"2026-09-01T08:00:00Z","path":"/p"},`,
+ } {
+ if rec := do(mux, "PUT", "/schedule/trailing", "Bearer tok", body); rec.Code != http.StatusBadRequest {
+ t.Errorf("PUT with trailing data %q → %d, want 400 (%s)", body, rec.Code, rec.Body)
+ }
+ }
+ // A clean single object still works.
+ if rec := do(mux, "PUT", "/schedule/clean", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/p"}`); rec.Code != http.StatusOK {
+ t.Fatalf("clean body → %d (%s)", rec.Code, rec.Body)
+ }
+}
+
+// A member's clock is not UTC; `at` must accept an offset and the row must
+// store the UTC instant it names, not the offset's clock-face numbers.
+func TestControlPutNormalisesTZOffsetToUTC(t *testing.T) {
+ reg, mux := controlFixture(t)
+ // 09:00 +01:00 is 08:00Z.
+ rec := do(mux, "PUT", "/schedule/tz", "Bearer tok", `{"at":"2026-09-01T09:00:00+01:00","path":"/p"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("put = %d %s", rec.Code, rec.Body)
+ }
+ want := at("2026-09-01T08:00:00Z").Unix()
+ var ts TimerStatus
+ json.Unmarshal(rec.Body.Bytes(), &ts)
+ if ts.NextAt != want || ts.Spec != "at "+itoa(want) {
+ t.Fatalf("response = %+v, want next_at=%d", ts, want)
+ }
+ tm, err := reg.GetTimer("a.bab.oncarlos.com", "tz")
+ if err != nil || tm.NextAt != want || tm.DueAt != want {
+ t.Fatalf("row = %+v, err=%v, want next_at/due_at=%d", tm, err, want)
+ }
+}
+
func TestControlDeleteRefusesDeclaredSchedule(t *testing.T) {
reg, mux := controlFixture(t)
reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "sync", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/s", NextAt: 1})