package web import ( "encoding/json" "io/fs" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "regexp" "strconv" "strings" "testing" "time" "github.com/google/uuid" "github.com/gramanas/blogspace/internal/config" "github.com/gramanas/blogspace/internal/i18n" "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. // "blog_" + subdomain must fit a 63-char Postgres database name. func TestSubdomainLength(t *testing.T) { if !subdomainRe.MatchString(strings.Repeat("a", 58)) || subdomainRe.MatchString(strings.Repeat("a", 59)) { t.Error("subdomains must be at most 58 chars") } } 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, "/b/alice/media/6ba7b810-9dad-11d1-80b4-00c04fd430c8": 303, "/b/alice/files": 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", "tag", "search"} { 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) } if th.PostsPerPage != 10 || th.TitleSize != "normal" || th.FooterAlign != "center" { t.Errorf("old theme content 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) { // The layout switches ride in the design form: the areas are checkboxes, so an unticked one is off. th := ThemeFromForm(DefaultTheme(), url.Values{"left_width": {"40"}, "right_width": {"40"}, "keep_columns": {"on"}, "header_on": {"on"}, "left_on": {"on"}, "right_on": {"on"}, "posts_per_page": {"20"}, "title_size": {"large"}, "footer_align": {"left"}}) if th.LeftWidth != 40 || th.RightWidth != 30 || th.MainWidth() != 30 || !th.KeepColumns { t.Errorf("the right column should give way: %+v", th) } if th.FooterOn || !th.HeaderOn || th.PostsPerPage != 20 || th.TitleSize != "large" || th.FooterAlign != "left" { t.Errorf("switches and the content options: %+v", th) } if css := th.CSS(); !strings.Contains(css, ".site-title { font-size:2.6em") || !strings.Contains(css, "text-align:left") { t.Errorf("css: %s", css) } th = ThemeFromForm(th, url.Values{"left_width": {"junk"}, "right_width": {"20"}, "posts_per_page": {"7"}}) if th.LeftWidth != 15 || th.RightWidth != 20 || th.KeepColumns || th.PostsPerPage != 10 { 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.LeftOn = false if th.AreaOn("left") || !th.AreaOn("above") || !th.AreaOn("bogus") { t.Errorf("area switches: %+v", th) } } // The design form sends every module as indexed fields; pos orders them and // add. appends. The index only tells rows apart. func TestParseModulesForm(t *testing.T) { f := url.Values{ "mod.0.id": {"1"}, "mod.0.area": {"header"}, "mod.0.kind": {"title"}, "mod.0.pos": {"1"}, "mod.1.id": {"2"}, "mod.1.area": {"header"}, "mod.1.kind": {"menu"}, "mod.1.pos": {"0"}, "mod.2.id": {"3"}, "mod.2.area": {"left"}, "mod.2.kind": {"recent"}, "mod.2.pos": {"0"}, "mod.2.title": {" New "}, "mod.2.count": {"99"}, "mod.3.id": {"4"}, "mod.3.area": {"footer"}, "mod.3.kind": {"rss"}, "mod.3.pos": {"0"}, "mod.3.del": {"on"}, "mod.7.id": {"0"}, "mod.7.area": {"below"}, "mod.7.kind": {"html"}, "mod.7.body": {"hi\r\n"}, "add.left": {"tags"}, "add.footer": {""}, } mods, err := parseModules(f, "en", "el") if err != nil { t.Fatal(err) } var got []string for _, m := range mods { got = append(got, m.Area+"/"+m.Kind) } if want := "header/menu header/title left/recent left/tags below/html"; strings.Join(got, " ") != want { t.Errorf("order: %v, want %s", got, want) } if mods[2].Title != "New" || mods[2].Count != maxRecentCount || mods[2].ID != 3 || mods[3].ID != 0 || mods[3].Title != "Ετικέτες" || mods[3].Count != 10 || mods[4].Body != "hi\n" { t.Errorf("fields: %+v", mods) } if mods, _ := parseModules(url.Values{"mod.0.area": {"header"}, "mod.0.kind": {"html"}, "add.header": {"rss"}}, "en", "en"); len(mods) != 0 { t.Errorf("kinds an area does not take are dropped: %+v", mods) } // Errors still come with every row, so the form can be shown again as sent. if mods, err := parseModules(url.Values{"mod.0.area": {"above"}, "mod.0.kind": {"html"}, "add.above": {"html"}}, "en", "en"); err == nil || len(mods) != 2 { t.Errorf("two modules above the posts should be refused: %v %+v", err, mods) } if mods, err := parseModules(url.Values{"mod.0.area": {"footer"}, "mod.0.kind": {"text"}, "mod.0.body": {strings.Repeat("x", maxModuleBytes+1)}}, "en", "en"); err == nil || len(mods) != 1 { t.Errorf("a long body should be refused: %v %+v", err, mods) } } func TestParseMenuForm(t *testing.T) { pages := []store.Page{{ID: 1, Title: "Home", Slug: "home", IsHome: true}, {ID: 2, Title: "About", Slug: "about"}} f := url.Values{ "menu.0.id": {"10"}, "menu.0.page": {"2"}, "menu.0.pos": {"1"}, "menu.1.id": {"11"}, "menu.1.label": {"Photos"}, "menu.1.url": {"example.org/pics"}, "menu.1.pos": {"0"}, "menu.2.id": {"12"}, "menu.2.page": {"9"}, "menu.2.pos": {"2"}, // deleted meanwhile "menu.3.id": {"13"}, "menu.3.page": {"2"}, "menu.3.pos": {"3"}, // listed twice "menu_add_page": {"1"}, "menu_add_label": {"Mail"}, "menu_add_url": {"mailto:me@example.org"}, } menu, err := parseMenu(f, "en", pages) if err != nil { t.Fatal(err) } if len(menu) != 4 || menu[0].ID != 11 || menu[0].URL != "https://example.org/pics" || menu[1].ID != 10 || menu[1].PageTitle != "About" || menu[2].ID != 0 || !menu[2].IsHome || menu[3].URL != "mailto:me@example.org" || menu[3].Label != "Mail" { t.Errorf("menu: %+v", menu) } if menu, err := parseMenu(url.Values{"menu.0.label": {"x"}, "menu.0.url": {"not a url"}}, "en", pages); err == nil || len(menu) != 1 { t.Errorf("a bad address should be refused, and kept for fixing: %v %+v", err, menu) } if menu, err := parseMenu(url.Values{"menu_add_label": {""}, "menu_add_url": {"https://example.org"}}, "en", pages); err == nil || len(menu) != 1 { t.Errorf("a link without text should be refused: %v %+v", err, menu) } } 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"}, {ID: 7, Area: "right", Kind: "tagcloud"}} 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() || !l.NeedsTags() { 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() || l.NeedsTags() { 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(store.Module{Kind: "html"}) || moduleHasSettings(store.Module{Kind: "rss"}) || !moduleHasSettings(store.Module{Kind: "search", Area: "left"}) || moduleHasSettings(store.Module{Kind: "search", Area: "header"}) { t.Error("moduleHasSettings") } if got := moduleSummary("en", store.Module{Kind: "recent", Count: 5, Title: "Fresh"}); got != "5 posts, heading “Fresh”" { t.Errorf("recent summary: %q", got) } if got := moduleSummary("en", store.Module{Kind: "html", Body: "

hi\n there

"}); got != "

hi there

" { t.Errorf("html summary: %q", got) } if got := moduleSummary("en", 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("en", store.Module{Kind: "menu"}); got != "" { t.Errorf("menu summary: %q", got) } if got := moduleSummary("en", store.Module{Kind: "tags", Count: 8}); got != "8 tags" { t.Errorf("tags summary: %q", got) } if got := moduleSummary("en", store.Module{Kind: "tags"}); got != "all tags" { t.Errorf("all-tags summary: %q", got) } } func TestParseTags(t *testing.T) { tags, ok := parseTags([]string{" Go ", "go", "GO", "", "tomatoes plants", "!!!", "Ντομάτες"}) if !ok || len(tags) != 3 { t.Fatalf("parseTags: ok=%v %+v", ok, tags) } for i, want := range []store.Tag{{Name: "Go", Slug: "go"}, {Name: "tomatoes plants", Slug: "tomatoes-plants"}, {Name: "Ντομάτες", Slug: "ntomates"}} { if tags[i] != want { t.Errorf("tag %d = %+v, want %+v", i, tags[i], want) } } if tags, ok := parseTags(nil); !ok || len(tags) != 0 { t.Errorf("no tags: ok=%v %+v", ok, tags) } if _, ok := parseTags([]string{strings.Repeat("α", 41)}); ok { t.Error("41 runes should be too long") } if _, ok := parseTags([]string{strings.Repeat("α", 40)}); !ok { t.Error("40 runes are fine") } many := make([]string, 21) for i := range many { many[i] = "t" + strconv.Itoa(i) } if _, ok := parseTags(many); ok { t.Error("21 tags should be too many") } if got := tagsText(tags); got != "Go, tomatoes plants, Ντομάτες" { t.Errorf("tagsText: %q", got) } } func TestCloudSizes(t *testing.T) { if got := cloudSizes(nil); len(got) != 0 { t.Errorf("empty: %+v", got) } same := cloudSizes([]store.TagCount{{Name: "a", Count: 1}, {Name: "b", Count: 1}}) if same[0].Size != 3 || same[1].Size != 3 { t.Errorf("equal counts get the middle size: %+v", same) } spread := cloudSizes([]store.TagCount{{Name: "a", Count: 1}, {Name: "b", Count: 5}, {Name: "c", Count: 9}}) if spread[0].Size != 1 || spread[1].Size != 3 || spread[2].Size != 5 || spread[0].Name != "a" { t.Errorf("spread: %+v", spread) } top := topTags([]store.TagCount{{Name: "a", Count: 1}, {Name: "b", Count: 5}, {Name: "c", Count: 5}}) if top[0].Name != "b" || top[1].Name != "c" || top[2].Name != "a" { t.Errorf("topTags: %+v", top) } } 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", "\">" if got := renderBody(store.FormatHTML, raw); got != raw { t.Errorf("html must pass through untouched: %q", got) } if got := renderBody(store.FormatMarkdown, raw); !strings.Contains(got, "x") || strings.Contains(got, "")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() s.handlePreview(rec, req) if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Errorf("content type %q", ct) } var out map[string]string if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatal(err) } if h := out["html"]; !strings.Contains(h, "x") || strings.Contains(h, "