check / check (push) Canceled after 1m16s
Each result gains `geo`. One request to geojs carries every address, 1 second in all. On a timeout, an error or an answer with no country, city or coordinates, the lookup reads DB-IP City Lite, downloaded monthly into the state directory. geojs answers stay in memory (250,000, least recently used dropped) and in a new `cache.db` table for 7 days. Judgement call: a reserved address goes to neither source, and its `geo` is null. Judgement call: `updated` for DB-IP is when the copy was downloaded. Deviation: the cache is SQLite, by sneak's words. Model: opus-5-5
204 lines
5.2 KiB
Go
204 lines
5.2 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHomePageCreditsDBIP expects the home page's footer to carry the credit
|
|
// to DB-IP.
|
|
func TestHomePageCreditsDBIP(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const attribution = `IP Geolocation by <a href="https://db-ip.com">DB-IP</a>`
|
|
|
|
_, footer, found := strings.Cut(renderPage(t, templates.IndexTemplate()), "<footer")
|
|
if !found {
|
|
t.Fatal("the page has no footer")
|
|
}
|
|
|
|
if !strings.Contains(footer, attribution) {
|
|
t.Errorf("footer does not credit DB-IP: %q", 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)
|
|
}
|
|
}
|
|
}
|