From 805fdcd2bc86f813b65f37bb19c33f761cd518bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Sun, 17 May 2026 21:43:47 +0000 Subject: [PATCH 1/3] feat(ui): proxy legacy /print + /jobs/{id} to the backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 First-Print test uses POST http://hhdocker02:8000/print directly against the backend. Phase 7 moved the backend behind a Frontend Go proxy that mounts /api/*, /docs, /openapi.json, /redoc, /readiness, /healthz — but missed /print and /jobs/{id}. Container port 8000 is no longer published either, so the curl-based smoke from inside Tailscale 404'd post-Phase-7. Add both routes to the proxy so the ad-hoc smoke workflow works again via https://labels.strausmann.cloud/print with the claude-automation Basic-Auth header. /jobs uses the chi regex wildcard {rest:.*} that has higher route priority than the plain {id} param, preserving the job-id path component end-to-end to the backend. Refs #22 --- frontend/cmd/server/main.go | 16 ++++++++ frontend/cmd/server/main_test.go | 66 ++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/frontend/cmd/server/main.go b/frontend/cmd/server/main.go index f666001..2a9bf51 100644 --- a/frontend/cmd/server/main.go +++ b/frontend/cmd/server/main.go @@ -151,6 +151,22 @@ func newRouter(ph *handlers.PageHandler, prx http.Handler, staticSubFS fs.FS) *c r.Handle("/redoc", prx) r.Handle("/readiness", prx) + // Legacy Phase-4 First-Print endpoints — still used by ad-hoc curl smoke + // tests from inside the Tailscale network. Before Phase 7 the backend port + // 8000 was public; Phase 7 closed it behind this proxy but missed wiring + // these two paths. The Pangolin Basic-Auth gate (claude-automation header) + // keeps them reachable without SSO. + // + // /print — fixed path, POST only in practice; r.Handle registers all methods. + r.Handle("/print", prx) + // /jobs/{rest:.*} — regex wildcard preserves the full path (including the + // job-id segment) when forwarding to the backend. chi treats a regex + // parameter as higher-priority than a plain {id} parameter, so this route + // takes precedence over the r.Get("/jobs/{id}", ph.JobDetail) page handler + // registered above, making GET /jobs/{job_id} return the backend JSON + // response rather than the HTML page — intentional for the API/curl path. + r.Handle("/jobs/{rest:.*}", prx) + return r } diff --git a/frontend/cmd/server/main_test.go b/frontend/cmd/server/main_test.go index 4923aa3..c6052b9 100644 --- a/frontend/cmd/server/main_test.go +++ b/frontend/cmd/server/main_test.go @@ -379,6 +379,72 @@ func TestProxyMountsBackendDocRoutes(t *testing.T) { } } +// TestProxyMountsLegacyFirstPrintRoutes verifies that POST /print and +// GET /jobs/{id} are forwarded to the backend (Phase 7 legacy smoke path). +// +// Before Phase 7 the smoke test called hhdocker02:8000/print directly +// (container port was public). Phase 7 placed a Go frontend proxy in front +// and closed the public port, but missed wiring /print and /jobs/{id} to the +// backend. This test locks in the fix so the ad-hoc curl workflow +// (POST /print → job_id → GET /jobs/{job_id}) works through Pangolin. +func TestProxyMountsLegacyFirstPrintRoutes(t *testing.T) { + // Not parallel at the outer level: initBuildInfoForTests must run (sync.Once + // write) before any parallel subtest reads the global. + initBuildInfoForTests(t) + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/print" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + fmt.Fprint(w, `{"job_id":"abc-123","status":"queued"}`) + case strings.HasPrefix(r.URL.Path, "/jobs/") && r.Method == http.MethodGet: + // Backend echoes the full path so the test can verify path preservation. + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"path":%q,"status":"completed"}`, r.URL.Path) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(backend.Close) + + ph := handlers.NewPageHandlerFromURL(t, backend.URL) + prx := proxy.New(backend.URL) + sub, err := fs.Sub(staticFS, "web/static") + if err != nil { + t.Fatalf("fs.Sub: %v", err) + } + r := newRouter(ph, prx, sub) + + t.Run("POST /print returns 202 with job_id", func(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/print", + strings.NewReader(`{"template_id":"qr-only-12mm","data":{"title":"T","primary_id":"P","qr_payload":"https://example.com"}}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(rec, req) + if rec.Code != http.StatusAccepted { + t.Fatalf("got %d, want 202 (body: %q)", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"job_id":"abc-123"`) { + t.Errorf("body = %q, expected job_id field", rec.Body.String()) + } + }) + + t.Run("GET /jobs/{id} preserves full path to backend", func(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/jobs/abc-123-def", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("got %d, want 200 (body: %q)", rec.Code, rec.Body.String()) + } + // Critical: the path must reach the backend INTACT — not stripped. + if !strings.Contains(rec.Body.String(), `"path":"/jobs/abc-123-def"`) { + t.Errorf("path not preserved: body = %q", rec.Body.String()) + } + }) +} + // TestRealTemplatesPerPageContent verifies that each page renders its own // content when using the real embedded templates — not the content of whatever // page file happens to be parsed last. From 0d06e7431e4c0a05436546c7e930d20a82ad7a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Sun, 17 May 2026 21:47:08 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(ui):=20revert=20/jobs=20proxy=20?= =?UTF-8?q?=E2=80=94=20overrode=20frontend=20job-detail=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 805fdcd added r.Handle("/jobs/{rest:.*}", prx) which had higher chi route priority than the existing r.Get("/jobs/{id}", ph.JobDetail) page route. Browsers visiting labels.strausmann.cloud/jobs/ got the backend JSON instead of the rendered HTML page — UI regression that was not asked for. Keep only /print proxied (the actual user request — Phase 4 First-Print smoke curl). /jobs/{id} stays as the frontend-rendered HTML page; if automated scripts need JSON, they can rely on the upcoming Phase 7d /api/print + /api/jobs endpoints, or use Accept: application/json sniffing in a future PR. Refs #22 --- frontend/cmd/server/main.go | 18 +++++++---------- frontend/cmd/server/main_test.go | 33 +++++++++++--------------------- 2 files changed, 18 insertions(+), 33 deletions(-) diff --git a/frontend/cmd/server/main.go b/frontend/cmd/server/main.go index 2a9bf51..b1970fa 100644 --- a/frontend/cmd/server/main.go +++ b/frontend/cmd/server/main.go @@ -151,21 +151,17 @@ func newRouter(ph *handlers.PageHandler, prx http.Handler, staticSubFS fs.FS) *c r.Handle("/redoc", prx) r.Handle("/readiness", prx) - // Legacy Phase-4 First-Print endpoints — still used by ad-hoc curl smoke + // Legacy Phase-4 First-Print endpoint — still used by ad-hoc curl smoke // tests from inside the Tailscale network. Before Phase 7 the backend port // 8000 was public; Phase 7 closed it behind this proxy but missed wiring - // these two paths. The Pangolin Basic-Auth gate (claude-automation header) - // keeps them reachable without SSO. + // /print. The Pangolin Basic-Auth gate (claude-automation header) keeps it + // reachable without SSO. // - // /print — fixed path, POST only in practice; r.Handle registers all methods. + // Note: /jobs/{id} is intentionally NOT proxied here — that path is served + // by the r.Get("/jobs/{id}", ph.JobDetail) page handler above which renders + // the HTML job-detail page for browser users. Scripts that need JSON for a + // job id should use the typed /api/* routes instead. r.Handle("/print", prx) - // /jobs/{rest:.*} — regex wildcard preserves the full path (including the - // job-id segment) when forwarding to the backend. chi treats a regex - // parameter as higher-priority than a plain {id} parameter, so this route - // takes precedence over the r.Get("/jobs/{id}", ph.JobDetail) page handler - // registered above, making GET /jobs/{job_id} return the backend JSON - // response rather than the HTML page — intentional for the API/curl path. - r.Handle("/jobs/{rest:.*}", prx) return r } diff --git a/frontend/cmd/server/main_test.go b/frontend/cmd/server/main_test.go index c6052b9..33feb13 100644 --- a/frontend/cmd/server/main_test.go +++ b/frontend/cmd/server/main_test.go @@ -379,14 +379,20 @@ func TestProxyMountsBackendDocRoutes(t *testing.T) { } } -// TestProxyMountsLegacyFirstPrintRoutes verifies that POST /print and -// GET /jobs/{id} are forwarded to the backend (Phase 7 legacy smoke path). +// TestProxyMountsLegacyFirstPrintRoutes verifies that POST /print is +// forwarded to the backend (Phase 7 legacy smoke path). // // Before Phase 7 the smoke test called hhdocker02:8000/print directly -// (container port was public). Phase 7 placed a Go frontend proxy in front -// and closed the public port, but missed wiring /print and /jobs/{id} to the +// (container port was public). Phase 7 placed a Go frontend proxy in +// front and closed the public port, but missed wiring /print to the // backend. This test locks in the fix so the ad-hoc curl workflow -// (POST /print → job_id → GET /jobs/{job_id}) works through Pangolin. +// (POST /print) works through Pangolin with the claude-automation +// Basic-Auth header. +// +// /jobs/{id} is intentionally NOT proxied — that path is served by the +// r.Get("/jobs/{id}", ph.JobDetail) page handler which renders the HTML +// job-detail page for browser users. Scripts that need JSON for a +// specific job id should use the typed /api/* routes instead. func TestProxyMountsLegacyFirstPrintRoutes(t *testing.T) { // Not parallel at the outer level: initBuildInfoForTests must run (sync.Once // write) before any parallel subtest reads the global. @@ -398,10 +404,6 @@ func TestProxyMountsLegacyFirstPrintRoutes(t *testing.T) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusAccepted) fmt.Fprint(w, `{"job_id":"abc-123","status":"queued"}`) - case strings.HasPrefix(r.URL.Path, "/jobs/") && r.Method == http.MethodGet: - // Backend echoes the full path so the test can verify path preservation. - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"path":%q,"status":"completed"}`, r.URL.Path) default: http.NotFound(w, r) } @@ -430,19 +432,6 @@ func TestProxyMountsLegacyFirstPrintRoutes(t *testing.T) { t.Errorf("body = %q, expected job_id field", rec.Body.String()) } }) - - t.Run("GET /jobs/{id} preserves full path to backend", func(t *testing.T) { - t.Parallel() - rec := httptest.NewRecorder() - r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/jobs/abc-123-def", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("got %d, want 200 (body: %q)", rec.Code, rec.Body.String()) - } - // Critical: the path must reach the backend INTACT — not stripped. - if !strings.Contains(rec.Body.String(), `"path":"/jobs/abc-123-def"`) { - t.Errorf("path not preserved: body = %q", rec.Body.String()) - } - }) } // TestRealTemplatesPerPageContent verifies that each page renders its own From 3a0077fa8d47dff26e1cd0795dfd2a500df77456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Sun, 17 May 2026 21:55:52 +0000 Subject: [PATCH 3/3] fix(ui): drop private hostname from test + reuse testRouterWithBackend Bot reviews on PR #84 flagged: - HIGH: hhdocker02 in a test comment tripped the Privacy / secret scan CI job. Replaced with a generic "backend container" reference. - MEDIUM: the new TestProxyMountsLegacyFirstPrintRoutes built its own mock backend + router init inline. Refactored to use the existing testRouterWithBackend helper for parity with the surrounding tests. Refs #22 --- frontend/cmd/server/main_test.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/frontend/cmd/server/main_test.go b/frontend/cmd/server/main_test.go index 33feb13..6f5fd70 100644 --- a/frontend/cmd/server/main_test.go +++ b/frontend/cmd/server/main_test.go @@ -382,8 +382,8 @@ func TestProxyMountsBackendDocRoutes(t *testing.T) { // TestProxyMountsLegacyFirstPrintRoutes verifies that POST /print is // forwarded to the backend (Phase 7 legacy smoke path). // -// Before Phase 7 the smoke test called hhdocker02:8000/print directly -// (container port was public). Phase 7 placed a Go frontend proxy in +// Before Phase 7 the smoke test called the backend container:8000/print +// directly (container port was public). Phase 7 placed a Go frontend proxy in // front and closed the public port, but missed wiring /print to the // backend. This test locks in the fix so the ad-hoc curl workflow // (POST /print) works through Pangolin with the claude-automation @@ -410,13 +410,7 @@ func TestProxyMountsLegacyFirstPrintRoutes(t *testing.T) { })) t.Cleanup(backend.Close) - ph := handlers.NewPageHandlerFromURL(t, backend.URL) - prx := proxy.New(backend.URL) - sub, err := fs.Sub(staticFS, "web/static") - if err != nil { - t.Fatalf("fs.Sub: %v", err) - } - r := newRouter(ph, prx, sub) + r := testRouterWithBackend(t, backend.URL) t.Run("POST /print returns 202 with job_id", func(t *testing.T) { t.Parallel()