From bfa4da3dba835b19670020a97f5eb5346c11a88e Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 27 Oct 2026 19:28:32 +0000 Subject: [PATCH] Show the whole IP lookup answer, @meta included, on the home page (closes #201) The home page's IP lookup card showed only the answer's `data` part, or only the message of an error answer. It now shows the whole answer, `@meta` included, as the IP information API card does. The server already added `@meta` to every JSON answer. A new table test requests every JSON route and checks `@meta` and its fields. It covers successes, the errors the handlers write, the router's 404 and 405, and the 500 and 504 the middleware writes. Model: opus-5-5 --- TODO.md | 5 + internal/server/handlers_internal_test.go | 3 +- internal/server/middleware_internal_test.go | 112 +++++++++++++++++++- internal/templates/index.html | 16 +-- 4 files changed, 123 insertions(+), 13 deletions(-) diff --git a/TODO.md b/TODO.md index e4761d2..2bc4b75 100644 --- a/TODO.md +++ b/TODO.md @@ -35,6 +35,11 @@ tested. # Completed Steps +- 2026-10-27: the home page's IP lookup shows the whole JSON answer, with its + `@meta`, as the IP information API card does. It used to show only the + answer's `data`, or only the message of an error answer. A test checks that + every JSON route answers with `@meta`, in its successes and its errors (closes + #201) - 2026-10-27: every value in a JSON answer that has a unit is a number, with the unit at the end of its field name. `@meta.execution_time` is now `execution_time_ms`. The stats' `uptime`, `connection_duration` and diff --git a/internal/server/handlers_internal_test.go b/internal/server/handlers_internal_test.go index 642f057..a1ce195 100644 --- a/internal/server/handlers_internal_test.go +++ b/internal/server/handlers_internal_test.go @@ -77,6 +77,7 @@ const ( prefixLengthPagePath = "/prefixlength/24" statusJSONPath = "/status.json" statsPath = "/api/v1/stats" + healthCheckPath = "/.well-known/healthcheck.json" ) // The names the stats handler tests report the two stats handlers by. @@ -1109,7 +1110,7 @@ func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) { path string wantCode int }{ - {"/.well-known/healthcheck.json", http.StatusServiceUnavailable}, + {healthCheckPath, http.StatusServiceUnavailable}, {statusJSONPath, http.StatusInternalServerError}, {statsPath, http.StatusInternalServerError}, {asJSONPath, http.StatusInternalServerError}, diff --git a/internal/server/middleware_internal_test.go b/internal/server/middleware_internal_test.go index 88d2bf6..a3d53ba 100644 --- a/internal/server/middleware_internal_test.go +++ b/internal/server/middleware_internal_test.go @@ -2,6 +2,7 @@ package server import ( "bytes" + "context" "encoding/json" "io" "log/slog" @@ -119,7 +120,7 @@ func TestJSONAnswersPutNoUnitInAString(t *testing.T) { paths := []string{ statusJSONPath, statsPath, - "/.well-known/healthcheck.json", + healthCheckPath, "/api/v1/ip/" + untrustedPeerAddr, "/ip/" + untrustedPeerAddr, asJSONPath, @@ -156,6 +157,115 @@ func TestJSONAnswersPutNoUnitInAString(t *testing.T) { } } +// TestJSONAnswersCarryMeta sends requests to every JSON route through the +// server's router, and checks that each answer carries @meta with its three +// fields. The answers include successes, and errors that the handlers, the +// router and the middleware write +// (https://git.eeqj.de/sneak/routewatch/issues/201). +func TestJSONAnswersCarryMeta(t *testing.T) { + t.Parallel() + + // writesNothingPath is a JSON route whose handler writes nothing, so that + // the JSON check on the route writes the error answer itself. + const writesNothingPath = "/writes-nothing.json" + + s := newServerWithTestRoutes(t) + s.resolver = &fakeResolver{} + s.streamer.GetMetricsTracker().SetConnected(true) + s.router.Get(writesNothingPath, JSONValidationMiddleware(http.HandlerFunc( + func(http.ResponseWriter, *http.Request) {})).ServeHTTP) + + tests := []struct { + method string + path string + wantCode int + }{ + // Answers that succeed + {http.MethodGet, statusJSONPath, http.StatusOK}, + {http.MethodGet, healthCheckPath, http.StatusOK}, + {http.MethodGet, "/ip", http.StatusOK}, + {http.MethodGet, ownAddressPath, http.StatusOK}, + {http.MethodGet, "/ip/" + addrInTestPrefixV4, http.StatusOK}, + {http.MethodGet, statsPath, http.StatusOK}, + {http.MethodGet, "/api/v1/ip/" + addrInTestPrefixV4, http.StatusOK}, + {http.MethodGet, asJSONPath, http.StatusOK}, + {http.MethodGet, "/api/v1/prefix/" + testPrefixV4, http.StatusOK}, + {http.MethodGet, "/api/v1/search/as?q=google", http.StatusOK}, + // Errors the handlers write + {http.MethodGet, "/ip/no-such-host.example", http.StatusBadRequest}, + {http.MethodGet, "/api/v1/as/foo", http.StatusBadRequest}, + {http.MethodGet, "/api/v1/as/0", http.StatusNotFound}, + {http.MethodGet, "/api/v1/prefix/192.0.2.0/33", http.StatusBadRequest}, + {http.MethodGet, "/api/v1/prefix/198.51.100.0/24", http.StatusNotFound}, + {http.MethodGet, "/api/v1/search/as", http.StatusBadRequest}, + // Errors the router and the middleware write + {http.MethodGet, "/api/v1/no-such-route", http.StatusNotFound}, + {http.MethodGet, "/ip/192.0.2.1/nothing", http.StatusNotFound}, + {http.MethodPost, statsPath, http.StatusMethodNotAllowed}, + {http.MethodPost, ownAddressPath, http.StatusMethodNotAllowed}, + {http.MethodGet, writesNothingPath, http.StatusInternalServerError}, + } + + for _, tt := range tests { + checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext( + t.Context(), tt.method, tt.path, nil), tt.wantCode) + } + + // A request whose time limit has already passed answers 504. The handlers + // write the answers of the first three routes, and the JSON check on the + // route writes the last. + pastTimeLimit, cancel := context.WithDeadline(t.Context(), time.Now()) + defer cancel() + + for _, path := range []string{ + statusJSONPath, "/ip/" + addrInTestPrefixV4, asJSONPath, writesNothingPath, + } { + checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext( + pastTimeLimit, http.MethodGet, path, nil), http.StatusGatewayTimeout) + } +} + +// checkAnswerCarriesMeta serves req through the server's router, and checks +// that it answers wantCode with @meta and its three fields. +func checkAnswerCarriesMeta( + t *testing.T, s *Server, req *http.Request, wantCode int, +) { + t.Helper() + + // The values README "API v1" gives the two fields that never change. + const ( + wantTimeZone = "UTC" + wantAPIVersion = 1 + ) + + name := req.Method + " " + req.URL.String() + + rec := httptest.NewRecorder() + s.router.ServeHTTP(rec, req) + + if rec.Code != wantCode { + t.Errorf("%s: status %d, want %d; body %s", name, rec.Code, wantCode, rec.Body) + } + + var answer map[string]any + + err := json.Unmarshal(rec.Body.Bytes(), &answer) + if err != nil { + t.Errorf("%s: decoding the answer %q: %v", name, rec.Body, err) + + return + } + + meta, _ := answer[jsonKeyMeta].(map[string]any) + _, timeIsNumber := meta[metaKeyExecutionTimeMs].(float64) + + if meta[metaKeyTimeZone] != wantTimeZone || + meta[metaKeyAPIVersion] != float64(wantAPIVersion) || !timeIsNumber { + t.Errorf("%s: @meta %v, want time_zone %q, api_version %d and a "+ + "number in execution_time_ms", name, meta, wantTimeZone, wantAPIVersion) + } +} + // stringsIn returns every string in a decoded JSON value, at any depth. func stringsIn(value any) []string { var found []string diff --git a/internal/templates/index.html b/internal/templates/index.html index ff1edfb..c940576 100644 --- a/internal/templates/index.html +++ b/internal/templates/index.html @@ -714,7 +714,8 @@ }); }); - // IP lookup + // IP lookup: show the whole JSON answer, its @meta and an error answer + // included, as the IP information API card does. document.getElementById('ip-form').addEventListener('submit', function(e) { e.preventDefault(); const ip = document.getElementById('ip-input').value.trim(); @@ -730,18 +731,11 @@ fetch('/ip/' + encodeURIComponent(ip)) .then(response => response.json()) - .then(response => { - contentEl.classList.remove('loading'); - if (response.status === 'error') { - contentEl.className = 'error'; - contentEl.textContent = 'Error: ' + response.error.msg; - } else { - contentEl.className = ''; - contentEl.textContent = JSON.stringify(response.data, null, 2); - } + .then(answer => { + contentEl.className = ''; + contentEl.textContent = JSON.stringify(answer, null, 2); }) .catch(error => { - contentEl.classList.remove('loading'); contentEl.className = 'error'; contentEl.textContent = 'Error: ' + error.message; });