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, "/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","nav_position":"left-sidebar","link_hover":"blue","heading_font":"wingdings","favicon":"x"}`)) if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.NavPosition != "left-sidebar" || th.LinkHover != "" || th.HeadingFont != "same" || th.Favicon != "" { t.Errorf("normalize: %+v", th) } // Themes saved before the new options existed keep today's look. if !th.ShowDates || !th.FooterShowRSS || th.LinkUnderline != "always" || th.ContentStyle != "card" || th.DateFormat != "long" { t.Errorf("old theme defaults: %+v", th) } f := url.Values{"bg_color": {"#ABCDEF"}, "content_width": {"wide"}, "header_show_title": {"on"}, "bg_image": {"not-a-uuid"}, "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.ContentWidth != "wide" || !th.HeaderShowTitle || th.BgImage != "" { 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", "max-width:1100px", "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)"} { 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 TestSidebarWidthCSS(t *testing.T) { th := ThemeFromForm(DefaultTheme(), url.Values{"nav_position": {"left-sidebar"}, "nav_sidebar_width": {"wide"}}) css := th.CSS() if !strings.Contains(css, "width:240px") || !strings.Contains(css, "margin-left:260px") || !strings.Contains(css, "margin-left:0") { t.Errorf("sidebar css: %s", css) } // The width only matters for the sidebar layout, and "normal" is blog.css's own value. th.NavPosition = "top-bar" if strings.Contains(th.CSS(), "width:240px") { t.Error("sidebar width leaked into a horizontal menu") } th = ThemeFromForm(DefaultTheme(), url.Values{"nav_position": {"left-sidebar"}}) if strings.Contains(th.CSS(), ".nav-left-sidebar") { t.Error("normal width should emit nothing") } } func TestThemeImagePick(t *testing.T) { id := "6ba7b810-9dad-11d1-80b4-00c04fd430c8" cur := DefaultTheme() cur.BgImage, cur.Favicon = id, id th := ThemeFromForm(cur, url.Values{}) if th.BgImage != id || th.Favicon != id { t.Errorf("missing radio should keep the image: %+v", th) } th = ThemeFromForm(cur, url.Values{"bg_image": {"none"}, "favicon": {"junk"}}) if th.BgImage != "" || th.Favicon != "" { t.Errorf("none clears, junk is dropped: %+v", th) } th = ThemeFromForm(DefaultTheme(), url.Values{"header_image": {id}}) if th.HeaderImage != id { t.Errorf("radio selects: %+v", th) } } func TestPresets(t *testing.T) { if len(Presets()) < 4 { t.Fatal("expected several presets") } cur := DefaultTheme() cur.BgImage, cur.NavPosition, cur.Font, cur.LinkHover = "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "top-bar", "mono", "#123456" for _, p := range Presets() { for _, c := range []string{p.Bg, p.ContentBg, p.Text, p.Link, p.HeaderBg, p.HeaderText, p.NavBg, p.NavText, p.FooterBg, p.FooterText} { if !hexColor.MatchString(c) { t.Errorf("preset %s: bad colour %q", p.Key, c) } } th := cur.WithPreset(p) if th.BgColor != p.Bg || th.FooterColor != p.FooterText || th.LinkHover != "" { t.Errorf("preset %s not applied: %+v", p.Key, th) } if th.BgImage != cur.BgImage || th.NavPosition != "top-bar" || th.Font != "mono" { t.Errorf("preset %s touched non-colour fields: %+v", p.Key, th) } } if _, ok := PresetByKey("nope"); ok { t.Error("unknown preset should not resolve") } } func TestFormatDate(t *testing.T) { at := time.Date(2026, 9, 3, 10, 0, 0, 0, time.UTC) for format, want := range map[string]string{"long": "3 September 2026", "short": "3 Sep 2026", "iso": "2026-09-03"} { th := Theme{DateFormat: format} if got := th.FormatDate(at); got != want { t.Errorf("%s: got %q, want %q", format, got, want) } } } func TestAllTemplatesParse(t *testing.T) { tpl := newTemplates(false, funcs) for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html", "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/images.html", "dashboard/settings.html", "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", "blog/page.html", "blog/post.html", "blog/404.html"} { if _, err := tpl.get(name); err != nil { t.Errorf("%s: %v", name, err) } } } // /webadmin on a blog's own host bounces to the root login page and back to that blog's dashboard. func TestSubdomainWebadminRedirect(t *testing.T) { cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20} s := NewServer(cfg, nil) rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/webadmin", nil) req.Host = "alice.example.com" s.ServeHTTP(rec, req) if want := "http://example.com/webadmin?next=%2Fb%2Falice%2F"; rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != want { t.Errorf("got %d %q, want 303 %q", rec.Code, rec.Header().Get("Location"), want) } } func TestAppendImageMD(t *testing.T) { img := &store.Image{ID: uuid.MustParse("6ba7b810-9dad-11d1-80b4-00c04fd430c8"), Filename: "cat].png"} line := "![cat.png](/media/6ba7b810-9dad-11d1-80b4-00c04fd430c8)" if got := appendImageMD("", img); got != line+"\n" { t.Errorf("empty body: %q", got) } if got := appendImageMD("hello\n", img); got != "hello\n\n"+line+"\n" { t.Errorf("with body: %q", got) } if got := appendImageMD("hello", nil); got != "hello" { t.Errorf("nil image should not change the body: %q", got) } } func TestSplitSections(t *testing.T) { secs := []store.Section{{ID: 1, Placement: "above"}, {ID: 2, Placement: "below"}, {ID: 3, Placement: "sidebar"}} above, below, sidebar := splitSections(secs, true) if len(above) != 1 || len(below) != 1 || len(sidebar) != 1 || sidebar[0].ID != 3 { t.Errorf("with sidebar: %v %v %v", above, below, sidebar) } above, _, sidebar = splitSections(secs, false) if len(above) != 2 || len(sidebar) != 0 || above[1].ID != 3 { // sidebar falls back to above, keeping order t.Errorf("without sidebar: %v %v", above, sidebar) } sec := &store.Section{Placement: "footer", Style: "