check / check (push) Canceled after 0s
The routing table in memory now holds every route, so the two per-peer route tables go, with every write, delete, count and read that served them. The prefix handler applies each update to the table, writes the table's changes every second, and stores each new prefix with its first-seen time. The peer delete, its retry on the next flush and the updates held for it go too. The types and errors of the server's answers move to internal/server. The image's memory settings fit a full table of about 1 GB: GOMEMLIMIT of 2.5 GiB, a 512 MiB page cache for the writing connection, and SQLite heap limits of 768 MiB and 1 GiB. The README describes the new table, its database copy and the memory budget. Model: opus-5-5
183 lines
4.6 KiB
Go
183 lines
4.6 KiB
Go
package templates_test
|
|
|
|
import (
|
|
"bytes"
|
|
"html/template"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"git.eeqj.de/sneak/routewatch/internal/server"
|
|
"git.eeqj.de/sneak/routewatch/internal/templates"
|
|
)
|
|
|
|
// renderPage renders a page with the data every page needs, and the visitor's
|
|
// address, which the home page also needs.
|
|
func renderPage(t *testing.T, tmpl *template.Template) string {
|
|
t.Helper()
|
|
|
|
var page bytes.Buffer
|
|
|
|
err := tmpl.Execute(&page, struct{ CSPNonce, ClientIP string }{})
|
|
if err != nil {
|
|
t.Fatalf("rendering the page: %v", err)
|
|
}
|
|
|
|
return page.String()
|
|
}
|
|
|
|
// TestFooterLicense renders the home and status pages and expects each footer
|
|
// to name the licence in LICENSE, MIT, and not WTFPL.
|
|
func TestFooterLicense(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
license = "MIT"
|
|
wrongLicense = "WTFPL"
|
|
)
|
|
|
|
pages := map[string]*template.Template{
|
|
"index": templates.IndexTemplate(),
|
|
"status": templates.StatusTemplate(),
|
|
}
|
|
|
|
for name, tmpl := range pages {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, footer, found := strings.Cut(renderPage(t, tmpl), "<footer")
|
|
if !found {
|
|
t.Fatal("the page has no footer")
|
|
}
|
|
|
|
footer, _, _ = strings.Cut(footer, "</footer>")
|
|
|
|
if !strings.Contains(footer, license) {
|
|
t.Errorf("footer does not name %s: %q", license, footer)
|
|
}
|
|
|
|
if strings.Contains(footer, wrongLicense) {
|
|
t.Errorf("footer names %s: %q", wrongLicense, footer)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFreshnessIndicator renders the home and status pages and expects each to
|
|
// have, right after the status element, the element where the page's script
|
|
// shows its spinner or "stale".
|
|
func TestFreshnessIndicator(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pages := map[string]struct {
|
|
tmpl *template.Template
|
|
status string
|
|
indicator string
|
|
}{
|
|
"index": {
|
|
tmpl: templates.IndexTemplate(),
|
|
status: `<span id="stat-status">-</span>`,
|
|
indicator: `<span id="freshness"></span>`,
|
|
},
|
|
"status": {
|
|
tmpl: templates.StatusTemplate(),
|
|
status: `<span class="metric-value" id="connected">-</span>`,
|
|
indicator: `<span class="metric-value" id="freshness"></span>`,
|
|
},
|
|
}
|
|
|
|
for name, page := range pages {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, afterStatus, found := strings.Cut(renderPage(t, page.tmpl), page.status)
|
|
if !found {
|
|
t.Fatalf("the page has no status element %s", page.status)
|
|
}
|
|
|
|
if !strings.HasPrefix(strings.TrimSpace(afterStatus), page.indicator) {
|
|
t.Errorf("the status element is not followed by %s", page.indicator)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestASDetailPrefixListsFold renders the AS page with one IPv4 and one IPv6
|
|
// prefix. It expects each prefix list inside its own <details> element that
|
|
// starts open and whose <summary> holds the list's heading, so that a click on
|
|
// the heading folds the list.
|
|
func TestASDetailPrefixListsFold(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
asn = 64500
|
|
prefixV4 = "192.0.2.0/24"
|
|
prefixV6 = "2001:db8::/32"
|
|
)
|
|
|
|
ipv4 := []server.LiveRoute{{Prefix: prefixV4}}
|
|
ipv6 := []server.LiveRoute{{Prefix: prefixV6}}
|
|
|
|
// The fields the AS page handler gives the template.
|
|
data := struct {
|
|
ASN *database.ASN
|
|
IPv4Prefixes []server.LiveRoute
|
|
IPv6Prefixes []server.LiveRoute
|
|
TotalCount int
|
|
IPv4Count int
|
|
IPv6Count int
|
|
Peers []database.ASPeer
|
|
PeerCount int
|
|
CSPNonce string
|
|
}{
|
|
ASN: &database.ASN{ASN: asn},
|
|
IPv4Prefixes: ipv4,
|
|
IPv6Prefixes: ipv6,
|
|
TotalCount: len(ipv4) + len(ipv6),
|
|
IPv4Count: len(ipv4),
|
|
IPv6Count: len(ipv6),
|
|
}
|
|
|
|
var rendered bytes.Buffer
|
|
|
|
err := templates.ASDetailTemplate().Execute(&rendered, data)
|
|
if err != nil {
|
|
t.Fatalf("rendering the page: %v", err)
|
|
}
|
|
|
|
page := rendered.String()
|
|
|
|
lists := []struct{ heading, prefix string }{
|
|
{"IPv4 Prefixes", prefixV4},
|
|
{"IPv6 Prefixes", prefixV6},
|
|
}
|
|
|
|
for _, list := range lists {
|
|
before, _, found := strings.Cut(page, list.prefix)
|
|
if !found {
|
|
t.Fatalf("the page does not list %s", list.prefix)
|
|
}
|
|
|
|
// The list's section starts at the last <details before its prefix.
|
|
start := strings.LastIndex(before, "<details")
|
|
if start < 0 || strings.Contains(before[start:], "</details>") {
|
|
t.Errorf("%s: the list is not inside a <details> element", list.heading)
|
|
|
|
continue
|
|
}
|
|
|
|
tag, section, _ := strings.Cut(before[start:], ">")
|
|
if !strings.Contains(tag, " open") {
|
|
t.Errorf("%s: the <details> element does not start open: %s>",
|
|
list.heading, tag)
|
|
}
|
|
|
|
_, summary, _ := strings.Cut(section, "<summary")
|
|
summary, _, _ = strings.Cut(summary, "</summary>")
|
|
|
|
if !strings.Contains(summary, list.heading) {
|
|
t.Errorf("%s: the <summary> does not hold the heading", list.heading)
|
|
}
|
|
}
|
|
}
|