From cdaeb369726b83e13493988f2f63d3771ee1b284 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 29 Jul 2026 09:13:13 +0200 Subject: [PATCH] =?UTF-8?q?v0.185.1=20=E2=80=94=20E-2:=20the=20offer=20end?= =?UTF-8?q?points=20were=20mounted=20where=20nothing=20routed=20to=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registered as /api/backup-target inside ServeStorageAPI, which main.go mounts ONLY at /api/storage/. Live result: endpoint not found, while every unit test passed -- the tests called the handlers directly and never travelled the mount. Caught by the first live call, which is why the live call is part of the procedure. Moved to /api/storage/backup-target[/assign]. A new test asserts the dispatcher source contains both paths, so a handler nothing routes to fails the suite -- the seam-wiring rule applied to a route rather than a button. --- CHANGELOG.md | 12 ++++++ .../internal/web/backup_target_offer.go | 4 +- .../internal/web/backup_target_offer_test.go | 37 +++++++++++++++++++ controller/internal/web/storage_handlers.go | 4 +- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3809642..a872cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ ## Changelog +### v0.185.1 — E-2: the offer endpoints were mounted where nothing routed to them (2026-07-29) + +Registered as `/api/backup-target` inside `ServeStorageAPI`'s switch — which `main.go` mounts ONLY at +`/api/storage/`. Live result: `{"ok":false,"error":"endpoint not found"}` while every unit test +passed, because the tests called the handlers directly and never travelled the mount. Caught by the +first live call, which is exactly why the live call is part of the procedure. + +Moved to `/api/storage/backup-target[/assign]`. `TestBackupTargetRoutesLiveUnderTheStorageAPIMount` +now asserts the dispatcher's own source contains both paths, so a handler that nothing routes to +fails the suite — the repo's seam-wiring rule ("a feature is not shipped until its entry point is +reachable"), applied to a route rather than a button. + ### v0.185.0 — E-2 Parts 3+4: the offer, and the honest degraded state (2026-07-29) — MinAgent 0.113.0 The half that makes the rest work. A degraded backup target recorded only in config is the diff --git a/controller/internal/web/backup_target_offer.go b/controller/internal/web/backup_target_offer.go index 6934bda..ee9c239 100644 --- a/controller/internal/web/backup_target_offer.go +++ b/controller/internal/web/backup_target_offer.go @@ -136,7 +136,7 @@ func degradedMessageFor(st BackupTargetState) string { return backupTargetDegradedText } -// handleBackupTargetState serves GET /api/backup-target — the dashboard's source for the degraded +// handleBackupTargetState serves GET /api/storage/backup-target — the dashboard's source for the degraded // banner and the offer. func (s *Server) handleBackupTargetState(w http.ResponseWriter, r *http.Request) { st := s.resolveBackupTargetState(r.Context()) @@ -157,7 +157,7 @@ func (s *Server) handleBackupTargetState(w http.ResponseWriter, r *http.Request) writeDiskJSON(w, http.StatusOK, true, "", out) } -// handleBackupTargetAssign is the ACCEPTANCE of the offer — POST /api/backup-target/assign. +// handleBackupTargetAssign is the ACCEPTANCE of the offer — POST /api/storage/backup-target/assign. // // NOTHING calls this except an explicit customer action. Registration does not, the drive-gate does // not, and no scheduler does (E-2 §3: a drive never acquires a role by appearing). Declining is diff --git a/controller/internal/web/backup_target_offer_test.go b/controller/internal/web/backup_target_offer_test.go index ac6d6da..0d2c626 100644 --- a/controller/internal/web/backup_target_offer_test.go +++ b/controller/internal/web/backup_target_offer_test.go @@ -1,6 +1,7 @@ package web import ( + "os" "strings" "testing" ) @@ -59,3 +60,39 @@ func TestUnknownStateRendersNothing(t *testing.T) { "of degradation", msg) } } + +// REACHABILITY — the repo's seam-wiring rule, and it already bit once here. +// +// These handlers were first registered as /api/backup-target inside ServeStorageAPI's switch, which +// main.go mounts ONLY at "/api/storage/". The endpoints returned "endpoint not found" on a live box +// while every unit test passed, because the tests called the handlers directly and never travelled +// the mount. A feature is not shipped until its entry point is reachable. +func TestBackupTargetRoutesLiveUnderTheStorageAPIMount(t *testing.T) { + // main.go: mux.Handle("/api/storage/", ... ServeStorageAPI). Anything the switch answers MUST + // therefore begin with that prefix or it is dead on arrival. + for _, p := range []string{ + "/api/storage/backup-target", + "/api/storage/backup-target/assign", + } { + if !strings.HasPrefix(p, "/api/storage/") { + t.Fatalf("%s is outside the /api/storage/ mount and would 404 in production", p) + } + } + src := storageAPISource(t) + for _, want := range []string{`"/api/storage/backup-target"`, `"/api/storage/backup-target/assign"`} { + if !strings.Contains(src, want) { + t.Errorf("ServeStorageAPI does not dispatch %s — the handler exists but nothing routes to it", want) + } + } +} + +// storageAPISource reads the dispatcher's own source, so the assertion is about what the router +// actually contains rather than about a constant the test itself defines. +func storageAPISource(t *testing.T) string { + t.Helper() + b, err := os.ReadFile("storage_handlers.go") + if err != nil { + t.Fatalf("read storage_handlers.go: %v", err) + } + return string(b) +} diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index 94d196f..cbefe33 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -356,9 +356,9 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) { s.handleStorageRegister(w, r) // E-2 Parts 3+4. State drives the degraded banner and the OFFER; assign is the offer's // ACCEPTANCE and the ONLY writer of the role — registration above deliberately does not set it. - case r.URL.Path == "/api/backup-target" && r.Method == http.MethodGet: + case r.URL.Path == "/api/storage/backup-target" && r.Method == http.MethodGet: s.handleBackupTargetState(w, r) - case r.URL.Path == "/api/backup-target/assign" && r.Method == http.MethodPost: + case r.URL.Path == "/api/storage/backup-target/assign" && r.Method == http.MethodPost: s.handleBackupTargetAssign(w, r) case r.URL.Path == "/api/storage/migrate" && r.Method == http.MethodPost: s.handleStorageMigrate(w, r)