package web import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/google/uuid" "github.com/gramanas/blogspace/internal/config" "github.com/gramanas/blogspace/internal/store" ) func TestHostname(t *testing.T) { cases := map[string]string{"Example.com:8080": "example.com", "a.example.com": "a.example.com", "example.com.": "example.com", "[::1]:80": "::1"} for in, want := range cases { if got := hostname(in); got != want { t.Errorf("hostname(%q) = %q, want %q", in, got, want) } } } // Host routing that does not need the database: unknown hosts and reserved/nested subdomains 404 before any lookup. func TestHostRoutingWithoutDB(t *testing.T) { cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20} s := NewServer(cfg, nil) for _, host := range []string{"evil.com", "a.b.example.com", "www.blog.example.com", "static.example.com", "notexample.com"} { rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/", nil) req.Host = host s.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("host %q: got %d, want 404", host, rec.Code) } } // root domain (and www) reach the management mux: /webadmin renders without DB access for _, host := range []string{"example.com", "www.example.com", "example.com:8080"} { rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/webadmin", nil) req.Host = host s.ServeHTTP(rec, req) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Log in") { t.Errorf("host %q /webadmin: got %d", host, rec.Code) } } } // On the root domain the literal management routes must win over the blog's /{page} wildcards. func TestRootRoutePrecedence(t *testing.T) { cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20} s := NewServer(cfg, nil) for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/b/alice/announcements": 303, "/b/alice/layout": 303, "/account/password": 303} { rec := httptest.NewRecorder() req := httptest.NewRequest("GET", path, nil) req.Host = "example.com" s.ServeHTTP(rec, req) if rec.Code != want { t.Errorf("%s: got %d, want %d", path, rec.Code, want) } } for _, slug := range []string{"webadmin", "admin", "b", "media", "feed.xml", "favicon.ico"} { if !reservedPageSlugs[slug] { t.Errorf("page slug %q should be reserved", slug) } } } func TestThemeNormalizeAndCSS(t *testing.T) { th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","logo":"../etc","link_hover":"blue","heading_font":"wingdings","favicon":"x","logo_size":"huge","left_width":5,"right_width":90}`)) if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.Logo != "" || th.LinkHover != "" || th.HeadingFont != "same" || th.Favicon != "" || th.LogoSize != "medium" { t.Errorf("normalize: %+v", th) } if th.LeftWidth != 15 || th.RightWidth != 40 { t.Errorf("width clamp: %+v", th) } // Themes saved before the new options existed keep today's look: every area on, columns a quarter each. if !th.ShowDates || th.LinkUnderline != "always" || th.ContentStyle != "card" || th.DateFormat != "long" { t.Errorf("old theme defaults: %+v", th) } if !th.HeaderOn || !th.LeftOn || !th.RightOn || !th.FooterOn || th.LeftWidth != 15 { t.Errorf("old theme layout defaults: %+v", th) } f := url.Values{"bg_color": {"#ABCDEF"}, "bg_image": {"not-a-uuid"}, "logo_size": {"large"}, "link_hover": {"#ff0000"}, "nav_hover": {"#00ff00"}, "nav_hover_custom": {"on"}, "nav_style": {"uppercase"}, "nav_align": {"center"}, "link_underline": {"hover"}, "heading_font": {"serif"}, "content_style": {"flat"}, "content_padding": {"roomy"}, "date_format": {"iso"}} th = ThemeFromForm(DefaultTheme(), f) if th.BgColor != "#abcdef" || th.BgImage != "" || th.LogoSize != "large" { t.Errorf("from form: %+v", th) } if th.LinkHover != "" || th.NavHover != "#00ff00" || th.ShowDates || th.DateFormat != "iso" { t.Errorf("hover checkbox / unticked checkboxes: %+v", th) } css := th.CSS() for _, want := range []string{"background-color:#abcdef", "text-transform:uppercase", ".site-nav a:hover { color:#00ff00", "a:hover { text-decoration:underline", "h1, h2, h3, h4, h5, h6 { font-family:Georgia", "text-align:center", "border-radius:0", "padding:2.2em 3em", "@media (max-width: 700px)", ".logo { max-height:140px"} { if !strings.Contains(css, want) { t.Errorf("css missing %q:\n%s", want, css) } } if strings.Contains(css, "display:none") { t.Errorf("css: %s", css) } // Default padding is left to blog.css so its phone override keeps working. if css := DefaultTheme().CSS(); strings.Contains(css, "padding:") || strings.Contains(css, "a:hover") { t.Errorf("default css should not set padding or hover: %s", css) } } func TestLayoutTheme(t *testing.T) { th := LayoutFromForm(DefaultTheme(), url.Values{"left_width": {"40"}, "right_width": {"40"}, "keep_columns": {"on"}}) if th.LeftWidth != 40 || th.RightWidth != 30 || th.MainWidth() != 30 || !th.KeepColumns { t.Errorf("the right column should give way: %+v", th) } th = LayoutFromForm(th, url.Values{"left_width": {"junk"}, "right_width": {"20"}}) if th.LeftWidth != 15 || th.RightWidth != 20 || th.KeepColumns { t.Errorf("junk clamps to the minimum: %+v", th) } css := th.CSS() for _, want := range []string{"@media (min-width: 701px)", ".col-left { width:15%", ".col-right { width:20%", "body.has-left.has-right .col-main { width:65%", "body.has-left:not(.has-right) .col-main { width:85%", "body.has-right:not(.has-left) .col-main { width:80%"} { if !strings.Contains(css, want) { t.Errorf("css missing %q:\n%s", want, css) } } th.SetAreaOn("left", false) th.SetAreaOn("bogus", false) if th.AreaOn("left") || !th.AreaOn("right") || !th.AreaOn("above") { t.Errorf("area switches: %+v", th) } } func TestBuildLayout(t *testing.T) { mods := []store.Module{{ID: 1, Area: "header", Kind: "title"}, {ID: 2, Area: "left", Kind: "recent", Count: 3}, {ID: 3, Area: "right", Kind: "recent", Count: 8}, {ID: 4, Area: "right", Kind: "archive"}, {ID: 5, Area: "footer", Kind: "rss"}, {ID: 6, Area: "above", Kind: "html"}} th := DefaultTheme() l := buildLayout(th, mods) if !l.HasLeft || !l.HasRight || len(l.Header) != 1 || len(l.Footer) != 1 || len(l.Above) != 1 || l.MaxRecent() != 8 || !l.NeedsArchive() { t.Errorf("layout: %+v", l) } th.RightOn, th.FooterOn = false, false l = buildLayout(th, mods) if l.HasRight || len(l.Footer) != 0 || l.MaxRecent() != 3 || l.NeedsArchive() { t.Errorf("switched-off areas should drop their modules: %+v", l) } if buildLayout(th, nil).HasLeft { t.Error("an empty column is not shown") } th.KeepColumns = true if k := buildLayout(th, nil); !k.HasLeft || !k.HasRight || len(k.Right) != 0 { t.Errorf("keep_columns reserves empty and hidden columns: %+v", k) } th.KeepColumns = false // announcements go to their column, or to the main one when that column is not laid out secs := []store.Section{{ID: 1, Placement: "main-top"}, {ID: 2, Placement: "right-bottom"}, {ID: 3, Placement: "left-top"}, {ID: 4, Placement: "left-bottom"}} n := placeNotices(secs, l) // l: left laid out, right not if len(n["main-top"]) != 1 || len(n["main-bottom"]) != 1 || n["main-bottom"][0].ID != 2 || len(n["left-top"]) != 1 || len(n["left-bottom"]) != 1 || len(n["right-bottom"]) != 0 { t.Errorf("left column only: %v", n) } n = placeNotices(secs, Layout{}) if len(n["main-top"]) != 2 || n["main-top"][1].ID != 3 || len(n["main-bottom"]) != 2 || len(n) != 2 { // no columns: everything in main, order kept t.Errorf("no columns: %v", n) } } func TestModuleKinds(t *testing.T) { for area, kinds := range moduleKinds { if _, ok := areaNames[area]; !ok { t.Errorf("area %q has no name", area) } for _, k := range kinds { if moduleNames[k] == "" { t.Errorf("kind %q has no name", k) } } } if !allowedKind("header", "menu") || allowedKind("header", "archive") || allowedKind("above", "menu") || allowedKind("attic", "html") { t.Error("allowedKind") } if !moduleHasSettings("html") || moduleHasSettings("rss") { t.Error("moduleHasSettings") } if got := moduleSummary(store.Module{Kind: "recent", Count: 5, Title: "Fresh"}); got != "5 posts, heading “Fresh”" { t.Errorf("recent summary: %q", got) } if got := moduleSummary(store.Module{Kind: "html", Body: "

hi\n there

"}); got != "

hi there

" { t.Errorf("html summary: %q", got) } if got := moduleSummary(store.Module{Kind: "html", Body: strings.Repeat("x", 100)}); len([]rune(got)) != 58 || !strings.HasSuffix(got, "…") { t.Errorf("long html summary: %q", got) } if got := moduleSummary(store.Module{Kind: "menu"}); got != "" { t.Errorf("menu summary: %q", got) } } func TestValidLinkURL(t *testing.T) { for _, ok := range []string{"https://example.org", "http://example.org/a?b=c", "mailto:me@example.org", "/about", "/news/hello"} { if !validLinkURL(ok) { t.Errorf("%q should be accepted", ok) } } for _, bad := range []string{"", "javascript:alert(1)", "//evil.org", "ftp://x", "https://", "example.org", "https://a b", "\">