orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "bytes"
5 "context"
6 "database/sql"
7 "encoding/json"
8 "io/fs"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "path/filepath"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/google/uuid"
18 "status.bythewood.me/web"
19)
20
21// fullPageData populates every field, so a render hits every branch that reads
22// a value and not only the nil paths.
23func fullPageData(t *testing.T) PageData {
24 t.Helper()
25
26 pct := 99.4
27 avg := int64(87)
28 dur := int64(1234)
29 pages := int64(42)
30 now := time.Now()
31 score := 0.42
32
33 return PageData{
34 Title: "Example",
35 Description: "A description.",
36 Path: "/",
37 Canonical: baseURL + "/",
38 Staging: true,
39 Authenticated: true,
40 Year: now.Year(),
41 BaseURL: baseURL,
42 SourceURL: sourceURL,
43 SiteName: siteName,
44 AuthorName: authorName,
45 Script: "/static/base.js",
46 Styles: []string{"/static/base.css"},
47 PageScript: "/static/pages.js",
48 PageStyles: []string{"/static/pages.css"},
49
50 Next: "/properties",
51 Error: "Invalid password.",
52
53 TotalChecks: 130042,
54 TotalProperties: 3,
55 FirstCheckAt: "Jun 5, 2022",
56 Query: "example",
57 GeneratedAt: "2026-08-26 21:00 UTC",
58
59 Property: &PropertyView{
60 ID: uuid.New().String(),
61 URL: "https://example.com",
62 Name: "example.com",
63 IsPublic: true,
64 IsProtected: false,
65 CurrentStatus: 200,
66 AvgResponseTime: 142,
67 RecentUptimePct: &pct,
68 RecentTickStream: []string{"up", "up", "down", "up"},
69 TotalChecks: 1440,
70
71 CrawlState: "idle",
72 LastCrawlSuccessAt: &now,
73 LastCrawlDurationMS: &dur,
74 LastCrawlPagesCount: &pages,
75 NextRunAtCrawler: &now,
76
77 LighthouseState: "idle",
78 LighthouseScores: &Scores{
79 Performance: 98, Accessibility: 100, BestPractices: 96, SEO: 91,
80 },
81 LighthouseDetails: &Details{
82 Metrics: []Metric{{
83 ID: "largest-contentful-paint", Acronym: "LCP",
84 Title: "Largest Contentful Paint", DisplayValue: "1.2 s",
85 Score: &score, Weight: 25,
86 }},
87 Opportunities: []Opportunity{{
88 ID: "unused-css-rules", Title: "Reduce unused CSS",
89 DisplayValue: "Potential savings of 12 KiB", SavingsMS: 1350,
90 }},
91 },
92 LastLighthouseSuccessAt: &now,
93 LastLighthouseDurationMS: &dur,
94 NextLighthouseRunAt: &now,
95 AvgLighthouseScore: &avg,
96
97 AlertState: "up",
98 CreatedAt: now,
99 UpdatedAt: now,
100
101 IsHTTPS: true,
102 HasMIMEType: true,
103 HasContentSniffProtection: true,
104 HasClickjackProtection: false,
105 HidesServerVersion: true,
106 HasHSTS: true,
107 HasHSTSPreload: false,
108 HasSecurityIssue: true,
109
110 CrawlerInsights: []Insight{
111 {URL: "https://example.com/a", Issue: "Page has no title", Type: typeSEO, Severity: sevError},
112 },
113 },
114
115 InsightGroups: []InsightGroup{{
116 Type: typeSEO,
117 Items: []Insight{
118 {URL: "https://example.com/a", Issue: "Page has no title", Type: typeSEO, Severity: sevError},
119 {URL: "https://example.com/b", Issue: "Thin content (12 words)", Type: typeSEO, Severity: sevWarn, Item: "x"},
120 },
121 }},
122 ResponseTimes: []ResponseTimePoint{
123 {Label: now.Format(time.RFC3339), Total: 142, DNS: &avg, TCP: &avg, TLS: &avg, TTFB: &avg},
124 {Label: now.Format(time.RFC3339), Total: 138},
125 },
126 StatusCodes: []LabelCount{{Label: 200, Count: 1400}, {Label: 526, Count: 40}},
127 UptimeSlices: []LabelPercent{{Label: "Uptime", Count: 97.2}, {Label: "Downtime", Count: 2.8}},
128 }
129}
130
131// emptyPageData is the nil-everywhere state a template is most likely to break
132// on.
133func emptyPageData() PageData {
134 return PageData{
135 Title: "Example",
136 SiteName: siteName,
137 BaseURL: baseURL,
138 Year: 2026,
139 Property: &PropertyView{
140 ID: uuid.New().String(),
141 URL: "https://example.com",
142 Name: "example.com",
143 CurrentStatus: 200,
144 CrawlState: "idle",
145 },
146 }
147}
148
149func TestTemplatesExecute(t *testing.T) {
150 templates, err := fs.Sub(templateFS, "templates")
151 if err != nil {
152 t.Fatal(err)
153 }
154
155 pages := []string{
156 "home.html",
157 "properties.html", "property.html", "notfound.html",
158 }
159
160 renderer, err := web.NewRenderer(templates, templateFuncs,
161 []string{"base.html", "partials.html"}, pages)
162 if err != nil {
163 t.Fatalf("parsing templates: %v", err)
164 }
165
166 full := fullPageData(t)
167 full.Properties = []*PropertyView{full.Property}
168
169 empty := emptyPageData()
170 empty.Properties = []*PropertyView{empty.Property}
171
172 for _, page := range pages {
173 for name, data := range map[string]PageData{"full": full, "empty": empty} {
174 t.Run(page+"/"+name, func(t *testing.T) {
175 // Render through a recorder, since Renderer.Render panics on a
176 // template error and that panic is the failure being tested for.
177 rec := httptest.NewRecorder()
178 defer func() {
179 if r := recover(); r != nil {
180 t.Fatalf("rendering %s: %v", page, r)
181 }
182 }()
183 renderer.Render(rec, http.StatusOK, page, data)
184
185 if rec.Code != http.StatusOK {
186 t.Fatalf("rendering %s: status %d", page, rec.Code)
187 }
188 if rec.Body.Len() == 0 {
189 t.Fatalf("rendering %s produced no output", page)
190 }
191 })
192 }
193 }
194}
195
196// Covers the {{else}} arm of the range, which neither fixture above reaches.
197func TestPropertiesListWithNoProperties(t *testing.T) {
198 templates, _ := fs.Sub(templateFS, "templates")
199 renderer, err := web.NewRenderer(templates, templateFuncs,
200 []string{"base.html", "partials.html"}, []string{"properties.html"})
201 if err != nil {
202 t.Fatal(err)
203 }
204
205 rec := httptest.NewRecorder()
206 renderer.Render(rec, http.StatusOK, "properties.html", PageData{
207 Title: "Properties", SiteName: siteName, Year: 2026,
208 })
209 if !strings.Contains(rec.Body.String(), "no properties yet") {
210 t.Error("empty properties list did not render its empty state")
211 }
212}
213
214func TestReportTemplatesExecute(t *testing.T) {
215 full := fullPageData(t)
216 empty := emptyPageData()
217 empty.GeneratedAt = "2026-08-26 21:00 UTC"
218 empty.BaseURL = baseURL
219
220 for _, name := range []string{"report.typ", "report.md"} {
221 for label, data := range map[string]PageData{"full": full, "empty": empty} {
222 t.Run(name+"/"+label, func(t *testing.T) {
223 var buf bytes.Buffer
224 if err := reportTemplates.ExecuteTemplate(&buf, name, data); err != nil {
225 t.Fatalf("executing %s: %v", name, err)
226 }
227 if buf.Len() == 0 {
228 t.Fatalf("%s produced no output", name)
229 }
230 })
231 }
232 }
233}
234
235// "//" starts a Typst line comment and a URL is full of them, so an unescaped
236// property URL swallows the rest of its line in the PDF.
237func TestReportEscapesTypstComment(t *testing.T) {
238 data := fullPageData(t)
239 data.Property.URL = "https://example.com/a//b"
240
241 var buf bytes.Buffer
242 if err := reportTemplates.ExecuteTemplate(&buf, "report.typ", data); err != nil {
243 t.Fatal(err)
244 }
245 if strings.Contains(buf.String(), "https://example.com") {
246 t.Error("property URL reached the Typst source with its slashes unescaped")
247 }
248 if !strings.Contains(buf.String(), `https:\/\/example.com`) {
249 t.Error("property URL was not escaped the way typstMD escapes it")
250 }
251}
252
253// TestSchemaAcceptsLegacyDatabase writes through every column of an inherited
254// database, so schema drift fails here and not on deploy day.
255func TestSchemaAcceptsLegacyDatabase(t *testing.T) {
256 path := filepath.Join(t.TempDir(), "db.sqlite3")
257
258 legacySchema, err := os.ReadFile("testdata/legacy_schema.sql")
259 if err != nil {
260 t.Fatal(err)
261 }
262
263 seed, err := sql.Open("sqlite", path)
264 if err != nil {
265 t.Fatal(err)
266 }
267 if _, err := seed.Exec(string(legacySchema)); err != nil {
268 t.Fatalf("applying the legacy schema: %v", err)
269 }
270 // An older migration tool's bookkeeping table, which this app ignores and
271 // must not trip over. Dropping it for real would break a rollback.
272 if _, err := seed.Exec(`CREATE TABLE _sqlx_migrations (version BIGINT PRIMARY KEY);
273 INSERT INTO _sqlx_migrations VALUES (1), (2);`); err != nil {
274 t.Fatal(err)
275 }
276
277 id := uuid.New()
278 now := time.Now().UnixMilli()
279 if _, err := seed.Exec(
280 `INSERT INTO properties (id, url, is_public, is_protected, alert_state, created_at, updated_at)
281 VALUES (?, 'https://example.com', 1, 0, 'up', ?, ?)`, id[:], now, now); err != nil {
282 t.Fatal(err)
283 }
284 if _, err := seed.Exec(
285 `INSERT INTO checks (property_id, status_code, response_ms, headers, created_at)
286 VALUES (?, 200, 142, '{}', ?)`, id[:], now); err != nil {
287 t.Fatal(err)
288 }
289 seed.Close()
290
291 db, err := openDB(path)
292 if err != nil {
293 t.Fatalf("opening a legacy database: %v", err)
294 }
295 defer db.Close()
296
297 ctx := context.Background()
298
299 p, err := getProperty(ctx, db, id)
300 if err != nil {
301 t.Fatalf("reading the existing property: %v", err)
302 }
303 if p == nil {
304 t.Fatal("the existing property was not found; the cutover would lose it")
305 }
306 if p.URL != "https://example.com" || p.Name() != "example.com" {
307 t.Errorf("property read back wrong: %+v", p)
308 }
309
310 dns, tcp, tls, ttfb := int64(3), int64(11), int64(29), int64(64)
311 if _, err := db.ExecContext(ctx,
312 `INSERT INTO checks (property_id, status_code, response_ms, headers,
313 dns_ms, tcp_ms, tls_ms, ttfb_ms, created_at)
314 VALUES (?, 200, 107, '{}', ?, ?, ?, ?, ?)`,
315 id[:], dns, tcp, tls, ttfb, now); err != nil {
316 t.Fatalf("writing a check with phase timings: %v", err)
317 }
318
319 checks, err := recentChecks(ctx, db, id, 10)
320 if err != nil {
321 t.Fatal(err)
322 }
323 if len(checks) != 2 {
324 t.Fatalf("expected 2 checks, got %d", len(checks))
325 }
326 // The older row must read back with nil phases and not zeros, since the
327 // chart draws a gap for nil and a floor for 0.
328 var legacy *Check
329 for i := range checks {
330 if checks[i].ResponseMS == 142 {
331 legacy = &checks[i]
332 }
333 }
334 if legacy == nil {
335 t.Fatal("the pre-migration check row did not read back")
336 }
337 if legacy.DNSMS != nil || legacy.TCPMS != nil || legacy.TLSMS != nil || legacy.TTFBMS != nil {
338 t.Error("a pre-migration check row came back with non-nil phase timings")
339 }
340}
341
342// TestSchemaIsIdempotent covers the ordinary restart, where openDB has to be a
343// no-op against a database it already created.
344func TestSchemaIsIdempotent(t *testing.T) {
345 path := filepath.Join(t.TempDir(), "db.sqlite3")
346 for i := range 3 {
347 db, err := openDB(path)
348 if err != nil {
349 t.Fatalf("open %d: %v", i, err)
350 }
351 db.Close()
352 }
353}
354
355func TestSecurityPosture(t *testing.T) {
356 headers := func(m map[string]string) []Check {
357 encoded, _ := json.Marshal(m)
358 return []Check{{StatusCode: 200, Headers: string(encoded)}}
359 }
360
361 t.Run("a fully hardened response passes everything", func(t *testing.T) {
362 v := &PropertyView{URL: "https://example.com", CurrentStatus: 200}
363 v.applySecurityPosture(headers(map[string]string{
364 "content-type": "text/html; charset=utf-8",
365 "x-content-type-options": "nosniff",
366 "x-frame-options": "SAMEORIGIN",
367 "strict-transport-security": "max-age=31536000; includeSubDomains; preload",
368 }))
369 if v.HasSecurityIssue {
370 t.Errorf("a hardened response was reported as having an issue: %+v", v)
371 }
372 })
373
374 t.Run("an hsts max-age under a year does not count", func(t *testing.T) {
375 v := &PropertyView{URL: "https://example.com"}
376 v.applySecurityPosture(headers(map[string]string{
377 "strict-transport-security": "max-age=86400",
378 }))
379 if v.HasHSTS {
380 t.Error("a one-day max-age was accepted as HSTS")
381 }
382 })
383
384 t.Run("an absurd max-age does not overflow into a failure", func(t *testing.T) {
385 v := &PropertyView{URL: "https://example.com"}
386 v.applySecurityPosture(headers(map[string]string{
387 "strict-transport-security": "max-age=99999999999999999999999",
388 }))
389 // It does not parse as an int64, so it reports absent rather than wrapping
390 // negative. A panic here is the failure.
391 _ = v.HasHSTS
392 })
393
394 t.Run("any server header spelling defeats the version check", func(t *testing.T) {
395 for _, header := range []string{"server", "x-server", "powered-by", "x-powered-by"} {
396 v := &PropertyView{URL: "https://example.com"}
397 v.applySecurityPosture(headers(map[string]string{header: "nginx/1.2.3"}))
398 if v.HidesServerVersion {
399 t.Errorf("%s leaked the server version but the check passed", header)
400 }
401 }
402 })
403
404 t.Run("a property with no checks yet is not reported as insecure HTTPS", func(t *testing.T) {
405 v := &PropertyView{URL: "https://example.com"}
406 v.applySecurityPosture(nil)
407 if !v.IsHTTPS {
408 t.Error("an https:// URL with no checks was reported as not HTTPS")
409 }
410 })
411
412 t.Run("526 is what marks a certificate invalid", func(t *testing.T) {
413 v := &PropertyView{URL: "https://example.com", CurrentStatus: 526}
414 v.applySecurityPosture(nil)
415 if !v.InvalidCert {
416 t.Error("a 526 did not mark the certificate invalid")
417 }
418 })
419}
420
421// TestAlertStateMachine pins the asymmetry in the alerting, two strikes to go
422// down and one success to come back.
423func TestAlertStateMachine(t *testing.T) {
424 db, id := freshDB(t)
425 ctx := context.Background()
426 notifier := &Notifier{client: http.DefaultClient, base: "http://127.0.0.1:1", topic: "test"}
427
428 p, err := getProperty(ctx, db, id)
429 if err != nil {
430 t.Fatal(err)
431 }
432
433 state := func() string {
434 var s string
435 if err := db.QueryRow("SELECT alert_state FROM properties WHERE id = ?", id[:]).Scan(&s); err != nil {
436 t.Fatal(err)
437 }
438 return s
439 }
440 record := func(code int64) {
441 if _, err := db.Exec(
442 `INSERT INTO checks (property_id, status_code, response_ms, headers, created_at)
443 VALUES (?, ?, 100, '{}', ?)`, id[:], code, nowMS()); err != nil {
444 t.Fatal(err)
445 }
446 if err := advanceAlertState(ctx, db, notifier, p, code); err != nil {
447 t.Fatal(err)
448 }
449 }
450
451 if state() != "up" {
452 t.Fatalf("a new property should start up, got %q", state())
453 }
454
455 record(503)
456 if state() != "up" {
457 t.Error("one failure took the property down; it should need two")
458 }
459
460 record(503)
461 if state() != "down" {
462 t.Error("two consecutive failures did not take the property down")
463 }
464
465 record(200)
466 if state() != "up" {
467 t.Error("a single success did not bring the property back up")
468 }
469}
470
471// TestAlertStateIgnoresNonConsecutiveFailures is the flapping case, where
472// fail, recover, fail must not fire an outage.
473func TestAlertStateIgnoresNonConsecutiveFailures(t *testing.T) {
474 db, id := freshDB(t)
475 ctx := context.Background()
476 notifier := &Notifier{client: http.DefaultClient, base: "http://127.0.0.1:1", topic: "test"}
477 p, _ := getProperty(ctx, db, id)
478
479 // created_at is explicit and increasing, since the state machine orders by
480 // it and same-millisecond rows would order arbitrarily.
481 base := nowMS()
482 for i, code := range []int64{503, 200, 503} {
483 if _, err := db.Exec(
484 `INSERT INTO checks (property_id, status_code, response_ms, headers, created_at)
485 VALUES (?, ?, 100, '{}', ?)`, id[:], code, base+int64(i)); err != nil {
486 t.Fatal(err)
487 }
488 if err := advanceAlertState(ctx, db, notifier, p, code); err != nil {
489 t.Fatal(err)
490 }
491 }
492
493 var s string
494 if err := db.QueryRow("SELECT alert_state FROM properties WHERE id = ?", id[:]).Scan(&s); err != nil {
495 t.Fatal(err)
496 }
497 if s != "up" {
498 t.Errorf("a flapping site was marked down; state machine says %q", s)
499 }
500}
501
502func freshDB(t *testing.T) (*sql.DB, uuid.UUID) {
503 t.Helper()
504 db, err := openDB(filepath.Join(t.TempDir(), "db.sqlite3"))
505 if err != nil {
506 t.Fatal(err)
507 }
508 t.Cleanup(func() { db.Close() })
509
510 id, err := createProperty(context.Background(), db, "https://example.com")
511 if err != nil {
512 t.Fatal(err)
513 }
514 return db, id
515}
516
517func TestRenderAlert(t *testing.T) {
518 ctx := AlertContext{
519 ID: "abc", Name: "example.com", URL: "https://example.com",
520 CurrentStatus: 503, AvgResponseMS: 184,
521 }
522
523 down, ok := renderAlert("down", ctx)
524 if !ok {
525 t.Fatal("the down alert did not render")
526 }
527 if !strings.Contains(down.Title, "example.com") || !strings.Contains(down.Message, "503") {
528 t.Errorf("the down alert lost its detail: %+v", down)
529 }
530 if down.Click != baseURL+"/abc" {
531 t.Errorf("the down alert links to %q, which is not an absolute dashboard URL", down.Click)
532 }
533 // Read on a phone, where an outage that looks like a recovery gets missed.
534 if down.Priority == "default" {
535 t.Error("the outage alert has the same priority as a recovery")
536 }
537
538 if _, ok := renderAlert("sideways", ctx); ok {
539 t.Error("an unknown alert kind rendered instead of being refused")
540 }
541}
542
543func page(url string, html *ParsedHTML) *Page {
544 return &Page{URL: url, Status: 200, IsHTML: true, HTML: html, ContentType: "text/html"}
545}
546
547func parsed(t *testing.T, body, url string) *ParsedHTML {
548 t.Helper()
549 p, err := parseHTML([]byte(body), url)
550 if err != nil {
551 t.Fatal(err)
552 }
553 return p
554}
555
556func TestParseHTML(t *testing.T) {
557 p := parsed(t, `<!doctype html><html lang="en"><head>
558 <title> A title </title>
559 <meta name="description" content="A description.">
560 <meta property="og:title" content="OG">
561 <meta name="twitter:card" content="summary">
562 <link rel="canonical" href="/canonical">
563 <link rel="shortcut icon" href="/favicon.ico">
564 <script type="application/ld+json">{"@type":"Thing"}</script>
565 <script type="application/ld+json">{not json}</script>
566 </head><body>
567 <h1>Heading one</h1>
568 <h3>Skipped a level</h3>
569 <a href="/a">Link A</a>
570 <a href="#frag">Fragment</a>
571 <a href="mailto:[email protected]">Mail</a>
572 <a href="https://other.example/b" rel="nofollow noopener">Off site</a>
573 <img src="/img.png" alt="described">
574 <img src="/bare.png">
575 <img src="/decorative.png" alt="">
576 <form action="/submit">
577 <label for="name">Name</label>
578 <input type="text" id="name">
579 <input type="text" id="unlabeled">
580 <input type="hidden" name="csrf">
581 </form>
582 <script>var ignored = "script text";</script>
583 <style>.ignored { color: red }</style>
584 </body></html>`, "https://example.com/page")
585
586 if p.Title != "A title" {
587 t.Errorf("title whitespace was not collapsed: %q", p.Title)
588 }
589 if p.Lang != "en" {
590 t.Errorf("lang = %q", p.Lang)
591 }
592 if p.Canonical != "https://example.com/canonical" {
593 t.Errorf("canonical was not resolved against the page URL: %q", p.Canonical)
594 }
595 if p.Favicon != "https://example.com/favicon.ico" {
596 t.Errorf("favicon = %q", p.Favicon)
597 }
598 if len(p.JSONLD) != 1 || p.JSONLDBad != 1 {
599 t.Errorf("JSON-LD: %d valid, %d bad; want 1 and 1", len(p.JSONLD), p.JSONLDBad)
600 }
601
602 // Fragments, mailto and tel are not pages.
603 if len(p.Links) != 2 {
604 t.Errorf("expected 2 crawlable links, got %d: %+v", len(p.Links), p.Links)
605 }
606
607 var missing, decorative int
608 for _, img := range p.Images {
609 switch {
610 case img.Alt == nil:
611 missing++
612 case *img.Alt == "":
613 decorative++
614 }
615 }
616 if missing != 1 || decorative != 1 {
617 t.Errorf("alt handling: %d missing, %d decorative; want 1 and 1", missing, decorative)
618 }
619
620 if len(p.Forms) != 1 {
621 t.Fatalf("expected one form, got %d", len(p.Forms))
622 }
623 if len(p.Forms[0].Inputs) != 3 || len(p.Forms[0].LabelFors) != 1 {
624 t.Errorf("form parsed as %+v", p.Forms[0])
625 }
626
627 // Script text must not reach the word count, or a page with a big inline
628 // bundle passes the thin-content check on its JavaScript.
629 scripty := parsed(t,
630 `<html><body><p>one two</p><script>var lorem = "ipsum dolor sit amet";</script></body></html>`,
631 "https://example.com/")
632 if scripty.WordCount != 2 {
633 t.Errorf("word count = %d, want 2; script contents reached the visible text", scripty.WordCount)
634 }
635}
636
637// Entities must be decoded, or " " counts as a word.
638func TestParseHTMLDecodesEntities(t *testing.T) {
639 p := parsed(t, `<html><body><p>one two&three</p></body></html>`, "https://example.com/")
640 if p.WordCount != 2 {
641 t.Errorf("word count = %d, want 2; entities were not decoded", p.WordCount)
642 }
643}
644
645func TestSameSite(t *testing.T) {
646 cases := []struct {
647 url, host string
648 want bool
649 }{
650 {"https://example.com/a", "example.com", true},
651 {"https://www.example.com/a", "example.com", true},
652 {"https://example.com/a", "www.example.com", true},
653 {"https://other.example/a", "example.com", false},
654 // A subdomain is a different site with its own robots.txt and sitemap.
655 {"https://blog.example.com/a", "example.com", false},
656 {"not a url", "example.com", false},
657 }
658 for _, c := range cases {
659 if got := sameSite(c.url, c.host); got != c.want {
660 t.Errorf("sameSite(%q, %q) = %v, want %v", c.url, c.host, got, c.want)
661 }
662 }
663}
664
665func TestChecksFindTheObviousFailures(t *testing.T) {
666 good := parsed(t, `<html lang="en"><head>
667 <title>A title long enough to sit inside the recommended thirty to sixty</title>
668 <meta name="description" content="A description written to be comfortably inside the recommended seventy to one hundred and sixty characters.">
669 <meta name="viewport" content="width=device-width">
670 <link rel="canonical" href="https://example.com/good">
671 <link rel="icon" href="/f.ico">
672 <meta property="og:title" content="t"><meta property="og:description" content="d">
673 <meta property="og:image" content="i"><meta property="og:url" content="u">
674 <meta name="twitter:card" content="summary">
675 </head><body><h1>A heading of a perfectly reasonable length</h1></body></html>`,
676 "https://example.com/good")
677
678 bad := parsed(t, `<html><head><title>Short</title></head><body></body></html>`,
679 "https://example.com/bad")
680
681 result := &CrawlResult{
682 StartURL: "https://example.com/good",
683 Host: "example.com",
684 Pages: []*Page{
685 page("https://example.com/good", good),
686 page("https://example.com/bad", bad),
687 },
688 Robots: RobotsCtx{Exists: true, ReferencesSitemap: true},
689 SitemapURLs: []string{"https://example.com/good", "https://example.com/bad"},
690 Compression: "gzip",
691 }
692
693 byURL := map[string][]string{}
694 for _, i := range runChecks(result) {
695 byURL[i.URL] = append(byURL[i.URL], i.Issue)
696 }
697
698 for _, want := range []string{
699 "Page has no meta description",
700 "Page has no h1",
701 "Page has no canonical URL",
702 "HTML lang attribute missing",
703 "Viewport meta tag missing (mobile)",
704 "Favicon link missing",
705 "Twitter card meta tag missing",
706 } {
707 if !containsIssue(byURL["https://example.com/bad"], want) {
708 t.Errorf("the deficient page was not flagged for %q", want)
709 }
710 }
711
712 // And the good page must not be caught for any of them, since a check that
713 // fires on everything gets the audit ignored.
714 for _, unwanted := range []string{
715 "Page has no title",
716 "Page has no meta description",
717 "Page has no h1",
718 "Page has no canonical URL",
719 "HTML lang attribute missing",
720 "Viewport meta tag missing (mobile)",
721 "Favicon link missing",
722 "Twitter card meta tag missing",
723 "Open Graph tags missing",
724 } {
725 if containsIssue(byURL["https://example.com/good"], unwanted) {
726 t.Errorf("a well-formed page was flagged for %q", unwanted)
727 }
728 }
729}
730
731func containsIssue(issues []string, want string) bool {
732 for _, i := range issues {
733 if strings.HasPrefix(i, want) {
734 return true
735 }
736 }
737 return false
738}
739
740// TestRedirectChainCheckFires guards a condition that is easy to make
741// unreachable by recording one hop and testing for more than two.
742func TestRedirectChainCheckFires(t *testing.T) {
743 result := &CrawlResult{
744 StartURL: "https://example.com/",
745 Host: "example.com",
746 Pages: []*Page{
747 {URL: "https://example.com/final", RequestedURL: "http://example.com", Status: 200, RedirectHops: 2},
748 {URL: "https://example.com/one-hop", RequestedURL: "http://example.com/one-hop", Status: 200, RedirectHops: 1},
749 {URL: "https://example.com/direct", RequestedURL: "https://example.com/direct", Status: 200},
750 },
751 }
752
753 var flagged []string
754 for _, i := range runChecks(result) {
755 if strings.HasPrefix(i.Issue, "Redirect chain") {
756 flagged = append(flagged, i.URL)
757 }
758 }
759
760 if len(flagged) != 1 || flagged[0] != "https://example.com/final" {
761 t.Errorf("redirect chain check flagged %v; want only the two-hop URL", flagged)
762 }
763}
764
765// The same crawl must produce the same findings in the same order every time,
766// or a weekly report diffs as changed when nothing has.
767func TestDuplicateChecksAreOrdered(t *testing.T) {
768 build := func() []*Page {
769 var pages []*Page
770 for _, slug := range []string{"a", "b", "c", "d", "e", "f"} {
771 url := "https://example.com/" + slug
772 pages = append(pages, page(url, parsed(t,
773 `<html><head><title>Same title everywhere</title></head><body></body></html>`, url)))
774 }
775 return pages
776 }
777
778 var first []string
779 for run := range 8 {
780 result := &CrawlResult{StartURL: "https://example.com/", Host: "example.com", Pages: build()}
781 var order []string
782 for _, i := range runChecks(result) {
783 if i.Issue == "Duplicate title" {
784 order = append(order, i.URL)
785 }
786 }
787 if len(order) != 6 {
788 t.Fatalf("run %d: expected 6 duplicate-title findings, got %d", run, len(order))
789 }
790 if first == nil {
791 first = order
792 continue
793 }
794 for i := range order {
795 if order[i] != first[i] {
796 t.Fatalf("run %d produced a different ordering than run 0:\n %v\n %v", run, first, order)
797 }
798 }
799 }
800}
801
802func TestLighthouseScorePairsAreOrdered(t *testing.T) {
803 s := &Scores{Performance: 1, Accessibility: 2, BestPractices: 3, SEO: 4}
804 want := []string{"Performance", "Accessibility", "Best practices", "SEO"}
805 for run := range 8 {
806 for i, pair := range s.Pairs() {
807 if pair.Label != want[i] {
808 t.Fatalf("run %d: score %d is %q, want %q", run, i, pair.Label, want[i])
809 }
810 }
811 }
812}
813
814func TestParseScoresRejectsNulls(t *testing.T) {
815 report := map[string]any{"categories": map[string]any{
816 "performance": map[string]any{"score": 0.98},
817 "accessibility": map[string]any{"score": nil},
818 "best-practices": map[string]any{"score": 0.96},
819 "seo": map[string]any{"score": 0.91},
820 }}
821
822 // A null score means the category could not be evaluated, so storing zero
823 // would claim the site scored nothing.
824 if _, err := parseScores(report); err == nil {
825 t.Fatal("a null score was accepted instead of failing the audit")
826 }
827}
828
829func TestParseScoresRounds(t *testing.T) {
830 report := map[string]any{"categories": map[string]any{
831 "performance": map[string]any{"score": 0.985},
832 "accessibility": map[string]any{"score": 1.0},
833 "best-practices": map[string]any{"score": 0.0},
834 "seo": map[string]any{"score": 0.914},
835 }}
836 s, err := parseScores(report)
837 if err != nil {
838 t.Fatal(err)
839 }
840 if s.Performance != 99 || s.Accessibility != 100 || s.BestPractices != 0 || s.SEO != 91 {
841 t.Errorf("scores rounded to %+v", s)
842 }
843}
844
845func TestParseDetailsFiltersNonActionableAudits(t *testing.T) {
846 report := map[string]any{
847 "categories": map[string]any{"performance": map[string]any{"auditRefs": []any{
848 map[string]any{"id": "lcp", "group": "metrics", "weight": 25.0, "acronym": "LCP"},
849 map[string]any{"id": "passing", "weight": 0.0},
850 map[string]any{"id": "hidden-audit", "group": "hidden", "weight": 0.0},
851 map[string]any{"id": "diagnostic", "weight": 0.0},
852 map[string]any{"id": "real-opportunity", "weight": 0.0},
853 }}},
854 "audits": map[string]any{
855 "lcp": map[string]any{"score": 0.4, "title": "Largest Contentful Paint", "displayValue": "3.1 s"},
856 "passing": map[string]any{"score": 0.95, "title": "Already fine"},
857 // Kept by Lighthouse but no longer scored, so it must not show up
858 // as a win the site never earned.
859 "hidden-audit": map[string]any{"score": 0.1, "title": "Time to Interactive"},
860 // Scores badly but carries no actionable saving.
861 "diagnostic": map[string]any{"score": 0.1, "title": "Avoid forced reflow"},
862 "real-opportunity": map[string]any{
863 "score": 0.2, "title": "Reduce unused CSS",
864 "details": map[string]any{"overallSavingsMs": 1350.0},
865 },
866 },
867 }
868
869 d := parseDetails(report)
870 if d == nil {
871 t.Fatal("details did not parse")
872 }
873 if len(d.Metrics) != 1 || d.Metrics[0].Acronym != "LCP" {
874 t.Errorf("metrics = %+v", d.Metrics)
875 }
876 if len(d.Opportunities) != 1 || d.Opportunities[0].ID != "real-opportunity" {
877 t.Errorf("opportunities = %+v; only the one with a real saving should survive", d.Opportunities)
878 }
879}
880
881func TestAsciiFilename(t *testing.T) {
882 cases := map[string]string{
883 "example.com": "example.com",
884 // A non-ASCII byte cannot go into a header value unencoded.
885 "Café": "Caf_",
886 "...": "report",
887 " spaced": "spaced",
888 "": "report",
889 }
890 for in, want := range cases {
891 if got := asciiFilename(in); got != want {
892 t.Errorf("asciiFilename(%q) = %q, want %q", in, got, want)
893 }
894 }
895}
896
897func TestPropertyName(t *testing.T) {
898 cases := map[string]string{
899 "https://www.example.com/path": "example.com",
900 "https://example.com": "example.com",
901 "https://example.com:8443/": "example.com",
902 // Not a URL at all, so the whole string, rather than a blank row.
903 "nonsense": "nonsense",
904 }
905 for in, want := range cases {
906 p := &Property{URL: in}
907 if got := p.Name(); got != want {
908 t.Errorf("Name() for %q = %q, want %q", in, got, want)
909 }
910 }
911}
912
913func TestNext3MinBoundaryIsAligned(t *testing.T) {
914 next := time.UnixMilli(next3MinBoundary()).UTC()
915 if next.Second() != 0 || next.Nanosecond() != 0 || next.Minute()%3 != 0 {
916 t.Errorf("next run at %s is not on a three-minute boundary", next)
917 }
918 if !next.After(time.Now().UTC()) {
919 t.Errorf("next run at %s is not in the future", next)
920 }
921}
922
923func TestNaturalTime(t *testing.T) {
924 now := time.Now()
925 cases := []struct {
926 in *time.Time
927 want string
928 }{
929 {nil, "never"},
930 {ptrTime(now.Add(-30 * time.Second)), "just now"},
931 {ptrTime(now.Add(-2 * time.Minute)), "2 minutes ago"},
932 {ptrTime(now.Add(-1 * time.Hour)), "1 hour ago"},
933 {ptrTime(now.Add(48 * time.Hour)), "2 days from now"},
934 }
935 for _, c := range cases {
936 if got := naturalTime(c.in); got != c.want {
937 t.Errorf("naturalTime = %q, want %q", got, c.want)
938 }
939 }
940}
941
942func ptrTime(t time.Time) *time.Time { return &t }
943
944func TestIntcomma(t *testing.T) {
945 cases := map[int64]string{
946 0: "0", 999: "999", 1000: "1,000", 130042: "130,042",
947 1234567: "1,234,567", -4321: "-4,321",
948 }
949 for in, want := range cases {
950 if got := formatNum(in); got != want {
951 t.Errorf("formatNum(%d) = %q, want %q", in, got, want)
952 }
953 }
954}
955
956func TestJSONBlockCannotCloseTheScriptElement(t *testing.T) {
957 // The values here are what a crawled site can put in an error string, and
958 // they land in a <script type="application/json"> block. html/template does
959 // not escape inside that script type and template.JS turns its escaping off
960 // regardless, so encoding/json is the only thing holding the element shut.
961 in := map[string]string{"issue": `a <b> & "c" </script><img src=x onerror=alert(1)>`}
962 out, err := jsonBlock(in)
963 if err != nil {
964 t.Fatal(err)
965 }
966
967 for _, bad := range []string{"<", ">", "&"} {
968 if strings.Contains(string(out), bad) {
969 t.Errorf("%q reached the block unescaped, which can close it: %s", bad, out)
970 }
971 }
972
973 // Escaping is transparent to the consumer: JSON.parse gives the browser the
974 // original string back, so nothing downstream has to know this happened.
975 var round map[string]string
976 if err := json.Unmarshal([]byte(out), &round); err != nil {
977 t.Fatalf("output is not valid JSON: %v", err)
978 }
979 if round["issue"] != in["issue"] {
980 t.Errorf("value did not round-trip: got %q, want %q", round["issue"], in["issue"])
981 }
982}
983
984// TestStatusPayloadShape pins the field names the dashboard JavaScript reads,
985// where a rename breaks the live panel with no server error.
986func TestStatusPayloadShape(t *testing.T) {
987 pages := int64(12)
988 p := &Property{
989 CrawlState: "running", LighthouseState: "idle",
990 LastCrawlPagesCount: &pages,
991 }
992 encoded, err := json.Marshal(buildStatusPayload(p))
993 if err != nil {
994 t.Fatal(err)
995 }
996
997 var payload map[string]any
998 if err := json.Unmarshal(encoded, &payload); err != nil {
999 t.Fatal(err)
1000 }
1001 for _, key := range []string{"crawler", "lighthouse", "server_time"} {
1002 if _, ok := payload[key]; !ok {
1003 t.Errorf("status payload is missing %q", key)
1004 }
1005 }
1006 crawler := payload["crawler"].(map[string]any)
1007 for _, key := range []string{
1008 "state", "started_at", "last_attempt_at", "last_success_at", "last_error",
1009 "last_duration_ms", "pages_count", "next_run_at", "is_overdue",
1010 "insights_total", "insights_by_severity", "progress",
1011 } {
1012 if _, ok := crawler[key]; !ok {
1013 t.Errorf("crawler status is missing %q", key)
1014 }
1015 }
1016 // A running crawl must report progress, or the bar never appears.
1017 if crawler["progress"] == nil {
1018 t.Error("a running crawl reported no progress")
1019 }
1020}
1021
1022func TestCrawlProgressStaysBelowComplete(t *testing.T) {
1023 huge := int64(PageCap * 10)
1024 p := &Property{CrawlState: "running", LastCrawlPagesCount: &huge}
1025 progress := crawlProgress(p)
1026 if progress == nil || *progress > 0.9 {
1027 t.Errorf("progress = %v; a running crawl must never claim to be finished", progress)
1028 }
1029
1030 idle := &Property{CrawlState: "idle"}
1031 if crawlProgress(idle) != nil {
1032 t.Error("an idle crawl reported progress")
1033 }
1034}
1035
1036// reportTemplates is a package level Must(), so a broken report template takes
1037// the whole binary down at init rather than one route.
1038func TestReportsParse(t *testing.T) {
1039 var names []string
1040 for _, tmpl := range reportTemplates.Templates() {
1041 names = append(names, tmpl.Name())
1042 }
1043 for _, want := range []string{"report.typ", "report.md"} {
1044 found := false
1045 for _, name := range names {
1046 if name == want {
1047 found = true
1048 }
1049 }
1050 if !found {
1051 t.Errorf("%s was not parsed; got %v", want, names)
1052 }
1053 }
1054}
1055
1056// classifyCache answers one question, whether the edge served this itself. It
1057// used to infer origin health from Age, which called a healthy origin dead six
1058// times in an afternoon because Cloudflare's Edge TTL is set by a Cache Rule the
1059// origin never sees.
1060func TestClassifyCache(t *testing.T) {
1061 const cc = "public, max-age=300, stale-while-revalidate=86400, stale-if-error=604800"
1062
1063 cases := []struct {
1064 name string
1065 headers map[string]string
1066 cached bool
1067 age int64
1068 }{
1069 {
1070 name: "hit is the edge answering by itself",
1071 headers: map[string]string{"cf-cache-status": "HIT", "age": "419", "cache-control": cc},
1072 cached: true, age: 419,
1073 },
1074 {
1075 name: "served stale while revalidating is still the edge answering",
1076 headers: map[string]string{"cf-cache-status": "UPDATING", "age": "464", "cache-control": cc},
1077 cached: true, age: 464,
1078 },
1079 {
1080 // The case that used to fire. A Cache Rule holding a copy for longer
1081 // than the origin asked for is not an outage.
1082 name: "very old copy is still only a cache hit",
1083 headers: map[string]string{"cf-cache-status": "UPDATING", "age": "61854", "cache-control": cc},
1084 cached: true, age: 61854,
1085 },
1086 {
1087 name: "dynamic means the origin was reached",
1088 headers: map[string]string{"cf-cache-status": "DYNAMIC", "age": "99999", "cache-control": cc},
1089 cached: false, age: 99999,
1090 },
1091 {
1092 name: "miss means the origin was reached",
1093 headers: map[string]string{"cf-cache-status": "MISS", "cache-control": cc},
1094 cached: false, age: -1,
1095 },
1096 {
1097 name: "no cache headers at all",
1098 headers: map[string]string{},
1099 cached: false, age: -1,
1100 },
1101 }
1102
1103 for _, tc := range cases {
1104 t.Run(tc.name, func(t *testing.T) {
1105 _, age, cached := classifyCache(tc.headers)
1106 if cached != tc.cached {
1107 t.Fatalf("cached = %v, want %v", cached, tc.cached)
1108 }
1109 if got := derefAge(age); got != tc.age {
1110 t.Fatalf("age = %d, want %d", got, tc.age)
1111 }
1112 })
1113 }
1114}
1115
1116// The conclusion has to reach the database and not just the caller, since
1117// advanceAlertState re-reads status_code out of checks and would otherwise see
1118// the edge's 200 in the row that was just written.
1119func TestOriginStaleIsPersistedNotJustReturned(t *testing.T) {
1120 const cc = "public, max-age=300, stale-while-revalidate=86400, stale-if-error=604800"
1121
1122 _, _, cached := classifyCache(map[string]string{
1123 "cf-cache-status": "UPDATING",
1124 "age": "9000",
1125 "cache-control": cc,
1126 })
1127 if !cached {
1128 t.Fatal("precondition: this response should read as served from cache")
1129 }
1130
1131 // runCheck writes `effective`, so it has to write something the alert
1132 // machine treats as not-up. The number itself is arbitrary.
1133 if statusOriginStale == 200 {
1134 t.Fatal("statusOriginStale must not be 200, or every stale check reads as up")
1135 }
1136}
1137
1138// Every template the server asks for has to exist. NewRenderer resolves the
1139// list at boot rather than at build, so a page left listed after its file was
1140// deleted compiles, ships, and then crash-loops the container on startup, which
1141// is how it was found on repos.
1142func TestEveryListedTemplateParses(t *testing.T) {
1143 templates, err := fs.Sub(templateFS, "templates")
1144 if err != nil {
1145 t.Fatal(err)
1146 }
1147 if _, err := web.NewRenderer(templates, templateFuncs, layoutTemplates, pageTemplates); err != nil {
1148 t.Fatalf("the template set does not parse: %v", err)
1149 }
1150}