v0.185.1 — E-2: the offer endpoints were mounted where nothing routed to them

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.
This commit is contained in:
2026-07-29 09:13:13 +02:00
parent 3f7cf2a965
commit cdaeb36972
4 changed files with 53 additions and 4 deletions
+12
View File
@@ -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
@@ -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
@@ -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)
}
+2 -2
View File
@@ -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)