check / check (push) Canceled after 0s
The home page gets cards for the newest and oldest kept route, the newest and oldest updated AS, the AS with the most prefixes, the ASes with a single prefix and fifteen well-known networks. Two more cards count the bogon announcements and the prefixes with several origin ASes. They link to the new pages /bogons and /multiple-origins, which list up to 1,000 entries each. The new package internal/bogon tells reserved prefixes and AS numbers, from the IANA special-purpose and address space registries. Table.Overview walks the table and lets the read lock go every 10,000 prefixes. The server works the cards out once a minute and serves that copy. A new index on asns(last_seen) finds the newest and oldest updated AS. Model: opus-5-5
187 lines
4.8 KiB
Go
187 lines
4.8 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 and the routing table's cards, which the home page also needs. The
|
|
// cards are left out, as before the server first works them out.
|
|
func renderPage(t *testing.T, tmpl *template.Template) string {
|
|
t.Helper()
|
|
|
|
var page bytes.Buffer
|
|
|
|
err := tmpl.Execute(&page, struct {
|
|
CSPNonce, ClientIP string
|
|
Cards any
|
|
}{})
|
|
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)
|
|
}
|
|
}
|
|
}
|