Give every API value with a unit as a number (closes #199)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A value with a unit is now a number, and its field name ends in the unit, as the vendored API_POLICIES.md requires. @meta.execution_time becomes execution_time_ms. In the stats answers, uptime, connection_duration, memory_usage and whois_stats.current_interval become uptime_seconds, connection_duration_seconds, memory_usage_bytes and whois_stats.current_interval_seconds. connection_duration_seconds is null while the feed is not connected. The status page formats the numbers itself. REPO_POLICIES.md is re-vendored from the same sneak/prompts commit. Model: opus-5-5
This commit is contained in:
+41
-33
@@ -23,7 +23,6 @@ import (
|
||||
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
||||
"git.eeqj.de/sneak/routewatch/internal/templates"
|
||||
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -226,18 +225,23 @@ type WHOISStatsInfo struct {
|
||||
NeverFetched int `json:"never_fetched"`
|
||||
SuccessesLastHour int `json:"successes_last_hour"`
|
||||
ErrorsLastHour int `json:"errors_last_hour"`
|
||||
CurrentInterval string `json:"current_interval"`
|
||||
ConsecutiveFails int `json:"consecutive_fails"`
|
||||
FreshPercent float64 `json:"fresh_percent"`
|
||||
|
||||
// CurrentIntervalSeconds is the wait between two fetches. It can have a
|
||||
// fractional part, such as 22.5.
|
||||
CurrentIntervalSeconds float64 `json:"current_interval_seconds"`
|
||||
}
|
||||
|
||||
// Stats is the /status.json answer: uptime, message counts, database stats,
|
||||
// and the routing table's figures. OldestRoute and NewestRoute are the times
|
||||
// of the oldest and the newest kept routes.
|
||||
// and the routing table's figures. UptimeSeconds is the time since the stream
|
||||
// last connected, or 0 if it has never connected. MemoryUsageBytes is the Go
|
||||
// heap in use. OldestRoute and NewestRoute are the times of the oldest and the
|
||||
// newest kept routes.
|
||||
//
|
||||
//nolint:tagliatelle // field names are the existing JSON API
|
||||
type Stats struct {
|
||||
Uptime string `json:"uptime"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
TotalMessages uint64 `json:"total_messages"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
TotalWireBytes uint64 `json:"total_wire_bytes"`
|
||||
@@ -247,7 +251,7 @@ type Stats struct {
|
||||
Connected bool `json:"connected"`
|
||||
GoVersion string `json:"go_version"`
|
||||
Goroutines int `json:"goroutines"`
|
||||
MemoryUsage string `json:"memory_usage"`
|
||||
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
||||
ASNs int `json:"asns"`
|
||||
Prefixes int `json:"prefixes"`
|
||||
IPv4Prefixes int `json:"ipv4_prefixes"`
|
||||
@@ -362,7 +366,7 @@ func (s *Server) statusStats(
|
||||
runtime.ReadMemStats(&memStats)
|
||||
|
||||
return Stats{
|
||||
Uptime: streamUptime(streamMetrics.ConnectedSince),
|
||||
UptimeSeconds: uptimeSeconds(streamMetrics.ConnectedSince),
|
||||
TotalMessages: streamMetrics.TotalMessages,
|
||||
TotalBytes: streamMetrics.TotalBytes,
|
||||
TotalWireBytes: streamMetrics.TotalWireBytes,
|
||||
@@ -372,7 +376,7 @@ func (s *Server) statusStats(
|
||||
Connected: streamMetrics.Connected,
|
||||
GoVersion: runtime.Version(),
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
MemoryUsage: humanize.Bytes(memStats.Alloc),
|
||||
MemoryUsageBytes: memStats.Alloc,
|
||||
ASNs: dbStats.ASNs,
|
||||
Prefixes: counts.IPv4Prefixes + counts.IPv6Prefixes,
|
||||
IPv4Prefixes: counts.IPv4Prefixes,
|
||||
@@ -439,30 +443,31 @@ func (s *Server) getWHOISStats(ctx context.Context) *WHOISStatsInfo {
|
||||
NeverFetched: dbStats.NeverFetched,
|
||||
SuccessesLastHour: fetcherStats.SuccessesLastHour,
|
||||
ErrorsLastHour: fetcherStats.ErrorsLastHour,
|
||||
CurrentInterval: fetcherStats.CurrentInterval.String(),
|
||||
ConsecutiveFails: fetcherStats.ConsecutiveFails,
|
||||
FreshPercent: freshPercent,
|
||||
|
||||
CurrentIntervalSeconds: fetcherStats.CurrentInterval.Seconds(),
|
||||
}
|
||||
}
|
||||
|
||||
// streamUptime is how long the stream has been connected, to the second, or
|
||||
// "0s" if it has never connected.
|
||||
func streamUptime(connectedSince time.Time) string {
|
||||
// uptimeSeconds is the number of whole seconds since the stream last
|
||||
// connected, or 0 if it has never connected.
|
||||
func uptimeSeconds(connectedSince time.Time) int64 {
|
||||
if connectedSince.IsZero() {
|
||||
return "0s"
|
||||
return 0
|
||||
}
|
||||
|
||||
return time.Since(connectedSince).Truncate(time.Second).String()
|
||||
return int64(time.Since(connectedSince) / time.Second)
|
||||
}
|
||||
|
||||
// connectionDuration is how long the stream has been connected, to the
|
||||
// second, or "disconnected".
|
||||
func connectionDuration(connected bool, connectedSince time.Time) string {
|
||||
// connectionDurationSeconds is the number of whole seconds the stream has
|
||||
// been connected, or nil while it is not connected.
|
||||
func connectionDurationSeconds(connected bool, connectedSince time.Time) *int64 {
|
||||
if !connected || connectedSince.IsZero() {
|
||||
return "disconnected"
|
||||
return nil
|
||||
}
|
||||
|
||||
return time.Since(connectedSince).Truncate(time.Second).String()
|
||||
return new(uptimeSeconds(connectedSince))
|
||||
}
|
||||
|
||||
// whoisStaleThreshold matches the fetcher's threshold for consistency.
|
||||
@@ -512,7 +517,7 @@ type StreamStats struct {
|
||||
//
|
||||
//nolint:tagliatelle // field names are the existing JSON API
|
||||
type StatsResponse struct {
|
||||
Uptime string `json:"uptime"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
TotalMessages uint64 `json:"total_messages"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
TotalWireBytes uint64 `json:"total_wire_bytes"`
|
||||
@@ -520,11 +525,10 @@ type StatsResponse struct {
|
||||
MbitsPerSec float64 `json:"mbits_per_sec"`
|
||||
WireMbitsPerSec float64 `json:"wire_mbits_per_sec"`
|
||||
Connected bool `json:"connected"`
|
||||
ConnectionDuration string `json:"connection_duration"`
|
||||
ReconnectCount uint64 `json:"reconnect_count"`
|
||||
GoVersion string `json:"go_version"`
|
||||
Goroutines int `json:"goroutines"`
|
||||
MemoryUsage string `json:"memory_usage"`
|
||||
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
||||
GC GCStats `json:"gc"`
|
||||
Stream StreamStats `json:"stream"`
|
||||
ASNs int `json:"asns"`
|
||||
@@ -546,6 +550,9 @@ type StatsResponse struct {
|
||||
IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
|
||||
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
|
||||
|
||||
// ConnectionDurationSeconds is null while the stream is not connected.
|
||||
ConnectionDurationSeconds *int64 `json:"connection_duration_seconds"`
|
||||
|
||||
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
|
||||
}
|
||||
|
||||
@@ -684,20 +691,18 @@ func (s *Server) statsResponse(
|
||||
runtime.ReadMemStats(&memStats)
|
||||
|
||||
return StatsResponse{
|
||||
Uptime: streamUptime(streamMetrics.ConnectedSince),
|
||||
TotalMessages: streamMetrics.TotalMessages,
|
||||
TotalBytes: streamMetrics.TotalBytes,
|
||||
TotalWireBytes: streamMetrics.TotalWireBytes,
|
||||
MessagesPerSec: streamMetrics.MessagesPerSec,
|
||||
MbitsPerSec: streamMetrics.BitsPerSec / bitsPerMegabit,
|
||||
WireMbitsPerSec: streamMetrics.WireBitsPerSec / bitsPerMegabit,
|
||||
Connected: streamMetrics.Connected,
|
||||
ConnectionDuration: connectionDuration(
|
||||
streamMetrics.Connected, streamMetrics.ConnectedSince),
|
||||
UptimeSeconds: uptimeSeconds(streamMetrics.ConnectedSince),
|
||||
TotalMessages: streamMetrics.TotalMessages,
|
||||
TotalBytes: streamMetrics.TotalBytes,
|
||||
TotalWireBytes: streamMetrics.TotalWireBytes,
|
||||
MessagesPerSec: streamMetrics.MessagesPerSec,
|
||||
MbitsPerSec: streamMetrics.BitsPerSec / bitsPerMegabit,
|
||||
WireMbitsPerSec: streamMetrics.WireBitsPerSec / bitsPerMegabit,
|
||||
Connected: streamMetrics.Connected,
|
||||
ReconnectCount: streamMetrics.ReconnectCount,
|
||||
GoVersion: runtime.Version(),
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
MemoryUsage: humanize.Bytes(memStats.Alloc),
|
||||
MemoryUsageBytes: memStats.Alloc,
|
||||
GC: gcStats(&memStats),
|
||||
Stream: s.streamStats(),
|
||||
ASNs: dbStats.ASNs,
|
||||
@@ -719,6 +724,9 @@ func (s *Server) statsResponse(
|
||||
IPv6PrefixDistribution: prefixDistribution(counts.IPv6PrefixesByLength),
|
||||
WHOISStats: whoisStats,
|
||||
|
||||
ConnectionDurationSeconds: connectionDurationSeconds(
|
||||
streamMetrics.Connected, streamMetrics.ConnectedSince),
|
||||
|
||||
OriginsWithoutKeptRoute: counts.OriginsWithoutKeptRoute,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ func emptyTable() *routetable.Table {
|
||||
// Paths several tests request.
|
||||
const (
|
||||
asPagePath = "/as/64500"
|
||||
asJSONPath = "/api/v1/as/64500"
|
||||
prefixLengthPagePath = "/prefixlength/24"
|
||||
statusJSONPath = "/status.json"
|
||||
statsPath = "/api/v1/stats"
|
||||
@@ -295,6 +296,167 @@ func TestStatsAnswerCarriesChurnPerMinute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// fixedIntervalASNFetcher is a WHOIS fetcher that does nothing and reports
|
||||
// interval as its wait between fetches.
|
||||
type fixedIntervalASNFetcher struct {
|
||||
idleASNFetcher
|
||||
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
func (f fixedIntervalASNFetcher) GetStats() ASNFetcherStats {
|
||||
return ASNFetcherStats{CurrentInterval: f.interval}
|
||||
}
|
||||
|
||||
// unitStats holds the figures of a stats answer that carry a unit. Each is a
|
||||
// pointer, so that a figure missing from the answer is nil.
|
||||
//
|
||||
//nolint:tagliatelle // field names are the existing JSON API
|
||||
type unitStats struct {
|
||||
UptimeSeconds *int64 `json:"uptime_seconds"`
|
||||
MemoryUsageBytes *uint64 `json:"memory_usage_bytes"`
|
||||
WHOISStats struct {
|
||||
CurrentIntervalSeconds *float64 `json:"current_interval_seconds"`
|
||||
} `json:"whois_stats"`
|
||||
GC struct {
|
||||
HeapAllocBytes uint64 `json:"heap_alloc_bytes"`
|
||||
} `json:"gc"`
|
||||
}
|
||||
|
||||
// TestStatsAnswersGiveUnitsInFieldNames checks that the figures of both stats
|
||||
// answers that carry a unit are numbers, with the unit at the end of their
|
||||
// names. They used to be strings with the unit in them, such as "1h18m34s" or
|
||||
// "1.1 GB" (https://git.eeqj.de/sneak/routewatch/issues/199). The stream has
|
||||
// never connected, so the uptime is 0. The memory use in /api/v1/stats is the
|
||||
// same number of bytes as its gc.heap_alloc_bytes.
|
||||
func TestStatsAnswersGiveUnitsInFieldNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const fetchInterval = 18200 * time.Millisecond
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
s.SetASNFetcher(fixedIntervalASNFetcher{interval: fetchInterval})
|
||||
|
||||
for _, path := range []string{statusJSONPath, statsPath} {
|
||||
answer := get(t, s, path).Body.Bytes()
|
||||
|
||||
var body struct {
|
||||
Data unitStats `json:"data"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(answer, &body)
|
||||
if err != nil {
|
||||
t.Errorf("%s: figures are not numbers: %v; answer %s", path, err, answer)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
got := body.Data
|
||||
if got.UptimeSeconds == nil || *got.UptimeSeconds != 0 {
|
||||
t.Errorf("%s: uptime_seconds is not 0: %s", path, answer)
|
||||
}
|
||||
|
||||
if got.MemoryUsageBytes == nil || *got.MemoryUsageBytes == 0 {
|
||||
t.Errorf("%s: memory_usage_bytes is not a number of bytes: %s",
|
||||
path, answer)
|
||||
}
|
||||
|
||||
interval := got.WHOISStats.CurrentIntervalSeconds
|
||||
if interval == nil || *interval != fetchInterval.Seconds() {
|
||||
t.Errorf("%s: whois_stats.current_interval_seconds is not %v: %s",
|
||||
path, fetchInterval.Seconds(), answer)
|
||||
}
|
||||
|
||||
if path == statsPath && got.MemoryUsageBytes != nil &&
|
||||
*got.MemoryUsageBytes != got.GC.HeapAllocBytes {
|
||||
t.Errorf("%s: memory_usage_bytes %d, want gc.heap_alloc_bytes %d",
|
||||
path, *got.MemoryUsageBytes, got.GC.HeapAllocBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatsAnswerGivesConnectionDurationInSeconds checks that the
|
||||
// connection_duration_seconds of /api/v1/stats is null while the stream is not
|
||||
// connected, and a whole number of seconds once it is. It used to be the
|
||||
// string "disconnected", or a Go duration such as "1h18m34s"
|
||||
// (https://git.eeqj.de/sneak/routewatch/issues/199).
|
||||
func TestStatsAnswerGivesConnectionDurationInSeconds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
|
||||
connectionDuration := func() json.RawMessage {
|
||||
answer := get(t, s, statsPath).Body.Bytes()
|
||||
|
||||
var body struct {
|
||||
Data map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(answer, &body)
|
||||
if err != nil {
|
||||
t.Fatalf("decoding the answer: %v; answer %s", err, answer)
|
||||
}
|
||||
|
||||
duration, ok := body.Data["connection_duration_seconds"]
|
||||
if !ok {
|
||||
t.Fatalf("no connection_duration_seconds: %s", answer)
|
||||
}
|
||||
|
||||
return duration
|
||||
}
|
||||
|
||||
disconnected := connectionDuration()
|
||||
if string(disconnected) != "null" {
|
||||
t.Errorf("disconnected: connection_duration_seconds = %s, want null",
|
||||
disconnected)
|
||||
}
|
||||
|
||||
s.streamer.GetMetricsTracker().SetConnected(true)
|
||||
|
||||
var seconds int64
|
||||
|
||||
connected := connectionDuration()
|
||||
|
||||
err := json.Unmarshal(connected, &seconds)
|
||||
if err != nil || seconds != 0 {
|
||||
t.Errorf("just connected: connection_duration_seconds = %s, want 0",
|
||||
connected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamDurationsCountSeconds checks that the uptime and the connection
|
||||
// duration of a stream that connected 90 seconds ago are both 90. A stream
|
||||
// that has never connected has an uptime of 0, and one that is not connected
|
||||
// has no connection duration.
|
||||
func TestStreamDurationsCountSeconds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const connectedFor = 90 * time.Second
|
||||
|
||||
connectedSince := time.Now().Add(-connectedFor)
|
||||
want := int64(connectedFor / time.Second)
|
||||
|
||||
if got := uptimeSeconds(connectedSince); got != want {
|
||||
t.Errorf("uptimeSeconds = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
if got := uptimeSeconds(time.Time{}); got != 0 {
|
||||
t.Errorf("uptimeSeconds before any connection = %d, want 0", got)
|
||||
}
|
||||
|
||||
switch got := connectionDurationSeconds(true, connectedSince); {
|
||||
case got == nil:
|
||||
t.Errorf("connectionDurationSeconds = nil, want %d", want)
|
||||
case *got != want:
|
||||
t.Errorf("connectionDurationSeconds = %d, want %d", *got, want)
|
||||
}
|
||||
|
||||
if got := connectionDurationSeconds(false, connectedSince); got != nil {
|
||||
t.Errorf("connectionDurationSeconds while not connected = %d, want nil",
|
||||
*got)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestTable returns a routing table that holds a route for testPrefixV4,
|
||||
// with origin AS 64500, and one for testPrefixV6, with origin AS 64501.
|
||||
func newTestTable() *routetable.Table {
|
||||
@@ -766,7 +928,7 @@ func TestRoutesAnswer504WhenDatabaseReadIsCutOff(t *testing.T) {
|
||||
{path: "/ip/" + addrInTestPrefixV4, wantJSONBody: true},
|
||||
{path: statsPath, wantJSONBody: true},
|
||||
{path: "/api/v1/ip/" + addrInTestPrefixV4, wantJSONBody: true},
|
||||
{path: "/api/v1/as/64500", wantJSONBody: true},
|
||||
{path: asJSONPath, wantJSONBody: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -950,7 +1112,7 @@ func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
|
||||
{"/.well-known/healthcheck.json", http.StatusServiceUnavailable},
|
||||
{statusJSONPath, http.StatusInternalServerError},
|
||||
{statsPath, http.StatusInternalServerError},
|
||||
{"/api/v1/as/64500", http.StatusInternalServerError},
|
||||
{asJSONPath, http.StatusInternalServerError},
|
||||
{"/api/v1/ip/" + addrInTestPrefixV4, http.StatusInternalServerError},
|
||||
{"/api/v1/prefix/foo/24", http.StatusBadRequest},
|
||||
}
|
||||
|
||||
@@ -53,6 +53,15 @@ func (rw *responseWriter) Header() http.Header {
|
||||
return rw.ResponseWriter.Header()
|
||||
}
|
||||
|
||||
// The @meta object JSONResponseMiddleware adds to a JSON answer: its key, and
|
||||
// the keys of its fields.
|
||||
const (
|
||||
jsonKeyMeta = "@meta"
|
||||
metaKeyTimeZone = "time_zone"
|
||||
metaKeyAPIVersion = "api_version"
|
||||
metaKeyExecutionTimeMs = "execution_time_ms"
|
||||
)
|
||||
|
||||
// JSONResponseMiddleware is an HTTP middleware that wraps all JSON responses
|
||||
// with a @meta field containing execution metadata. The metadata includes the
|
||||
// time zone (always UTC), API version, and request execution time in milliseconds.
|
||||
@@ -106,10 +115,10 @@ func JSONResponseMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// Add @meta field
|
||||
originalResponse["@meta"] = map[string]any{
|
||||
"time_zone": "UTC",
|
||||
"api_version": 1,
|
||||
"execution_time": fmt.Sprintf("%d ms", execTime.Milliseconds()),
|
||||
originalResponse[jsonKeyMeta] = map[string]any{
|
||||
metaKeyTimeZone: "UTC",
|
||||
metaKeyAPIVersion: 1,
|
||||
metaKeyExecutionTimeMs: execTime.Milliseconds(),
|
||||
}
|
||||
|
||||
// Write the enhanced response
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -100,6 +101,81 @@ func TestJSONValidationKeeps4xxWithoutBody(t *testing.T) {
|
||||
checkJSONErrorMessage(t, name, rec, http.StatusText(http.StatusBadRequest))
|
||||
}
|
||||
|
||||
// TestJSONAnswersPutNoUnitInAString sends a request to each JSON route and
|
||||
// checks each answer. Its @meta.execution_time_ms is a number, and none of its
|
||||
// strings is a number with a unit, such as "12 ms", "1.1 GB" or "1h18m34s"
|
||||
// (https://git.eeqj.de/sneak/routewatch/issues/199). The health check answers
|
||||
// 503, since the stream is not connected, and the last path matches no route.
|
||||
func TestJSONAnswersPutNoUnitInAString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
numberWithUnit := regexp.MustCompile(
|
||||
`^([0-9]+(\.[0-9]+)? ?(ns|µs|ms|s|m|h|B|kB|MB|GB|TB))+$`)
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
s.resolver = &fakeResolver{}
|
||||
s.SetASNFetcher(fixedIntervalASNFetcher{interval: time.Second})
|
||||
|
||||
paths := []string{
|
||||
statusJSONPath,
|
||||
statsPath,
|
||||
"/.well-known/healthcheck.json",
|
||||
"/api/v1/ip/" + untrustedPeerAddr,
|
||||
"/ip/" + untrustedPeerAddr,
|
||||
asJSONPath,
|
||||
"/api/v1/prefix/" + testPrefixV4,
|
||||
"/api/v1/search/as?q=google",
|
||||
"/api/v1/no-such-route",
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, path, nil))
|
||||
|
||||
var answer map[string]any
|
||||
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &answer)
|
||||
if err != nil {
|
||||
t.Errorf("%s: decoding the answer: %v; answer %s", path, err, rec.Body)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
meta, _ := answer["@meta"].(map[string]any)
|
||||
if _, ok := meta["execution_time_ms"].(float64); !ok {
|
||||
t.Errorf("%s: @meta.execution_time_ms is not a number: %s",
|
||||
path, rec.Body)
|
||||
}
|
||||
|
||||
for _, value := range stringsIn(answer) {
|
||||
if numberWithUnit.MatchString(value) {
|
||||
t.Errorf("%s: %q is a number with a unit in a string", path, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stringsIn returns every string in a decoded JSON value, at any depth.
|
||||
func stringsIn(value any) []string {
|
||||
var found []string
|
||||
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
found = append(found, v)
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
found = append(found, stringsIn(item)...)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, item := range v {
|
||||
found = append(found, stringsIn(item)...)
|
||||
}
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
// TestPagesCarryTheCSPNonce checks that every script and style block of each
|
||||
// page carries the nonce its Content-Security-Policy header names, so that the
|
||||
// browser runs and applies them, that each answer has a nonce of its own, and
|
||||
|
||||
Reference in New Issue
Block a user