Compare commits
1
Commits
next
...
bfa4da3dba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfa4da3dba |
@@ -35,6 +35,11 @@ tested.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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
|
unit at the end of its field name. `@meta.execution_time` is now
|
||||||
`execution_time_ms`. The stats' `uptime`, `connection_duration` and
|
`execution_time_ms`. The stats' `uptime`, `connection_duration` and
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const (
|
|||||||
prefixLengthPagePath = "/prefixlength/24"
|
prefixLengthPagePath = "/prefixlength/24"
|
||||||
statusJSONPath = "/status.json"
|
statusJSONPath = "/status.json"
|
||||||
statsPath = "/api/v1/stats"
|
statsPath = "/api/v1/stats"
|
||||||
|
healthCheckPath = "/.well-known/healthcheck.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The names the stats handler tests report the two stats handlers by.
|
// The names the stats handler tests report the two stats handlers by.
|
||||||
@@ -1109,7 +1110,7 @@ func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
|
|||||||
path string
|
path string
|
||||||
wantCode int
|
wantCode int
|
||||||
}{
|
}{
|
||||||
{"/.well-known/healthcheck.json", http.StatusServiceUnavailable},
|
{healthCheckPath, http.StatusServiceUnavailable},
|
||||||
{statusJSONPath, http.StatusInternalServerError},
|
{statusJSONPath, http.StatusInternalServerError},
|
||||||
{statsPath, http.StatusInternalServerError},
|
{statsPath, http.StatusInternalServerError},
|
||||||
{asJSONPath, http.StatusInternalServerError},
|
{asJSONPath, http.StatusInternalServerError},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -119,7 +120,7 @@ func TestJSONAnswersPutNoUnitInAString(t *testing.T) {
|
|||||||
paths := []string{
|
paths := []string{
|
||||||
statusJSONPath,
|
statusJSONPath,
|
||||||
statsPath,
|
statsPath,
|
||||||
"/.well-known/healthcheck.json",
|
healthCheckPath,
|
||||||
"/api/v1/ip/" + untrustedPeerAddr,
|
"/api/v1/ip/" + untrustedPeerAddr,
|
||||||
"/ip/" + untrustedPeerAddr,
|
"/ip/" + untrustedPeerAddr,
|
||||||
asJSONPath,
|
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.
|
// stringsIn returns every string in a decoded JSON value, at any depth.
|
||||||
func stringsIn(value any) []string {
|
func stringsIn(value any) []string {
|
||||||
var found []string
|
var found []string
|
||||||
|
|||||||
@@ -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) {
|
document.getElementById('ip-form').addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const ip = document.getElementById('ip-input').value.trim();
|
const ip = document.getElementById('ip-input').value.trim();
|
||||||
@@ -730,18 +731,11 @@
|
|||||||
|
|
||||||
fetch('/ip/' + encodeURIComponent(ip))
|
fetch('/ip/' + encodeURIComponent(ip))
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(response => {
|
.then(answer => {
|
||||||
contentEl.classList.remove('loading');
|
contentEl.className = '';
|
||||||
if (response.status === 'error') {
|
contentEl.textContent = JSON.stringify(answer, null, 2);
|
||||||
contentEl.className = 'error';
|
|
||||||
contentEl.textContent = 'Error: ' + response.error.msg;
|
|
||||||
} else {
|
|
||||||
contentEl.className = '';
|
|
||||||
contentEl.textContent = JSON.stringify(response.data, null, 2);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
contentEl.classList.remove('loading');
|
|
||||||
contentEl.className = 'error';
|
contentEl.className = 'error';
|
||||||
contentEl.textContent = 'Error: ' + error.message;
|
contentEl.textContent = 'Error: ' + error.message;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user