diff --git a/.changeset/ocpp-dispatch-allowlist-http-timeouts.md b/.changeset/ocpp-dispatch-allowlist-http-timeouts.md new file mode 100644 index 00000000..4aa8ff3f --- /dev/null +++ b/.changeset/ocpp-dispatch-allowlist-http-timeouts.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Route periodic EV dispatch through OCPP, keep Lua loadpoint names off the OCPP allowlist, and bound HTTP read/write/idle time. diff --git a/go/cmd/ftw/bootstrap.go b/go/cmd/ftw/bootstrap.go index 3769d5d9..c223b5e5 100644 --- a/go/cmd/ftw/bootstrap.go +++ b/go/cmd/ftw/bootstrap.go @@ -10,7 +10,6 @@ import ( "path/filepath" "strings" "syscall" - "time" "github.com/srcfl/ftw/go/internal/api" "github.com/srcfl/ftw/go/internal/config" @@ -172,11 +171,7 @@ func runBootstrap(configPath, webDir, driverDir string) { }() }) - srv := &http.Server{ - Addr: ":8080", - Handler: secureBootstrapMutations(mux), - ReadHeaderTimeout: 10 * time.Second, - } + srv := newHTTPServer(":8080", secureBootstrapMutations(mux)) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("bootstrap server", "err", err) os.Exit(1) diff --git a/go/cmd/ftw/ev_send.go b/go/cmd/ftw/ev_send.go new file mode 100644 index 00000000..959a46f8 --- /dev/null +++ b/go/cmd/ftw/ev_send.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/ocpp" +) + +// ocppApprovedIDs is the OCPP allowlist. Lua loadpoint names are not charge-point +// identities: anyone holding the shared basic-auth secret could otherwise connect +// as "easee" and steal DerEV plus commands. Adopted OCPP loadpoints (driver names +// that are not Lua drivers) and ids listed under ocpp.chargers stay approved. +func ocppApprovedIDs(cfg *config.Config) []string { + if cfg == nil { + return nil + } + lua := make(map[string]struct{}, len(cfg.Drivers)) + for _, d := range cfg.Drivers { + if d.Name != "" { + lua[d.Name] = struct{}{} + } + } + seen := make(map[string]struct{}) + var out []string + add := func(id string) { + if id == "" { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + out = append(out, id) + } + if cfg.OCPP != nil { + for _, c := range cfg.OCPP.Chargers { + add(c.ID) + } + } + for _, lp := range cfg.Loadpoints { + if _, isLua := lua[lp.DriverName]; isLua { + continue + } + add(lp.DriverName) + } + return out +} + +// evCommandRouter sends EV commands to an online, adopted OCPP charger, or falls +// through to the Lua registry. Periodic dispatch uses SendWithOutcome / SendCycle; +// those must take the same route as the API's SenderFunc or planner ticks never +// reach a charger that has no Lua driver. +type evCommandRouter struct { + ocpp *ocpp.Server + send loadpoint.SenderFunc + sendOutcome loadpoint.OutcomeSenderFunc + sendCycle loadpoint.CycleSenderFunc +} + +func newEVCommandRouter( + srv *ocpp.Server, + send loadpoint.SenderFunc, + sendOutcome loadpoint.OutcomeSenderFunc, + sendCycle loadpoint.CycleSenderFunc, +) evCommandRouter { + return evCommandRouter{ocpp: srv, send: send, sendOutcome: sendOutcome, sendCycle: sendCycle} +} + +func (r evCommandRouter) ocppRoute(name string) bool { + return r.ocpp != nil && r.ocpp.Handler().IsOnline(name) && r.ocpp.Handler().IsApproved(name) +} + +func (r evCommandRouter) Send(ctx context.Context, name string, payload []byte) error { + if r.ocppRoute(name) { + return r.ocpp.Command(ctx, name, payload) + } + if r.send == nil { + return nil + } + return r.send(ctx, name, payload) +} + +func (r evCommandRouter) SendWithOutcome(ctx context.Context, name string, payload []byte, outcome func(error)) error { + if r.ocppRoute(name) { + err := r.ocpp.Command(ctx, name, payload) + if outcome != nil { + outcome(err) + } + return err + } + if r.sendOutcome != nil { + return r.sendOutcome(ctx, name, payload, outcome) + } + err := r.Send(ctx, name, payload) + if outcome != nil { + outcome(err) + } + return err +} + +func (r evCommandRouter) SendCycle(ctx context.Context, name string, payload []byte, cycleID uint64) error { + if r.ocppRoute(name) { + return r.ocpp.Command(ctx, name, payload) + } + if r.sendCycle != nil { + return r.sendCycle(ctx, name, payload, cycleID) + } + return r.Send(ctx, name, payload) +} diff --git a/go/cmd/ftw/ev_send_test.go b/go/cmd/ftw/ev_send_test.go new file mode 100644 index 00000000..bfff5b49 --- /dev/null +++ b/go/cmd/ftw/ev_send_test.go @@ -0,0 +1,281 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "sync" + "testing" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging" + "github.com/lorenzodonini/ocpp-go/ws" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/ocpp" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestOCPPApprovedIDsSkipLuaLoadpointNames(t *testing.T) { + cfg := &config.Config{ + Drivers: []config.Driver{{Name: "easee", Lua: "drivers/easee.lua"}}, + Loadpoints: []config.Loadpoint{ + {ID: "cloud", DriverName: "easee"}, + {ID: "garage", DriverName: "garage"}, + }, + OCPP: &config.OCPP{ + Enabled: true, + Username: "ftw", + Password: "shared-secret", + Chargers: []config.OCPPCharger{{ID: "wallbox", Password: "own"}}, + }, + } + got := ocppApprovedIDs(cfg) + want := map[string]bool{"garage": true, "wallbox": true} + if len(got) != len(want) { + t.Fatalf("approved %v, want garage and wallbox only", got) + } + for _, id := range got { + if !want[id] { + t.Errorf("approved unexpected id %q", id) + } + } +} + +func containsID(ids []string, want string) bool { + for _, id := range ids { + if id == want { + return true + } + } + return false +} + +func TestEaseeNamedLoadpointIsNotAnApprovedOCPPID(t *testing.T) { + cfg := &config.Config{ + Drivers: []config.Driver{{Name: "easee", Lua: "drivers/easee.lua"}}, + Loadpoints: []config.Loadpoint{{ID: "garage", DriverName: "easee"}}, + OCPP: &config.OCPP{Enabled: true, Username: "ftw", Password: "shared-secret"}, + } + if containsID(ocppApprovedIDs(cfg), "easee") { + t.Fatal("Lua loadpoint name easee must not be an approved OCPP identity") + } +} + +type recordingLuaSend struct { + mu sync.Mutex + calls int +} + +func (r *recordingLuaSend) Send(context.Context, string, []byte) error { + r.mu.Lock() + r.calls++ + r.mu.Unlock() + return errors.New(`driver "easee" not found`) +} + +func (r *recordingLuaSend) SendWithOutcome(_ context.Context, _ string, _ []byte, outcome func(error)) error { + r.mu.Lock() + r.calls++ + r.mu.Unlock() + err := errors.New(`driver "easee" not found`) + if outcome != nil { + outcome(err) + } + return err +} + +func (r *recordingLuaSend) n() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.calls +} + +type profileRecorder struct { + mu sync.Mutex + profiles int +} + +func (f *profileRecorder) OnSetChargingProfile(*smartcharging.SetChargingProfileRequest) (*smartcharging.SetChargingProfileConfirmation, error) { + f.mu.Lock() + f.profiles++ + f.mu.Unlock() + return smartcharging.NewSetChargingProfileConfirmation(smartcharging.ChargingProfileStatusAccepted), nil +} + +func (f *profileRecorder) OnClearChargingProfile(*smartcharging.ClearChargingProfileRequest) (*smartcharging.ClearChargingProfileConfirmation, error) { + return nil, errors.New("not used") +} + +func (f *profileRecorder) OnGetCompositeSchedule(*smartcharging.GetCompositeScheduleRequest) (*smartcharging.GetCompositeScheduleConfirmation, error) { + return nil, errors.New("not used") +} + +func (f *profileRecorder) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.profiles +} + +func startTestOCPP(t *testing.T, cfg *ocpp.Config) (int, *ocpp.Server) { + t.Helper() + if cfg.Bind == "" { + cfg.Bind = "127.0.0.1" + } + if cfg.Port == 0 { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + cfg.Port = l.Addr().(*net.TCPAddr).Port + l.Close() + } + srv, err := ocpp.Start(context.Background(), cfg, telemetry.NewStore()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(srv.Stop) + deadline := time.Now().Add(2 * time.Second) + addr := fmt.Sprintf("127.0.0.1:%d", cfg.Port) + for time.Now().Before(deadline) { + c, err := net.DialTimeout("tcp", addr, 50*time.Millisecond) + if err == nil { + c.Close() + return cfg.Port, srv + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("ocpp listener did not bind on %s", addr) + return 0, nil +} + +func connectTestCharger(t *testing.T, port int, id, user, pass string) (*profileRecorder, func()) { + t.Helper() + fake := &profileRecorder{} + client := ws.NewClient() + if user != "" || pass != "" { + client.SetBasicAuth(user, pass) + } + cp := ocpp16.NewChargePoint(id, nil, client) + cp.SetSmartChargingHandler(fake) + if err := cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("connect %s: %v", id, err) + } + var once sync.Once + stop := func() { once.Do(cp.Stop) } + t.Cleanup(stop) + if _, err := cp.BootNotification("Home", "Easee"); err != nil { + t.Fatalf("boot %s: %v", id, err) + } + return fake, stop +} + +func awaitOnline(t *testing.T, srv *ocpp.Server, id string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if srv.Handler().IsOnline(id) { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("%s never came online", id) +} + +func TestLuaEaseeWebsocketStaysPendingAndDoesNotReceiveCurrent(t *testing.T) { + site := &config.Config{ + Drivers: []config.Driver{{Name: "easee", Lua: "drivers/easee.lua"}}, + Loadpoints: []config.Loadpoint{{ID: "garage", DriverName: "easee"}}, + OCPP: &config.OCPP{Enabled: true, Username: "ftw", Password: "shared-secret"}, + } + approved := ocppApprovedIDs(site) + if containsID(approved, "easee") { + t.Fatal("easee must not be approved") + } + port, srv := startTestOCPP(t, &ocpp.Config{ + Enabled: true, + Username: "ftw", + Password: "shared-secret", + ApprovedIDs: approved, + }) + fake, _ := connectTestCharger(t, port, "easee", "ftw", "shared-secret") + awaitOnline(t, srv, "easee") + if !srv.Handler().Snapshot()["easee"].Pending { + t.Fatal("websocket to /easee must stay pending") + } + + lua := &recordingLuaSend{} + router := newEVCommandRouter(srv, lua.Send, lua.SendWithOutcome, nil) + payload, _ := json.Marshal(map[string]any{"action": "ev_set_current", "power_w": 4140, "voltage": 230.0, "site_phases": 3}) + if err := router.SendWithOutcome(context.Background(), "easee", payload, func(error) {}); err == nil { + t.Fatal("impostor path must not succeed as OCPP") + } + if fake.count() != 0 { + t.Fatal("pending /easee received ev_set_current") + } + if lua.n() == 0 { + t.Fatal("command did not fall through to the Lua registry") + } +} + +func TestPeriodicDispatchWithOutcomeSenderCallsOCPPCommand(t *testing.T) { + port, srv := startTestOCPP(t, &ocpp.Config{Enabled: true, ApprovedIDs: []string{"garage"}}) + fake, _ := connectTestCharger(t, port, "garage", "", "") + awaitOnline(t, srv, "garage") + + lua := &recordingLuaSend{} + router := newEVCommandRouter(srv, lua.Send, lua.SendWithOutcome, nil) + + now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) + cfg := loadpoint.Config{ + ID: "garage", + DriverName: "garage", + MinChargeW: 1400, + MaxChargeW: 11000, + AllowedStepsW: []float64{0, 1400, 4140, 11000}, + } + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{cfg}) + plan := loadpoint.PlanFunc(func(time.Time) (loadpoint.Directive, bool) { + return loadpoint.Directive{ + SlotStart: now.Add(-time.Second), + SlotEnd: now.Add(15 * time.Minute), + LoadpointEnergyWh: map[string]float64{cfg.ID: 2750}, + }, true + }) + tel := loadpoint.TelemetryFunc(func(string) (loadpoint.EVSample, bool) { + return loadpoint.EVSample{Connected: true, RequestActive: true}, true + }) + c := loadpoint.NewController(mgr, plan, tel, router.Send) + c.SetOutcomeSender(router.SendWithOutcome) + c.SetDriverOnline(func(name string) bool { + return srv.Handler().IsOnline(name) && srv.Handler().IsApproved(name) + }) + c.Tick(context.Background(), now) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && fake.count() == 0 { + time.Sleep(20 * time.Millisecond) + } + if lua.n() != 0 { + t.Fatal("periodic dispatch used Registry.Send instead of ocpp.Command") + } + if fake.count() == 0 { + t.Fatal("controller with SetOutcomeSender did not call ocpp.Command") + } + + profiles := fake.count() + if err := router.SendCycle(context.Background(), "garage", []byte(`{"action":"ev_pause"}`), 1); err != nil { + t.Fatal(err) + } + if lua.n() != 0 { + t.Fatal("cycle sender used Registry.Send") + } + if fake.count() == profiles { + t.Fatal("cycle sender did not call ocpp.Command") + } +} diff --git a/go/cmd/ftw/http_server.go b/go/cmd/ftw/http_server.go new file mode 100644 index 00000000..43789bcb --- /dev/null +++ b/go/cmd/ftw/http_server.go @@ -0,0 +1,26 @@ +package main + +import ( + "net/http" + "time" +) + +const ( + httpReadHeaderTimeout = 10 * time.Second + httpReadTimeout = 15 * time.Second + // WriteTimeout must outlast assistant.Timeout (90s): Ask why streams SSE + // on this listener, and a shorter write ceiling would cut the reply off. + httpWriteTimeout = 2 * time.Minute + httpIdleTimeout = 60 * time.Second +) + +func newHTTPServer(addr string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: httpReadHeaderTimeout, + ReadTimeout: httpReadTimeout, + WriteTimeout: httpWriteTimeout, + IdleTimeout: httpIdleTimeout, + } +} diff --git a/go/cmd/ftw/http_server_test.go b/go/cmd/ftw/http_server_test.go new file mode 100644 index 00000000..24a01a0d --- /dev/null +++ b/go/cmd/ftw/http_server_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "net/http" + "testing" + "time" +) + +func TestAPIHTTPServerSetsRequestTimeouts(t *testing.T) { + srv := newHTTPServer(":0", http.NotFoundHandler()) + if srv.ReadHeaderTimeout != 10*time.Second { + t.Errorf("ReadHeaderTimeout = %v, want 10s", srv.ReadHeaderTimeout) + } + if srv.ReadTimeout != 15*time.Second { + t.Errorf("ReadTimeout = %v, want 15s", srv.ReadTimeout) + } + if srv.WriteTimeout != 2*time.Minute { + t.Errorf("WriteTimeout = %v, want 2m", srv.WriteTimeout) + } + if srv.IdleTimeout != 60*time.Second { + t.Errorf("IdleTimeout = %v, want 60s", srv.IdleTimeout) + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index fbb819fb..85caa3fe 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -446,11 +446,10 @@ func main() { bootPolicy.VerifyLANSecret = lanAuth.Verify boot := newBootPhaseHandler(*webDir) apiHandler := newSwappableHandler(boot) - httpSrv := &http.Server{ - Addr: fmt.Sprintf(":%d", cfg.API.Port), - Handler: api.WithSecurityHeaders(api.Authenticate(apiHandler, bootPolicy)), - ReadHeaderTimeout: 10 * time.Second, - } + httpSrv := newHTTPServer( + fmt.Sprintf(":%d", cfg.API.Port), + api.WithSecurityHeaders(api.Authenticate(apiHandler, bootPolicy)), + ) go func() { slog.Info("HTTP API listening (boot phase)", "addr", httpSrv.Addr) if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { @@ -1031,13 +1030,7 @@ func main() { // a pending charger a new entry names is adopted on this save, // and one whose entry was removed goes back to pending. if ocppSrv != nil { - approved := make([]string, 0, len(newCfg.Loadpoints)) - for _, lp := range newCfg.Loadpoints { - if lp.DriverName != "" { - approved = append(approved, lp.DriverName) - } - } - ocppSrv.Handler().SetApprovedIDs(approved) + ocppSrv.Handler().SetApprovedIDs(ocppApprovedIDs(newCfg)) // A charger adopted by this save booted long ago and will not // boot again just because we changed our mind, so its device // row has to be written here rather than waiting for one. @@ -1245,12 +1238,7 @@ func main() { // from telemetry — so a device that merely knows the shared password // cannot inject EV load into dispatch. if cfg.OCPP != nil && cfg.OCPP.Enabled { - approved := make([]string, 0, len(cfg.Loadpoints)) - for _, lp := range cfg.Loadpoints { - if lp.DriverName != "" { - approved = append(approved, lp.DriverName) - } - } + approved := ocppApprovedIDs(cfg) ocppCfg := &ocpp.Config{ Enabled: cfg.OCPP.Enabled, Bind: cfg.OCPP.Bind, @@ -1800,23 +1788,13 @@ func main() { actuation := newDriverActuationTracker(tel) // An OCPP charge point is not in the driver registry — it connected to us - // rather than being dialled — so route by name: if an online charger - // answers to it, command it over OCPP, otherwise fall through to the Lua - // driver registry. Everything above stays unaware of the difference. - // - // Hoisted out of the loadpoint controller below because the API needs the - // same routing: the dashboard's Pause / Resume / Force start post to - // /api/ev/command, and sending those straight to the registry finds no - // driver for a charger that has none. - evSend := reg.Send - if ocppSrv != nil { - evSend = func(ctx context.Context, name string, payload []byte) error { - if ocppSrv.Handler().IsOnline(name) { - return ocppSrv.Command(ctx, name, payload) - } - return reg.Send(ctx, name, payload) - } - } + // rather than being dialled — so route by name: if an online, adopted + // charger answers to it, command it over OCPP, otherwise fall through to + // the Lua driver registry. Periodic dispatch uses SendWithOutcome / + // SendCycle and must take this same path; wiring those straight to the + // registry is how planner ticks never reached an OCPP wallbox. + evRouter := newEVCommandRouter(ocppSrv, reg.Send, reg.SendWithOutcome, reg.SendEVContinuation) + evSend := evRouter.Send // ---- EV loadpoint controller ---- // loadpoint.Controller owns per-tick EV dispatch, including the @@ -1846,7 +1824,7 @@ func main() { watchdog = health.WatchdogTimeoutOverride } deviceID, _ := runningDeviceID(reg, driver) - ocppOnline := ocppSrv != nil && ocppSrv.Handler().IsOnline(driver) + ocppOnline := ocppSrv != nil && ocppSrv.Handler().IsOnline(driver) && ocppSrv.Handler().IsApproved(driver) if ocppOnline { deviceID = currentOCPPDeviceID(ocppSrv.Handler(), driver) } @@ -1860,11 +1838,11 @@ func main() { // current and the plan keeps counting the load. Only the periodic // ev_set_current is reported — see loadpoint.DispatchOutcomeFunc // for the sends that are deliberately not. - lpController.SetOutcomeSender(reg.SendWithOutcome) - lpController.SetCycleSender(reg.SendEVContinuation) + lpController.SetOutcomeSender(evRouter.SendWithOutcome) + lpController.SetCycleSender(evRouter.SendCycle) lpController.SetDispatchOutcome(actuation.recordCommandOutcome) lpController.SetDriverOnline(func(name string) bool { - if ocppSrv != nil && ocppSrv.Handler().IsOnline(name) { + if ocppSrv != nil && ocppSrv.Handler().IsOnline(name) && ocppSrv.Handler().IsApproved(name) { return true } health := tel.DriverHealth(name) diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 23c29177..24a7cd54 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -649,9 +649,11 @@ func writeJSON(w http.ResponseWriter, status int, v any) { _ = json.NewEncoder(w).Encode(v) } +const maxJSONBody = 1 << 20 + func readJSON(r *http.Request, v any) error { defer r.Body.Close() - body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB cap + body, err := io.ReadAll(http.MaxBytesReader(nil, r.Body, maxJSONBody)) if err != nil { return err } diff --git a/go/internal/api/api_readjson_test.go b/go/internal/api/api_readjson_test.go new file mode 100644 index 00000000..efc9c77e --- /dev/null +++ b/go/internal/api/api_readjson_test.go @@ -0,0 +1,77 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" +) + +func jsonBodyRequest(t *testing.T, raw []byte) *http.Request { + t.Helper() + req, err := http.NewRequest(http.MethodPost, "/api/test", bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + return req +} + +func TestReadJSONAcceptsBodyJustUnderCap(t *testing.T) { + pad := strings.Repeat("a", maxJSONBody) + var raw []byte + for { + raw, _ = json.Marshal(map[string]string{"x": pad}) + if len(raw) < maxJSONBody { + break + } + pad = pad[:len(pad)-1] + if pad == "" { + t.Fatal("could not build a JSON body under the cap") + } + } + var got map[string]string + if err := readJSON(jsonBodyRequest(t, raw), &got); err != nil { + t.Fatalf("body of %d bytes: %v", len(raw), err) + } + if got["x"] != pad { + t.Fatal("decoded payload did not match") + } +} + +func TestReadJSONRejectsBodyJustOverCap(t *testing.T) { + raw := append(append([]byte(`{"x":"`), bytes.Repeat([]byte("a"), maxJSONBody)...), `"}`...) + if len(raw) <= maxJSONBody { + t.Fatalf("fixture is %d bytes, want over %d", len(raw), maxJSONBody) + } + var got map[string]string + err := readJSON(jsonBodyRequest(t, raw), &got) + if err == nil { + t.Fatal("oversized JSON was accepted") + } + var tooBig *http.MaxBytesError + if !errors.As(err, &tooBig) { + t.Fatalf("got %v (%T), want MaxBytesError", err, err) + } + if got != nil { + t.Fatal("unmarshaled a truncated prefix") + } +} + +func TestReadJSONFailsOnMaxBytesReaderError(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "/api/test", strings.NewReader(`{"ok":true}`)) + if err != nil { + t.Fatal(err) + } + req.Body = http.MaxBytesReader(nil, req.Body, 4) + var got map[string]any + gotErr := readJSON(req, &got) + if gotErr == nil { + t.Fatal("capped reader was accepted") + } + var tooBig *http.MaxBytesError + if !errors.As(gotErr, &tooBig) { + t.Fatalf("got %v (%T), want MaxBytesError", gotErr, gotErr) + } +} diff --git a/go/internal/loadpoint/controller_dispatch_outcome_test.go b/go/internal/loadpoint/controller_dispatch_outcome_test.go index a1338c13..493734d4 100644 --- a/go/internal/loadpoint/controller_dispatch_outcome_test.go +++ b/go/internal/loadpoint/controller_dispatch_outcome_test.go @@ -100,6 +100,27 @@ func outcomeFixture(t *testing.T, now time.Time, sender *outcomeSender) (*Contro // The charger answers every poll and refuses every setpoint. Before this was // wired the controller logged the refusal and moved on, so nothing upstream // ever learned that the EV load the plan booked was not being drawn. +func TestTickUsesOutcomeSenderNotPlainSend(t *testing.T) { + now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) + plain := &outcomeSender{} + c, _, _ := outcomeFixture(t, now, plain) + var outcomeN int + c.SetOutcomeSender(func(_ context.Context, _ string, _ []byte, outcome func(error)) error { + outcomeN++ + if outcome != nil { + outcome(nil) + } + return nil + }) + c.Tick(context.Background(), now) + if n := len(plain.sent()); n != 0 { + t.Fatalf("plain send used %d times; periodic dispatch must use the outcome sender", n) + } + if outcomeN != 1 { + t.Fatalf("outcome sender used %d times, want 1", outcomeN) + } +} + func TestRefusedEVSetCurrentIsReported(t *testing.T) { now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) refusal := errors.New("driver_command returned false") diff --git a/go/internal/ocpp/handlers.go b/go/internal/ocpp/handlers.go index 09f9b294..00359da9 100644 --- a/go/internal/ocpp/handlers.go +++ b/go/internal/ocpp/handlers.go @@ -179,6 +179,16 @@ func (h *Handler) SetApprovedIDs(ids []string) { } } +// IsApproved reports whether a charger id is on the site allowlist and may +// therefore feed telemetry and accept commands. Pending connectors stay +// visible in Snapshot but are not adopted. +func (h *Handler) IsApproved(id string) bool { + if h == nil { + return false + } + return h.isApproved(id) +} + // isApproved reports whether a charger id is named by a charger entry // (loadpoint) and therefore allowed to feed the site model. func (h *Handler) isApproved(id string) bool {