Files

1402 lines
60 KiB
Go

package editor
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"gopkg.in/yaml.v3"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/about"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/build"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/forms"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site"
"git.coffeylabs.org/coffey-labs/hotdog-cms/starter"
)
// fakeForge plays a Gitea: OAuth authorize/token endpoints and the API.
type fakeForge struct {
t *testing.T
challenge string
srv *httptest.Server
opened []map[string]string // pull requests opened
merged []string // merge calls
refuseMerge bool // answer merges as a writer without merge rights
bobGone bool // the platform stops taking bob's token
listed int // repository listings served
expiring bool // hand out tokens that expire in a minute, with a refresh token
refreshes int // refresh-token renewals served
refuse bool // refuse renewals
requested []string // reviewers asked for
}
func newFakeForge(t *testing.T) *fakeForge {
ff := &fakeForge{t: t}
ff.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
switch {
case r.URL.Path == "/login/oauth/access_token" && r.FormValue("grant_type") == "refresh_token":
ff.refreshes++
if ff.refuse || !strings.HasPrefix(r.FormValue("refresh_token"), "rt-") || r.FormValue("client_secret") != "shh" {
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"})
return
}
json.NewEncoder(w).Encode(map[string]any{"access_token": "user-token", "refresh_token": fmt.Sprintf("rt-%d", ff.refreshes), "expires_in": 3600})
case r.URL.Path == "/login/oauth/access_token":
r.ParseForm()
sum := sha256.Sum256([]byte(r.PostForm.Get("code_verifier")))
code := r.PostForm.Get("code")
if base64.RawURLEncoding.EncodeToString(sum[:]) != ff.challenge || (code != "the-code" && code != "bob-code") || r.PostForm.Get("client_secret") != "shh" {
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"})
return
}
tok := "user-token"
if code == "bob-code" {
tok = "bob-token"
}
if ff.expiring {
json.NewEncoder(w).Encode(map[string]any{"access_token": tok, "refresh_token": "rt-0", "expires_in": 60})
return
}
json.NewEncoder(w).Encode(map[string]string{"access_token": tok})
case auth != "Bearer user-token" && (auth != "Bearer bob-token" || ff.bobGone):
w.WriteHeader(401)
case r.URL.Path == "/api/v1/user" && auth == "Bearer bob-token":
json.NewEncoder(w).Encode(map[string]string{"login": "bob", "full_name": "Bob"})
case r.URL.Path == "/api/v1/user":
json.NewEncoder(w).Encode(map[string]string{"login": "ada", "full_name": "Ada Lovelace"})
case r.URL.Path == "/api/v1/repos/owner/site":
// Ada can write; Bob can only read.
json.NewEncoder(w).Encode(map[string]any{"permissions": map[string]bool{"pull": true, "push": auth == "Bearer user-token"}})
case r.URL.Path == "/api/v1/repos/owner/secret" && auth == "Bearer bob-token":
json.NewEncoder(w).Encode(map[string]any{"permissions": map[string]bool{"pull": true, "push": true}})
case r.URL.Path == "/api/v1/repos/owner/secret":
w.WriteHeader(404)
case r.URL.Path == "/api/v1/user/repos":
ff.listed++
repo := func(full string, extra map[string]any) map[string]any {
m := map[string]any{"full_name": full, "clone_url": "https://forge.test/" + full + ".git", "default_branch": "main",
"owner": map[string]string{"login": strings.Split(full, "/")[0]}, "permissions": map[string]bool{"pull": true, "push": true}}
for k, v := range extra {
m[k] = v
}
return m
}
json.NewEncoder(w).Encode([]map[string]any{
repo("owner/site", nil), // also in editor.yaml
repo("owner/blog", nil), // a site
repo("owner/notes", nil), // no site.yaml
repo("owner/other", nil), // someone else's site.yaml
repo("owner/attic", map[string]any{"archived": true}),
repo("stranger/site2", nil), // outside the owners filter
repo("owner/mirror", map[string]any{"clone_url": "https://elsewhere.example/owner/mirror.git"}),
})
case strings.HasSuffix(r.URL.Path, "/raw/site.yaml"):
switch r.URL.Path {
case "/api/v1/repos/owner/blog/raw/site.yaml", "/api/v1/repos/owner/site/raw/site.yaml", "/api/v1/repos/stranger/site2/raw/site.yaml", "/api/v1/repos/owner/attic/raw/site.yaml", "/api/v1/repos/owner/mirror/raw/site.yaml":
w.Write([]byte("name: The Blog\nurl: https://blog.example\n"))
case "/api/v1/repos/owner/other/raw/site.yaml":
w.Write([]byte("title: Jekyll thing\ntheme: minima\n"))
default:
w.WriteHeader(404)
}
case r.URL.Path == "/api/v1/repos/owner/site/branches":
json.NewEncoder(w).Encode([]map[string]any{{"name": "main", "commit": map[string]string{"id": "a1"}}, {"name": "draft/hello", "commit": map[string]string{"id": "b2"}}})
case r.URL.Path == "/api/v1/repos/owner/site/pulls" && r.Method == "POST":
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
ff.opened = append(ff.opened, body)
w.WriteHeader(201)
json.NewEncoder(w).Encode(map[string]any{"number": 8, "html_url": "https://forge.test/owner/site/pulls/8"})
case r.URL.Path == "/api/v1/repos/owner/site/pulls":
json.NewEncoder(w).Encode([]map[string]any{{"number": 7, "title": "New hello", "html_url": "https://forge.test/owner/site/pulls/7", "head": map[string]string{"ref": "draft/hello"}, "user": map[string]string{"login": "ada"}}})
case r.URL.Path == "/api/v1/repos/owner/site/reviewers":
json.NewEncoder(w).Encode([]map[string]string{{"login": "ada", "full_name": "Ada Lovelace"}, {"login": "carol", "full_name": "Carol"}})
case r.URL.Path == "/api/v1/repos/owner/site/pulls/8/requested_reviewers" && r.Method == "POST":
var body map[string][]string
json.NewDecoder(r.Body).Decode(&body)
ff.requested = append(ff.requested, body["reviewers"]...)
w.WriteHeader(201)
w.Write([]byte("{}"))
case r.URL.Path == "/api/v1/repos/owner/site/pulls/7/merge" && r.Method == "POST":
ff.merged = append(ff.merged, "7")
w.WriteHeader(200)
case r.URL.Path == "/api/v1/repos/owner/site/pulls/8/merge" && r.Method == "POST":
if ff.refuseMerge {
w.WriteHeader(405)
w.Write([]byte(`{"message":"not allowed to merge"}`))
return
}
ff.merged = append(ff.merged, "8")
w.WriteHeader(200)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(ff.srv.Close)
return ff
}
func starterRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
err := fs.WalkDir(starter.Files, "site", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
target := filepath.Join(dir, strings.TrimPrefix(p, "site"))
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
data, _ := starter.Files.ReadFile(p)
return os.WriteFile(target, []byte(strings.ReplaceAll(string(data), "{{SITE_NAME}}", "Test")), 0o644)
})
if err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"init", "-q", "-b", "main"}, {"add", "-A"}, {"commit", "-q", "-m", "site"}} {
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "[email protected]", "-c", "commit.gpgsign=false"}, args...)...)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v %s", args, err, out)
}
}
return dir
}
func newTestServer(t *testing.T) (*Server, *fakeForge) {
s, ff, _ := newTestServerRepo(t)
return s, ff
}
func newTestServerRepo(t *testing.T) (*Server, *fakeForge, string) {
t.Helper()
return newTestServerWith(t, "")
}
// newTestServerWith adds settings to the test platform's entry in editor.yaml.
func newTestServerWith(t *testing.T, forgeExtra string) (*Server, *fakeForge, string) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
ff := newFakeForge(t)
repo := starterRepo(t)
dir := t.TempDir()
cfg := `listen: 127.0.0.1:8190
public_url: http://editor.test
cache: ` + filepath.Join(dir, "cache") + `
forges:
- { host: forge.test, kind: gitea, client_id: app, client_secret_env: TEST_SECRET, base: "` + ff.srv.URL + `"` + forgeExtra + ` }
sites:
- { name: Test site, id: site, repo: "https://forge.test/owner/site.git", preview: { domain: localhost, port: "8160", scheme: http } }
- { name: Secret site, id: secret, repo: "https://forge.test/owner/secret.git" }
`
os.WriteFile(filepath.Join(dir, "editor.yaml"), []byte(cfg), 0o644)
t.Setenv("TEST_SECRET", "shh")
t.Setenv("HOTDOG_EDITOR_SECRET", strings.Repeat("e", 40))
s, err := New(filepath.Join(dir, "editor.yaml"), os.Stderr)
if err != nil {
t.Fatal(err)
}
s.live = newLiveServer(LiveConfig{Listen: "127.0.0.1:8191"}, "http://editor.test")
s.cloneURL = func(*SiteConfig) string { return repo }
return s, ff, repo
}
func do(s *Server, method, path string, cookies []*http.Cookie, hdr map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "http://editor.test"+path, nil)
for _, c := range cookies {
req.AddCookie(c)
}
for k, v := range hdr {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
return rec
}
func cookie(rec *httptest.ResponseRecorder, name string) *http.Cookie {
for _, c := range rec.Result().Cookies() {
if c.Name == name {
return c
}
}
return nil
}
func signIn(t *testing.T, s *Server, ff *fakeForge) *http.Cookie {
t.Helper()
rec := do(s, "GET", "/auth/login?forge=forge.test&return=/sites/site", nil, nil)
if rec.Code != 302 {
t.Fatalf("login: %d", rec.Code)
}
loc, _ := url.Parse(rec.Header().Get("Location"))
if !strings.HasPrefix(loc.String(), ff.srv.URL+"/login/oauth/authorize") || loc.Query().Get("code_challenge_method") != "S256" || loc.Query().Get("scope") != "read:user write:repository" {
t.Fatalf("authorize redirect: %s", loc)
}
ff.challenge = loc.Query().Get("code_challenge")
st := cookie(rec, stateCookie)
// A wrong state is refused.
if rec := do(s, "GET", "/auth/callback?code=the-code&state=wrong", []*http.Cookie{st}, nil); rec.Code != 400 {
t.Fatalf("wrong state accepted: %d", rec.Code)
}
rec = do(s, "GET", "/auth/callback?code=the-code&state="+loc.Query().Get("state"), []*http.Cookie{st}, nil)
if rec.Code != 302 || rec.Header().Get("Location") != "/sites/site" {
t.Fatalf("callback: %d %s %s", rec.Code, rec.Header().Get("Location"), rec.Body)
}
sess := cookie(rec, accountCookie(0))
if sess == nil || !sess.HttpOnly || sess.SameSite != http.SameSiteLaxMode || strings.Contains(sess.Value, "user-token") {
t.Fatalf("session cookie wrong: %+v", sess)
}
return sess
}
func TestSignInAndBrowse(t *testing.T) {
s, ff := newTestServer(t)
if rec := do(s, "GET", "/api/sites", nil, nil); rec.Code != 401 {
t.Fatalf("signed-out API: %d", rec.Code)
}
sess := signIn(t, s, ff)
jar := []*http.Cookie{sess}
var sites []map[string]any
json.NewDecoder(do(s, "GET", "/api/sites", jar, nil).Body).Decode(&sites)
if len(sites) != 1 || sites[0]["id"] != "site" || sites[0]["canWrite"] != true {
t.Fatalf("sites: %v", sites)
}
if rec := do(s, "GET", "/api/sites/secret", jar, nil); rec.Code != 404 {
t.Errorf("a site the platform hides opened: %d", rec.Code)
}
rec := do(s, "GET", "/api/sites/site", jar, nil)
var d struct {
Pages []PageInfo
Drafts []struct{ Branch, Preview string }
Reviews []map[string]any
Build BuildResult
Preview string
}
json.NewDecoder(rec.Body).Decode(&d)
if rec.Code != 200 || len(d.Pages) < 4 || d.Build.Pages == 0 || d.Build.Failed != "" {
t.Fatalf("site: %d %+v", rec.Code, d)
}
if len(d.Drafts) != 1 || d.Drafts[0].Branch != "draft/hello" || !strings.HasPrefix(d.Drafts[0].Preview, "http://draft-hello-") {
t.Errorf("drafts: %+v", d.Drafts)
}
if len(d.Reviews) != 1 || d.Preview == "" {
t.Errorf("reviews/preview: %+v %q", d.Reviews, d.Preview)
}
var pg PageDetail
json.NewDecoder(do(s, "GET", "/api/sites/site/page?source=content/about.md", jar, nil).Body).Decode(&pg)
if pg.FrontMatter["title"] != "About" || !strings.Contains(pg.Body, "content/about.md") {
t.Errorf("page: %+v", pg)
}
for _, bad := range []string{"../site.yaml", "content/../site.yaml", "/etc/passwd", "content/about.md/../../site.yaml", "layouts/base.html"} {
if rec := do(s, "GET", "/api/sites/site/page?source="+url.QueryEscape(bad), jar, nil); rec.Code != 404 {
t.Errorf("source %q: %d", bad, rec.Code)
}
}
}
type PageDetail struct {
FrontMatter map[string]any
Body string
}
func TestSessionAndCSRF(t *testing.T) {
s, ff := newTestServer(t)
sess := signIn(t, s, ff)
tampered := *sess
tampered.Value = sess.Value[:len(sess.Value)-4] + "AAAA"
if rec := do(s, "GET", "/api/sites", []*http.Cookie{&tampered}, nil); rec.Code != 401 {
t.Errorf("tampered session accepted: %d", rec.Code)
}
// The sealed state cookie can't stand in for a session.
rec := do(s, "GET", "/auth/login?forge=forge.test", nil, nil)
st := cookie(rec, stateCookie)
st.Name = accountCookie(0)
if rec := do(s, "GET", "/api/sites", []*http.Cookie{st}, nil); rec.Code != 401 {
t.Errorf("state cookie worked as a session: %d", rec.Code)
}
if rec := do(s, "POST", "/auth/logout", []*http.Cookie{sess}, nil); rec.Code != 403 {
t.Errorf("logout without CSRF header: %d", rec.Code)
}
if rec := do(s, "POST", "/auth/logout", []*http.Cookie{sess}, map[string]string{"Origin": "https://evil.example", "X-HotDog": "1"}); rec.Code != 403 {
t.Errorf("cross-site logout: %d", rec.Code)
}
if rec := do(s, "POST", "/auth/logout", []*http.Cookie{sess}, map[string]string{"Origin": "http://editor.test", "X-HotDog": "1"}); rec.Code != 204 {
t.Errorf("logout: %d", rec.Code)
}
if rec := do(s, "GET", "/auth/login?forge=forge.test&return=//evil.example/", nil, nil); rec.Code != 302 {
t.Fatal(rec.Code)
}
if got := safeReturn("//evil.example/"); got != "/" {
t.Errorf("open redirect: %s", got)
}
h := do(s, "GET", "/", nil, nil).Header()
if !strings.Contains(h.Get("Content-Security-Policy"), "frame-ancestors 'none'") || !strings.Contains(h.Get("Content-Security-Policy"), "frame-src http://*.localhost:8160") || h.Get("X-Frame-Options") != "DENY" {
t.Errorf("headers: %v", h)
}
}
func post(s *Server, path string, cookies []*http.Cookie, body any) *httptest.ResponseRecorder {
b, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "http://editor.test"+path, strings.NewReader(string(b)))
for _, c := range cookies {
req.AddCookie(c)
}
req.Header.Set("Origin", "http://editor.test")
req.Header.Set("X-HotDog", "1")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
return rec
}
func git(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "[email protected]", "-c", "commit.gpgsign=false"}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v %s", args, err, out)
}
return strings.TrimSpace(string(out))
}
func TestWriting(t *testing.T) {
s, ff, repo := newTestServerRepo(t)
jar := []*http.Cookie{signIn(t, s, ff)}
mainTip := git(t, repo, "rev-parse", "main")
// Without the editor's own header, nothing is written.
req := httptest.NewRequest("POST", "http://editor.test/api/sites/site/save", strings.NewReader("{}"))
req.AddCookie(jar[0])
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
if rec.Code != 403 {
t.Fatalf("write without CSRF header: %d", rec.Code)
}
// Saving on the publishing branch starts a draft, as the person.
title := "About us"
rec = post(s, "/api/sites/site/save", jar, map[string]any{"source": "content/about.md", "branch": "main", "baseCommit": mainTip,
"edit": map[string]any{"set": map[string]any{"title": title, "summary": nil}, "body": "Rewritten."}})
var saved map[string]any
json.NewDecoder(rec.Body).Decode(&saved)
if rec.Code != 200 || saved["branch"] != "draft/about" || saved["created"] != true {
t.Fatalf("save: %d %v", rec.Code, saved)
}
if got := git(t, repo, "rev-parse", "main"); got != mainTip {
t.Fatal("a save reached the publishing branch")
}
file := git(t, repo, "show", "draft/about:content/about.md")
if !strings.Contains(file, "title: About us") || strings.Contains(file, "summary:") || !strings.HasSuffix(strings.TrimSpace(file), "Rewritten.") {
t.Errorf("saved file:\n%s", file)
}
if author := git(t, repo, "log", "-1", "--format=%an <%ae>", "draft/about"); author != "Ada Lovelace <[email protected]>" {
t.Errorf("author: %s", author)
}
// A save based on an old commit is refused, not merged over.
rec = post(s, "/api/sites/site/save", jar, map[string]any{"source": "content/about.md", "branch": "draft/about", "baseCommit": mainTip,
"edit": map[string]any{"body": "Clobber."}})
if rec.Code != 409 {
t.Errorf("stale save: %d", rec.Code)
}
// A new page starts its own draft.
rec = post(s, "/api/sites/site/new", jar, map[string]any{"collection": "articles", "title": "My New Post"})
var np map[string]any
json.NewDecoder(rec.Body).Decode(&np)
if rec.Code != 200 || np["source"] != "content/articles/my-new-post.md" || np["branch"] != "draft/my-new-post" {
t.Fatalf("new page: %d %v", rec.Code, np)
}
// The live preview shows unsaved text, on its own origin.
rec = post(s, "/api/sites/site/live", jar, map[string]any{"source": "content/about.md", "branch": "draft/about",
"edit": map[string]any{"set": map[string]any{"title": "Unsaved title"}, "body": "Typing…"}})
var lv map[string]string
json.NewDecoder(rec.Body).Decode(&lv)
u, err := url.Parse(lv["url"])
if rec.Code != 200 || err != nil || !strings.HasSuffix(u.Hostname(), ".localhost") || u.Path != "/about/" {
t.Fatalf("live: %d %v", rec.Code, lv)
}
lreq := httptest.NewRequest("GET", lv["url"], nil)
lreq.Host = u.Host
lrec := httptest.NewRecorder()
s.live.ServeHTTP(lrec, lreq)
if !strings.Contains(lrec.Body.String(), "Unsaved title") || lrec.Header().Get("Content-Security-Policy") != "frame-ancestors http://editor.test" {
t.Errorf("live page: %d %s", lrec.Code, lrec.Header())
}
other := httptest.NewRequest("GET", "http://deadbeef.localhost:8191/about/", nil)
orec := httptest.NewRecorder()
s.live.ServeHTTP(orec, other)
if orec.Code != 404 {
t.Errorf("unknown live token: %d", orec.Code)
}
// Review, then publish behind the checks.
rec = post(s, "/api/sites/site/review", jar, map[string]any{"branch": "draft/about", "title": "New about page", "description": "Shorter."})
if rec.Code != 200 || len(ff.opened) != 1 || ff.opened[0]["base"] != "main" || !strings.Contains(ff.opened[0]["body"], "Preview: http://draft-about-") {
t.Fatalf("review: %d %v", rec.Code, ff.opened)
}
// draft/hello (review #7) carries a check error: a forbidden string.
git(t, repo, "checkout", "-q", "-b", "draft/hello")
cfg, _ := os.ReadFile(filepath.Join(repo, "site.yaml"))
os.WriteFile(filepath.Join(repo, "site.yaml"), []byte(strings.Replace(string(cfg), " forbid: []", " forbid: ['secret\\.internal']", 1)), 0o644)
os.WriteFile(filepath.Join(repo, "content", "hello.md"), []byte("---\ntitle: Hello\n---\nOn secret.internal.\n"), 0o644)
git(t, repo, "add", "-A")
git(t, repo, "commit", "-q", "-m", "leak")
git(t, repo, "checkout", "-q", "main")
rec = post(s, "/api/sites/site/publish", jar, map[string]any{"branch": "draft/hello"})
if rec.Code != 409 || len(ff.merged) != 0 {
t.Fatalf("publish with a check error: %d, merged %v", rec.Code, ff.merged)
}
git(t, repo, "checkout", "-q", "draft/hello")
os.WriteFile(filepath.Join(repo, "content", "hello.md"), []byte("---\ntitle: Hello\n---\nFixed.\n"), 0o644)
git(t, repo, "commit", "-q", "-am", "fix")
git(t, repo, "checkout", "-q", "main")
rec = post(s, "/api/sites/site/publish", jar, map[string]any{"branch": "draft/hello"})
if rec.Code != 200 || len(ff.merged) != 1 {
t.Fatalf("publish: %d %s merged %v", rec.Code, rec.Body, ff.merged)
}
if rec := post(s, "/api/sites/site/publish", jar, map[string]any{"branch": "main"}); rec.Code != 400 {
t.Errorf("publishing the publishing branch: %d", rec.Code)
}
// A writer can push a pipeline change with plain git, but can't publish it.
git(t, repo, "checkout", "-q", "draft/hello")
os.MkdirAll(filepath.Join(repo, ".gitea", "workflows"), 0o755)
os.WriteFile(filepath.Join(repo, ".gitea", "workflows", "deploy.yml"), []byte("on: push\n"), 0o644)
git(t, repo, "add", "-A")
git(t, repo, "commit", "-q", "-m", "pipeline")
git(t, repo, "checkout", "-q", "main")
rec = post(s, "/api/sites/site/publish", jar, map[string]any{"branch": "draft/hello"})
if rec.Code != 403 || len(ff.merged) != 1 || !strings.Contains(rec.Body.String(), ".gitea/workflows/deploy.yml") {
t.Errorf("a writer published a pipeline change: %d %s merged %v", rec.Code, rec.Body, ff.merged)
}
}
func TestCompose(t *testing.T) {
orig := []byte("---\n# kept comment\ntitle: Old\ntags: [a, b]\nweight: 3\n---\nBody.\n")
got, err := compose(orig, Edit{Set: rawSet(map[string]any{"title": "New", "weight": nil, "summary": "Line one"}), Body: "New body."})
if err != nil {
t.Fatal(err)
}
want := "---\n# kept comment\ntitle: New\ntags: [a, b]\nsummary: Line one\n---\n\nNew body.\n"
if string(got) != want {
t.Errorf("compose:\n%q\nwant\n%q", got, want)
}
if _, err := compose(orig, Edit{FrontMatter: strPtr("title: [unclosed"), Body: "x"}); err == nil {
t.Error("invalid YAML accepted")
}
}
func strPtr(s string) *string { return &s }
// addAccount signs in as another user while already signed in, the way
// "Add an account" does, and returns the cookies the browser then holds.
func addAccount(t *testing.T, s *Server, ff *fakeForge, jar []*http.Cookie, code string) []*http.Cookie {
t.Helper()
rec := do(s, "GET", "/auth/login?forge=forge.test&select=1&return=/", jar, nil)
loc, _ := url.Parse(rec.Header().Get("Location"))
ff.challenge = loc.Query().Get("code_challenge")
rec = do(s, "GET", "/auth/callback?code="+code+"&state="+loc.Query().Get("state"), append(jar, cookie(rec, stateCookie)), nil)
if rec.Code != 302 {
t.Fatalf("second sign-in: %d %s", rec.Code, rec.Body)
}
out := map[string]*http.Cookie{}
for _, c := range jar {
out[c.Name] = c
}
for _, c := range rec.Result().Cookies() {
if strings.HasPrefix(c.Name, accountPrefix) {
out[c.Name] = c
}
}
var res []*http.Cookie
for _, c := range out {
res = append(res, c)
}
return res
}
func TestMultiAccount(t *testing.T) {
s, ff := newTestServer(t)
ada := signIn(t, s, ff)
jar := addAccount(t, s, ff, []*http.Cookie{ada}, "bob-code")
if len(jar) != 2 {
t.Fatalf("expected ada in slot 0 and bob in slot 1, got %d cookies", len(jar))
}
var sess struct {
Accounts []map[string]string
}
json.NewDecoder(do(s, "GET", "/api/session", jar, nil).Body).Decode(&sess)
if len(sess.Accounts) != 2 || sess.Accounts[0]["id"] != "forge.test/ada" || sess.Accounts[1]["id"] != "forge.test/bob" {
t.Fatalf("session: %+v", sess)
}
// Each site, with the accounts that can open it.
var sites []struct {
ID string
CanWrite bool
Accounts []siteAccount
}
json.NewDecoder(do(s, "GET", "/api/sites", jar, nil).Body).Decode(&sites)
if len(sites) != 2 || sites[0].ID != "site" || len(sites[0].Accounts) != 2 || !sites[0].Accounts[0].CanWrite || sites[0].Accounts[1].CanWrite ||
sites[1].ID != "secret" || len(sites[1].Accounts) != 1 || sites[1].Accounts[0].Login != "bob" {
t.Fatalf("sites: %+v", sites)
}
// The default is the account that can write; ?as= picks another.
asOf := func(path string) (string, bool) {
var d struct {
As string
CanWrite bool
}
json.NewDecoder(do(s, "GET", path, jar, nil).Body).Decode(&d)
return d.As, d.CanWrite
}
if as, w := asOf("/api/sites/site"); as != "forge.test/ada" || !w {
t.Errorf("default account: %s %v", as, w)
}
if as, w := asOf("/api/sites/site?as=forge.test/bob"); as != "forge.test/bob" || w {
t.Errorf("as bob: %s %v", as, w)
}
if as, _ := asOf("/api/sites/site?as=forge.test/nobody"); as != "forge.test/ada" {
t.Errorf("an account that isn't signed in falls back: %s", as)
}
if as, w := asOf("/api/sites/secret"); as != "forge.test/bob" || !w {
t.Errorf("secret: %s %v", as, w)
}
// Writing as the read-only account is refused by the platform's answer.
if rec := post(s, "/api/sites/site/save?as=forge.test/bob", jar, map[string]any{"source": "content/about.md", "edit": map[string]any{"body": "x"}}); rec.Code != 403 {
t.Errorf("save as a reader: %d", rec.Code)
}
// Signing in as Ada again refreshes her slot instead of taking a third.
again := addAccount(t, s, ff, jar, "the-code")
if len(again) != 2 {
t.Errorf("re-sign-in took a new slot: %d cookies", len(again))
}
// When the platform stops taking Bob's token, only Bob is signed out.
ff.bobGone = true
s.accessSeen = nil
rec := do(s, "GET", "/api/sites/secret", jar, nil)
if rec.Code != 401 || !strings.Contains(rec.Body.String(), "@bob") {
t.Fatalf("expired account: %d %s", rec.Code, rec.Body)
}
if c := cookie(rec, accountCookie(1)); c == nil || c.MaxAge >= 0 {
t.Errorf("bob's cookie wasn't cleared: %+v", c)
}
if c := cookie(rec, accountCookie(0)); c != nil {
t.Errorf("ada's cookie was touched: %+v", c)
}
ff.bobGone = false
// Signing out one account leaves the other.
rec = do(s, "POST", "/auth/logout?account=forge.test/bob", jar, map[string]string{"Origin": "http://editor.test", "X-HotDog": "1"})
if rec.Code != 204 || cookie(rec, accountCookie(1)) == nil || cookie(rec, accountCookie(0)) != nil {
t.Errorf("sign out one: %d %v", rec.Code, rec.Result().Cookies())
}
rec = do(s, "POST", "/auth/logout", jar, map[string]string{"Origin": "http://editor.test", "X-HotDog": "1"})
if rec.Code != 204 || cookie(rec, accountCookie(0)) == nil || cookie(rec, accountCookie(1)) == nil {
t.Errorf("sign out all: %d %v", rec.Code, rec.Result().Cookies())
}
// GitHub is asked to let the person choose; other platforms aren't.
if loc := do(s, "GET", "/auth/login?forge=forge.test&select=1", nil, nil).Header().Get("Location"); strings.Contains(loc, "prompt=") {
t.Errorf("Gitea got a prompt: %s", loc)
}
}
func TestDiscover(t *testing.T) {
s, ff, _ := newTestServerWith(t, `, discover: { owners: [owner] }`)
jar := []*http.Cookie{signIn(t, s, ff)}
var sites []struct {
ID, Name, Repo string
Discovered bool
CanWrite bool
}
json.NewDecoder(do(s, "GET", "/api/sites", jar, nil).Body).Decode(&sites)
// The configured site, then the blog: not the repository without a
// site.yaml, the other tool's, the archived one, the other owner's, or
// one whose clone address is on another host.
if len(sites) != 2 || sites[0].ID != "site" || sites[0].Discovered || !strings.HasPrefix(sites[1].ID, "owner-blog-") || sites[1].Name != "The Blog" || !sites[1].Discovered || !sites[1].CanWrite {
t.Fatalf("sites: %+v", sites)
}
blogID := sites[1].ID
// A found site opens like any other, by its id.
rec := do(s, "GET", "/api/sites/"+blogID, jar, nil)
var d struct {
Name, Repo, As string
CanWrite bool
}
json.NewDecoder(rec.Body).Decode(&d)
if rec.Code != 200 || d.Repo != "https://forge.test/owner/blog.git" || !d.CanWrite || d.As != "forge.test/ada" {
t.Fatalf("found site: %d %+v %s", rec.Code, d, rec.Body)
}
// The listing is remembered, not repeated on every request.
if ff.listed != 1 {
t.Errorf("listed repositories %d times", ff.listed)
}
// Without discover, only editor.yaml's sites are offered.
s2, ff2, _ := newTestServerWith(t, "")
jar2 := []*http.Cookie{signIn(t, s2, ff2)}
json.NewDecoder(do(s2, "GET", "/api/sites", jar2, nil).Body).Decode(&sites)
if len(sites) != 1 || ff2.listed != 0 {
t.Errorf("discover off: %+v, listed %d", sites, ff2.listed)
}
if rec := do(s2, "GET", "/api/sites/"+blogID, jar2, nil); rec.Code != 404 {
t.Errorf("a site not offered opened: %d", rec.Code)
}
}
func TestDiscoverSetting(t *testing.T) {
for _, c := range []struct {
yaml string
on bool
}{{"", false}, {", discover: true", true}, {", discover: false", false}, {", discover: { limit: 20 }", true}} {
s, _, _ := newTestServerWith(t, c.yaml)
if got := s.cfg.Forges[0].Discover.on(); got != c.on {
t.Errorf("%q: on = %v", c.yaml, got)
}
}
}
func TestSectionsKeepOrder(t *testing.T) {
orig := []byte("---\ntitle: Home\nsections:\n - hero:\n heading: Hi\n eyebrow: Hello\n---\n")
var doc yaml.Node
yaml.Unmarshal([]byte("title: Home\nsections:\n - hero:\n heading: Hi\n eyebrow: Hello\n"), &doc)
j, err := orderedJSON(&doc)
if err != nil || string(j) != `{"title":"Home","sections":[{"hero":{"heading":"Hi","eyebrow":"Hello"}}]}` {
t.Fatalf("ordered JSON: %s %v", j, err)
}
// A new section, reordered fields, a multi-line field and a date-looking string.
set := map[string]json.RawMessage{"sections": json.RawMessage(`[{"text":{"heading":"Next","body":"One\nTwo"}},{"hero":{"heading":"Hi","eyebrow":"Hello","when":"2026-10-10"}}]`)}
got, err := compose(orig, Edit{Set: set})
if err != nil {
t.Fatal(err)
}
want := "---\ntitle: Home\nsections:\n - text:\n heading: Next\n body: |-\n One\n Two\n - hero:\n heading: Hi\n eyebrow: Hello\n when: \"2026-10-10\"\n---\n"
if string(got) != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
}
func TestEditsKeepUnchangedStyle(t *testing.T) {
orig := []byte("---\ntitle: Home\nsections:\n # the opening\n - hero:\n heading: Hi\n buttons:\n - { text: Go, href: /go/ }\n - { text: Stay, href: /stay/ }\n - closing:\n heading: Bye\n button: { text: Back, href: / }\n---\n")
// The hero's heading changes and the two sections swap places.
set := map[string]json.RawMessage{"sections": json.RawMessage(`[{"closing":{"heading":"Bye","button":{"text":"Back","href":"/"}}},{"hero":{"heading":"Hello","buttons":[{"text":"Go","href":"/go/"},{"text":"Stay","href":"/stay/"}]}}]`)}
got, err := compose(orig, Edit{Set: set})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"button: {text: Back, href: /}", "- {text: Go, href: /go/}", "- {text: Stay, href: /stay/}", "heading: Hello", "# the opening"} {
if !strings.Contains(string(got), want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
if i, j := strings.Index(string(got), "closing:"), strings.Index(string(got), "hero:"); i > j {
t.Errorf("order not applied:\n%s", got)
}
}
func TestSectionsAPI(t *testing.T) {
s, ff := newTestServer(t)
jar := []*http.Cookie{signIn(t, s, ff)}
var lib struct {
Sections []build.SectionInfo
Data []string
Collections []string
}
rec := do(s, "GET", "/api/sites/site/sections", jar, nil)
json.NewDecoder(rec.Body).Decode(&lib)
names := []string{}
for _, x := range lib.Sections {
names = append(names, x.Name)
}
if rec.Code != 200 || len(lib.Sections) != 6 || strings.Join(lib.Data, ",") != "features" || strings.Join(lib.Collections, ",") != "articles" {
t.Fatalf("sections: %d %v %v %v", rec.Code, names, lib.Data, lib.Collections)
}
// The page API keeps the file's key order, so sections come back as written.
rec = do(s, "GET", "/api/sites/site/page?source=content/index.md", jar, nil)
body := rec.Body.String()
if i, j := strings.Index(body, `"hero"`), strings.Index(body, `"closing"`); i < 0 || j < i || !strings.Contains(body, `"eyebrow":"Built with HotDog CMS","heading"`) {
t.Errorf("front matter order: %s", body)
}
}
func TestLookPanel(t *testing.T) {
s, ff, repo := newTestServerRepo(t)
jar := []*http.Cookie{signIn(t, s, ff)}
mainTip := git(t, repo, "rev-parse", "main")
var lk struct {
Look *site.LookFile
Values map[string]any
}
rec := do(s, "GET", "/api/sites/site/look", jar, nil)
json.NewDecoder(rec.Body).Decode(&lk)
if rec.Code != 200 || lk.Look == nil || len(lk.Look.Tokens) < 5 || len(lk.Values) != 0 {
t.Fatalf("look: %d %+v", rec.Code, lk)
}
// The preview links the unsaved stylesheet, and serves it.
rec = post(s, "/api/sites/site/look-live", jar, map[string]any{"branch": "main", "look": map[string]any{"accent": "#0a7d55"}})
var lv map[string]string
json.NewDecoder(rec.Body).Decode(&lv)
u, err := url.Parse(lv["url"])
if rec.Code != 200 || err != nil || lv["error"] != "" {
t.Fatalf("look-live: %d %v", rec.Code, lv)
}
page := httptest.NewRecorder()
req := httptest.NewRequest("GET", lv["url"], nil)
req.Host = u.Host
s.live.ServeHTTP(page, req)
m := regexp.MustCompile(`href="(/look\.[0-9a-f]+\.css)"`).FindStringSubmatch(page.Body.String())
if m == nil {
t.Fatalf("preview page has no look stylesheet:\n%s", page.Body)
}
cssRec := httptest.NewRecorder()
creq := httptest.NewRequest("GET", "http://"+u.Host+m[1], nil)
creq.Host = u.Host
s.live.ServeHTTP(cssRec, creq)
if !strings.Contains(cssRec.Body.String(), "--accent: #0a7d55") || !strings.HasPrefix(cssRec.Header().Get("Content-Type"), "text/css") {
t.Errorf("preview stylesheet: %s %s", cssRec.Header().Get("Content-Type"), cssRec.Body)
}
// A choice the theme doesn't allow isn't saved.
if rec := post(s, "/api/sites/site/look-save", jar, map[string]any{"branch": "main", "baseCommit": mainTip, "look": map[string]any{"font": "comic"}}); rec.Code != 422 {
t.Errorf("bad look saved: %d", rec.Code)
}
// Saving starts draft/look, with the look in site.yaml and the rest kept.
rec = post(s, "/api/sites/site/look-save", jar, map[string]any{"branch": "main", "baseCommit": mainTip, "look": map[string]any{"accent": "#0a7d55", "font": "serif"}})
var saved map[string]any
json.NewDecoder(rec.Body).Decode(&saved)
if rec.Code != 200 || saved["branch"] != "draft/look" || saved["created"] != true {
t.Fatalf("look-save: %d %v", rec.Code, saved)
}
cfg := git(t, repo, "show", "draft/look:site.yaml")
if !strings.Contains(cfg, "look:\n accent: '#0a7d55'\n font: serif") && !strings.Contains(cfg, "look:\n accent: \"#0a7d55\"\n font: serif") {
t.Errorf("site.yaml:\n%s", cfg)
}
// Everything but the look is exactly as it was.
orig := git(t, repo, "show", "main:site.yaml")
if cfg != orig+"\nlook:\n accent: '#0a7d55'\n font: serif" { // git() trims the last newline
t.Errorf("site.yaml changed beyond the look:\n%s", cfg)
}
// Saving again on the draft replaces the block, and an empty look removes it.
tip := git(t, repo, "rev-parse", "draft/look")
if rec := post(s, "/api/sites/site/look-save", jar, map[string]any{"branch": "draft/look", "baseCommit": tip, "look": map[string]any{}}); rec.Code != 200 {
t.Fatalf("clearing the look: %d %s", rec.Code, rec.Body)
}
if got := git(t, repo, "show", "draft/look:site.yaml"); got != orig {
t.Errorf("an empty look didn't restore site.yaml:\n%s", got)
}
if git(t, repo, "rev-parse", "main") != mainTip {
t.Error("the look reached the publishing branch")
}
}
func TestSetTopLevel(t *testing.T) {
in := "name: T\nlook:\n accent: \"#111111\"\n font: serif\n# Menus\nmenus:\n main:\n - { name: Home, url: / }\n"
got, err := setTopLevel(in, "look", map[string]any{"accent": "#222222"})
want := "name: T\nlook:\n accent: '#222222'\n# Menus\nmenus:\n main:\n - { name: Home, url: / }\n"
if err != nil || got != want {
t.Errorf("replace:\n%s\n%v", got, err)
}
if got, _ := setTopLevel(in, "look", nil); got != "name: T\n# Menus\nmenus:\n main:\n - { name: Home, url: / }\n" {
t.Errorf("remove:\n%s", got)
}
if _, err := setTopLevel("look: { accent: '#111' }\n", "look", map[string]any{"font": "serif"}); err == nil {
t.Error("a one-line look: was rewritten")
}
}
func TestFilesPowerTools(t *testing.T) {
s, ff, repo := newTestServerRepo(t)
jar := []*http.Cookie{signIn(t, s, ff)}
mainTip := git(t, repo, "rev-parse", "main")
var list struct{ Files []FileInfo }
json.NewDecoder(do(s, "GET", "/api/sites/site/files", jar, nil).Body).Decode(&list)
paths := map[string]FileInfo{}
for _, f := range list.Files {
paths[f.Path] = f
}
if !paths["site.yaml"].Text || !paths["layouts/base.html"].Text {
t.Fatalf("files: %v", list.Files)
}
for p := range paths {
if p == ".git" || strings.HasPrefix(p, ".git/") || strings.HasPrefix(p, "public/") {
t.Errorf("listed %s", p)
}
}
var file struct {
Content string
Exists bool
}
json.NewDecoder(do(s, "GET", "/api/sites/site/file?path=site.yaml", jar, nil).Body).Decode(&file)
if !file.Exists || !strings.Contains(file.Content, "name: Test") {
t.Fatalf("file: %+v", file)
}
edited := strings.Replace(file.Content, "name: Test", "name: Renamed", 1)
// The diff before saving.
var df struct{ Diff string }
json.NewDecoder(post(s, "/api/sites/site/file-diff", jar, map[string]any{"path": "site.yaml", "content": edited}).Body).Decode(&df)
if !strings.Contains(df.Diff, "--- a/site.yaml") || !strings.Contains(df.Diff, "-name: Test") || !strings.Contains(df.Diff, "+name: Renamed") {
t.Fatalf("diff:\n%s", df.Diff)
}
// Saving starts a draft; adding and deleting files are commits on it.
var sv map[string]any
json.NewDecoder(post(s, "/api/sites/site/file-save", jar, map[string]any{"path": "site.yaml", "branch": "main", "baseCommit": mainTip, "content": edited}).Body).Decode(&sv)
if sv["branch"] != "draft/site" || sv["created"] != true {
t.Fatalf("save: %v", sv)
}
tip := git(t, repo, "rev-parse", "draft/site")
if rec := post(s, "/api/sites/site/file-save", jar, map[string]any{"path": "data/team.yaml", "branch": "draft/site", "baseCommit": tip, "content": "- Ada\n"}); rec.Code != 200 {
t.Fatalf("add: %d %s", rec.Code, rec.Body)
}
tip = git(t, repo, "rev-parse", "draft/site")
if rec := post(s, "/api/sites/site/file-save", jar, map[string]any{"path": "content/contact.md", "branch": "draft/site", "baseCommit": tip, "delete": true}); rec.Code != 200 {
t.Fatalf("delete: %d %s", rec.Code, rec.Body)
}
tree := git(t, repo, "ls-tree", "-r", "--name-only", "draft/site")
if !strings.Contains(tree, "data/team.yaml") || strings.Contains(tree, "content/contact.md") || !strings.Contains(git(t, repo, "show", "draft/site:site.yaml"), "name: Renamed") {
t.Errorf("draft tree:\n%s", tree)
}
if git(t, repo, "rev-parse", "main") != mainTip {
t.Error("a file save reached the publishing branch")
}
for _, bad := range []string{".git/config", "public/index.html", "../outside", "/etc/passwd", "a/../../b"} {
if rec := post(s, "/api/sites/site/file-save", jar, map[string]any{"path": bad, "branch": "draft/site", "content": "x"}); rec.Code != 400 {
t.Errorf("%s: %d", bad, rec.Code)
}
}
// Writers can't change what runs at publish time; the fake forge makes
// this account a writer, not a maintainer.
for _, pipe := range []string{"publish.yaml", ".gitea/workflows/deploy.yml", ".github/workflows/ci.yml"} {
if rec := post(s, "/api/sites/site/file-save", jar, map[string]any{"path": pipe, "branch": "draft/site", "content": "x"}); rec.Code != 403 {
t.Errorf("%s: %d", pipe, rec.Code)
}
}
// What publishing the draft would change, and its history.
var cmp struct{ Diff, Stat string }
json.NewDecoder(do(s, "GET", "/api/sites/site/compare?branch=draft/site", jar, nil).Body).Decode(&cmp)
if !strings.Contains(cmp.Diff, "+name: Renamed") || !strings.Contains(cmp.Diff, "data/team.yaml") || !strings.Contains(cmp.Stat, "content/contact.md") {
t.Errorf("compare:\n%s\n%s", cmp.Stat, cmp.Diff)
}
var lg struct{ Commits []Commit }
json.NewDecoder(do(s, "GET", "/api/sites/site/log?branch=draft/site&n=5", jar, nil).Body).Decode(&lg)
if len(lg.Commits) < 4 || lg.Commits[0].Subject != "Delete content/contact.md" || lg.Commits[1].Subject != "Add data/team.yaml" || lg.Commits[2].Subject != "Edit site.yaml" || lg.Commits[0].Author != "Ada Lovelace" {
t.Errorf("log: %+v", lg.Commits)
}
json.NewDecoder(do(s, "GET", "/api/sites/site/log?branch=draft/site&path=site.yaml", jar, nil).Body).Decode(&lg)
if len(lg.Commits) != 2 || lg.Commits[0].Subject != "Edit site.yaml" {
t.Errorf("file log: %+v", lg.Commits)
}
}
func TestPointAndEdit(t *testing.T) {
l := newLiveServer(LiveConfig{Listen: "127.0.0.1:8191"}, "http://editor.test")
u := l.show("o", t.TempDir(), "/about/", []byte("<html><body><h1>About</h1></body></html>"), nil)
host := strings.TrimPrefix(strings.Split(u, "/")[2], "")
get := func(p string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "http://"+host+p, nil)
req.Host = host
rec := httptest.NewRecorder()
l.ServeHTTP(rec, req)
return rec
}
if b := get("/about/").Body.String(); !strings.Contains(b, `<script src="/__hotdog/jump.js" defer></script></body>`) {
t.Errorf("page: %s", b)
}
js := get("/__hotdog/jump.js")
if !strings.Contains(js.Body.String(), `const editor = "http://editor.test"`) || !strings.HasPrefix(js.Header().Get("Content-Type"), "text/javascript") {
t.Errorf("script: %s %s", js.Header().Get("Content-Type"), js.Body)
}
if !strings.Contains(js.Body.String(), `replace(/\s+/g, ' ')`) {
t.Errorf("the script's regular expression was mangled:\n%s", js.Body)
}
}
func TestTokenRenewal(t *testing.T) {
s, ff := newTestServer(t)
ff.expiring = true
jar := []*http.Cookie{signIn(t, s, ff)}
// The token expires within the renewal window, so the next request renews
// it first, and hands the browser the updated account.
rec := do(s, "GET", "/api/sites", jar, nil)
renewed := cookie(rec, accountCookie(0))
if rec.Code != 200 || ff.refreshes != 1 || renewed == nil || renewed.MaxAge <= 0 {
t.Fatalf("renewal: %d, %d renewals, cookie %+v", rec.Code, ff.refreshes, renewed)
}
// With the renewed account (good for an hour), no further renewal.
if rec := do(s, "GET", "/api/sites", []*http.Cookie{renewed}, nil); rec.Code != 200 || ff.refreshes != 1 {
t.Errorf("renewed again: %d, %d renewals", rec.Code, ff.refreshes)
}
// A burst of requests with the old account shares one renewal.
s.renewed = nil
for i := 0; i < 3; i++ {
do(s, "GET", "/api/sites", jar, nil)
}
if ff.refreshes != 2 {
t.Errorf("a burst renewed %d times, want once more", ff.refreshes-1)
}
// A refused renewal signs that account out.
ff.refuse = true
s.renewed = nil
rec = do(s, "GET", "/api/sites", jar, nil)
if c := cookie(rec, accountCookie(0)); rec.Code != 401 || c == nil || c.MaxAge >= 0 {
t.Errorf("refused renewal: %d %+v", rec.Code, c)
}
}
func TestMoveAndRedirects(t *testing.T) {
s, ff, repo := newTestServerRepo(t)
jar := []*http.Cookie{signIn(t, s, ff)}
mainTip := git(t, repo, "rev-parse", "main")
origCfg := git(t, repo, "show", "main:site.yaml")
rec := post(s, "/api/sites/site/move", jar, map[string]any{"branch": "main", "baseCommit": mainTip, "source": "content/about.md", "to": "content/about-us.md"})
var mv map[string]any
json.NewDecoder(rec.Body).Decode(&mv)
if rec.Code != 200 || mv["from"] != "/about/" || mv["to"] != "/about-us/" || mv["redirected"] != true || mv["created"] != true {
t.Fatalf("move: %d %v", rec.Code, mv)
}
br := mv["branch"].(string)
tree := git(t, repo, "ls-tree", "-r", "--name-only", br)
if strings.Contains(tree, "content/about.md") || !strings.Contains(tree, "content/about-us.md") {
t.Fatalf("tree:\n%s", tree)
}
cfg := git(t, repo, "show", br+":site.yaml")
if cfg != origCfg+"\nredirects:\n - from: /about/\n to: /about-us/" {
t.Errorf("site.yaml:\n%s", cfg)
}
// Moving it again points the first redirect at the newest address too.
tip := git(t, repo, "rev-parse", br)
rec = post(s, "/api/sites/site/move", jar, map[string]any{"branch": br, "baseCommit": tip, "source": "content/about-us.md", "to": "content/team.md"})
if rec.Code != 200 {
t.Fatalf("second move: %d %s", rec.Code, rec.Body)
}
cfg = git(t, repo, "show", br+":site.yaml")
if !strings.Contains(cfg, " - from: /about/\n to: /team/\n - from: /about-us/\n to: /team/") {
t.Errorf("chained redirects:\n%s", cfg)
}
// The table: read, refused when it would hide a page, saved when right.
var rd struct{ Redirects []site.Redirect }
json.NewDecoder(do(s, "GET", "/api/sites/site/redirects?branch="+br, jar, nil).Body).Decode(&rd)
if len(rd.Redirects) != 2 {
t.Fatalf("redirects: %+v", rd.Redirects)
}
tip = git(t, repo, "rev-parse", br)
if rec := post(s, "/api/sites/site/redirects-save", jar, map[string]any{"branch": br, "baseCommit": tip, "redirects": []map[string]string{{"from": "/contact/", "to": "/team/"}}}); rec.Code != 422 || !strings.Contains(rec.Body.String(), "is a page") {
t.Errorf("redirect over a page: %d %s", rec.Code, rec.Body)
}
if rec := post(s, "/api/sites/site/redirects-save", jar, map[string]any{"branch": br, "baseCommit": tip, "redirects": []map[string]string{{"from": "/old/", "to": "https://elsewhere.example/"}}}); rec.Code != 200 {
t.Fatalf("save: %d %s", rec.Code, rec.Body)
}
if cfg := git(t, repo, "show", br+":site.yaml"); cfg != origCfg+"\nredirects:\n - from: /old/\n to: https://elsewhere.example/" {
t.Errorf("after the table:\n%s", cfg)
}
if git(t, repo, "rev-parse", "main") != mainTip {
t.Error("main moved")
}
}
func TestCheckRedirects(t *testing.T) {
for _, c := range []struct {
list []site.Redirect
want string
}{
{[]site.Redirect{{From: "old", To: "/new/"}}, "starting with /"},
{[]site.Redirect{{From: "/a/", To: "/b/"}, {From: "/a/", To: "/c/"}}, "twice"},
{[]site.Redirect{{From: "/a/", To: "javascript:alert(1)"}}, "should start with"},
{[]site.Redirect{{From: "/a/", To: "/a/"}}, "itself"},
} {
if err := checkRedirects(c.list, map[string]bool{}); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%v: %v", c.list, err)
}
}
}
func TestPrivacySettings(t *testing.T) {
s, ff, repo := newTestServerRepo(t)
jar := []*http.Cookie{signIn(t, s, ff)}
mainTip := git(t, repo, "rev-parse", "main")
orig := git(t, repo, "show", "main:site.yaml")
if rec := post(s, "/api/sites/site/privacy-save", jar, map[string]any{"branch": "main", "baseCommit": mainTip, "analytics": map[string]any{"provider": "ga4", "id": "UA-1"}}); rec.Code != 422 || !strings.Contains(rec.Body.String(), "G-ABC123") {
t.Errorf("bad id: %d %s", rec.Code, rec.Body)
}
if rec := post(s, "/api/sites/site/privacy-save", jar, map[string]any{"branch": "main", "baseCommit": mainTip, "privacy": map[string]string{"contact": "not an address"}}); rec.Code != 422 {
t.Errorf("bad contact: %d", rec.Code)
}
rec := post(s, "/api/sites/site/privacy-save", jar, map[string]any{"branch": "main", "baseCommit": mainTip,
"analytics": map[string]any{"provider": "plausible", "domain": "example.org"},
"privacy": map[string]string{"controller": "Test Co", "contact": "[email protected]"}})
var sv map[string]any
json.NewDecoder(rec.Body).Decode(&sv)
if rec.Code != 200 || sv["branch"] != "draft/privacy" {
t.Fatalf("save: %d %v", rec.Code, sv)
}
cfg := git(t, repo, "show", "draft/privacy:site.yaml")
if cfg != orig+"\nanalytics:\n domain: example.org\n provider: plausible\nprivacy:\n contact: [email protected]\n controller: Test Co" {
t.Errorf("site.yaml:\n%s", cfg)
}
// Counting without asking is written down as a choice.
tip := git(t, repo, "rev-parse", "draft/privacy")
post(s, "/api/sites/site/privacy-save", jar, map[string]any{"branch": "draft/privacy", "baseCommit": tip,
"analytics": map[string]any{"provider": "plausible", "domain": "example.org", "gated": false}, "privacy": map[string]string{}})
cfg = git(t, repo, "show", "draft/privacy:site.yaml")
if !strings.Contains(cfg, " gated: false") || strings.Contains(cfg, "\nprivacy:") {
t.Errorf("ungated:\n%s", cfg)
}
var got struct {
Analytics *site.AnalyticsConfig
}
json.NewDecoder(do(s, "GET", "/api/sites/site/privacy?branch=draft/privacy", jar, nil).Body).Decode(&got)
if got.Analytics == nil || got.Analytics.IsGated() {
t.Errorf("read back: %+v", got.Analytics)
}
}
func TestSearchEngineSettings(t *testing.T) {
s, ff, repo := newTestServerRepo(t)
jar := []*http.Cookie{signIn(t, s, ff)}
mainTip := git(t, repo, "rev-parse", "main")
orig := git(t, repo, "show", "main:site.yaml")
// Pasting the whole tag is fine: the code is taken out of it.
rec := post(s, "/api/sites/site/search-save", jar, map[string]any{"branch": "main", "baseCommit": mainTip,
"verify": map[string]string{"google": `<meta name="google-site-verification" content="abc123XYZ">`, "bing": "B1NG2"}, "indexnow": "0123456789abcdef"})
if rec.Code != 200 {
t.Fatalf("save: %d %s", rec.Code, rec.Body)
}
cfg := git(t, repo, "show", "draft/search-engines:site.yaml")
if cfg != orig+"\nverify:\n bing: B1NG2\n google: abc123XYZ\nindexnow:\n key: 0123456789abcdef" {
t.Errorf("site.yaml:\n%s", cfg)
}
if rec := post(s, "/api/sites/site/search-save", jar, map[string]any{"branch": "main", "baseCommit": mainTip, "indexnow": "short"}); rec.Code != 422 {
t.Errorf("short key: %d", rec.Code)
}
}
func TestFormBuilder(t *testing.T) {
s, ff, repo := newTestServerRepo(t)
jar := []*http.Cookie{signIn(t, s, ff)}
mainTip := git(t, repo, "rev-parse", "main")
var list struct {
Forms []struct {
Name string
Form forms.Form
UsedBy []string
}
}
json.NewDecoder(do(s, "GET", "/api/sites/site/forms", jar, nil).Body).Decode(&list)
if len(list.Forms) != 1 || list.Forms[0].Name != "contact" || len(list.Forms[0].UsedBy) != 1 || list.Forms[0].UsedBy[0] != "content/contact.md" || len(list.Forms[0].Form.Fields) < 2 {
t.Fatalf("forms: %+v", list.Forms)
}
fm := list.Forms[0].Form
fm.Submit = "Send it"
fm.Fields = append(fm.Fields, forms.Field{Name: "phone", Type: "tel", Label: "Phone"})
// The preview shows the edited form on the page that uses it.
rec := post(s, "/api/sites/site/form-live", jar, map[string]any{"branch": "main", "name": "contact", "form": fm})
var lv map[string]string
json.NewDecoder(rec.Body).Decode(&lv)
u, err := url.Parse(lv["url"])
if rec.Code != 200 || err != nil || lv["error"] != "" {
t.Fatalf("form-live: %d %v", rec.Code, lv)
}
req := httptest.NewRequest("GET", lv["url"], nil)
req.Host = u.Host
page := httptest.NewRecorder()
s.live.ServeHTTP(page, req)
if !strings.Contains(page.Body.String(), "Send it") || !strings.Contains(page.Body.String(), `name="phone"`) {
t.Errorf("preview:\n%s", page.Body)
}
// Saving keeps the file's opening comments.
orig := git(t, repo, "show", "main:forms/contact.yaml")
rec = post(s, "/api/sites/site/form-save", jar, map[string]any{"branch": "main", "baseCommit": mainTip, "name": "contact", "form": fm})
var sv map[string]any
json.NewDecoder(rec.Body).Decode(&sv)
if rec.Code != 200 || sv["branch"] != "draft/form-contact" {
t.Fatalf("save: %d %v", rec.Code, sv)
}
saved := git(t, repo, "show", "draft/form-contact:forms/contact.yaml")
if !strings.HasPrefix(saved, strings.SplitN(orig, "\n", 2)[0]) || !strings.Contains(saved, "submit: Send it") || !strings.Contains(saved, "- name: phone\n type: tel") {
t.Errorf("saved:\n%s", saved)
}
// A field type that doesn't exist is refused; a new form is a new file.
tip := git(t, repo, "rev-parse", "draft/form-contact")
bad := forms.Form{To: "[email protected]", Fields: []forms.Field{{Name: "x", Type: "colour"}}}
if rec := post(s, "/api/sites/site/form-save", jar, map[string]any{"branch": "draft/form-contact", "baseCommit": tip, "name": "newsletter", "form": bad}); rec.Code != 422 {
t.Errorf("bad type: %d %s", rec.Code, rec.Body)
}
good := forms.Form{To: "[email protected]", Fields: []forms.Field{{Name: "email", Type: "email", Required: true}}}
if rec := post(s, "/api/sites/site/form-save", jar, map[string]any{"branch": "draft/form-contact", "baseCommit": tip, "name": "newsletter", "form": good}); rec.Code != 200 {
t.Errorf("new form: %d %s", rec.Code, rec.Body)
}
if got := git(t, repo, "show", "draft/form-contact:forms/newsletter.yaml"); got != "to: [email protected]\nfields:\n - name: email\n type: email\n required: true" {
t.Errorf("new form file:\n%s", got)
}
// Submissions come through the endpoint's viewer, with the editor's token.
var askedWith string
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
askedWith = r.Header.Get("Authorization")
if r.URL.Path != "/sites/example.org/forms/contact" {
w.WriteHeader(404)
return
}
w.Write([]byte(`{"form":"contact","total":1,"submissions":[{"time":"2026-10-10T12:00:00Z","values":{"name":"Ada"}}]}`))
}))
defer fake.Close()
t.Setenv("SUBS_TOKEN", strings.Repeat("s", 40))
s.cfg.Sites[0].Submissions = SubmissionsConfig{URL: fake.URL, TokenEnv: "SUBS_TOKEN", Site: "example.org"}
rec = do(s, "GET", "/api/sites/site/submissions?form=contact", jar, nil)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"Ada"`) || askedWith != "Bearer "+strings.Repeat("s", 40) {
t.Fatalf("submissions: %d %s (asked with %q)", rec.Code, rec.Body, askedWith)
}
// Someone who can only read the site can't read its submissions.
bob := addAccount(t, s, ff, nil, "bob-code")
if rec := do(s, "GET", "/api/sites/site/submissions?form=contact&as=forge.test/bob", bob, nil); rec.Code != 403 {
t.Errorf("reader: %d", rec.Code)
}
}
func TestPeopleAndUndo(t *testing.T) {
s, ff, repo := newTestServerRepo(t)
jar := []*http.Cookie{signIn(t, s, ff)}
var people struct{ People []Person }
json.NewDecoder(do(s, "GET", "/api/sites/site/people", jar, nil).Body).Decode(&people)
if len(people.People) != 1 || people.People[0].Login != "carol" {
t.Fatalf("people: %+v", people.People)
}
var d struct{ Role string }
json.NewDecoder(do(s, "GET", "/api/sites/site", jar, nil).Body).Decode(&d)
if d.Role != "writer" {
t.Errorf("role: %q", d.Role)
}
// A review that asks Carol: the platform is asked to notify her.
git(t, repo, "checkout", "-q", "-b", "draft/x")
git(t, repo, "commit", "-q", "--allow-empty", "-m", "x")
git(t, repo, "checkout", "-q", "main")
rec := post(s, "/api/sites/site/review", jar, map[string]any{"branch": "draft/x", "title": "X", "description": "", "reviewers": []string{"carol"}})
if rec.Code != 200 || len(ff.requested) != 1 || ff.requested[0] != "carol" {
t.Fatalf("review with reviewers: %d %s %v", rec.Code, rec.Body, ff.requested)
}
// A published change, then undone on a draft.
before := git(t, repo, "show", "main:content/about.md")
os.WriteFile(filepath.Join(repo, "content", "about.md"), []byte(before+"\nA line that was a mistake.\n"), 0o644)
git(t, repo, "commit", "-q", "-am", "Oops")
bad := git(t, repo, "rev-parse", "HEAD")
mainTip := bad
rec = post(s, "/api/sites/site/revert", jar, map[string]any{"sha": bad})
var rv map[string]any
json.NewDecoder(rec.Body).Decode(&rv)
if rec.Code != 200 || !strings.HasPrefix(rv["branch"].(string), "draft/undo-") {
t.Fatalf("revert: %d %v", rec.Code, rv)
}
if got := git(t, repo, "show", rv["branch"].(string)+":content/about.md"); got != strings.TrimRight(before, "\n") {
t.Errorf("undone file:\n%s", got)
}
if git(t, repo, "rev-parse", "main") != mainTip {
t.Error("undo reached the publishing branch")
}
if subj := git(t, repo, "log", "-1", "--format=%s", rv["branch"].(string)); !strings.HasPrefix(subj, `Revert "Oops"`) {
t.Errorf("subject: %s", subj)
}
if rec := post(s, "/api/sites/site/revert", jar, map[string]any{"sha": "not-a-sha"}); rec.Code != 400 {
t.Errorf("bad sha: %d", rec.Code)
}
}
func TestDiscoverFindsNewRepos(t *testing.T) {
s, ff, _ := newTestServerWith(t, `, discover: { owners: [owner] }`)
jar := []*http.Cookie{signIn(t, s, ff)}
do(s, "GET", "/api/sites", jar, nil) // caches the listing
if ff.listed != 1 {
t.Fatalf("listed %d", ff.listed)
}
// Age the cached listing past 30 seconds: an unknown id looks again.
s.found.mu.Lock()
for k, d := range s.found.seen {
d.at = d.at.Add(-time.Minute)
s.found.seen[k] = d
}
s.found.mu.Unlock()
if rec := do(s, "GET", "/api/sites/owner-newsite", jar, nil); rec.Code != 404 || ff.listed != 2 {
t.Errorf("unknown id: %d, listed %d", rec.Code, ff.listed)
}
// Straight after, it doesn't ask again.
do(s, "GET", "/api/sites/owner-newsite", jar, nil)
if ff.listed != 2 {
t.Errorf("asked again at once: listed %d", ff.listed)
}
}
func TestPipelineFile(t *testing.T) {
for p, want := range map[string]bool{
"publish.yaml": true, "site/publish.yml": true, ".gitea/workflows/a.yml": true, ".github/dependabot.yml": true,
".forgejo/workflows/b.yaml": true, ".gitlab-ci.yml": true, ".woodpecker/build.yml": true, ".woodpecker.yml": true,
"Jenkinsfile": true, "content/publish.md": false, "site.yaml": false, "docs/.github.md": false, "assets/ci.css": false,
} {
if pipelineFile(p) != want {
t.Errorf("%s: want %v", p, want)
}
}
}
func TestAboutBeforeSignIn(t *testing.T) {
s, _ := newTestServer(t)
s.cfg.Links = []about.Link{{Name: "Privacy", URL: "https://example.org/privacy/"}}
rec := do(s, "GET", "/api/about", nil, nil)
var a about.Info
json.NewDecoder(rec.Body).Decode(&a)
if rec.Code != 200 || a.License != "AGPL-3.0-or-later" || !strings.HasPrefix(a.Source, about.DefaultSource) || a.CompanyURL != "https://coffeylabs.org" || len(a.Links) != 1 {
t.Errorf("about: %d %+v", rec.Code, a)
}
}
func TestQuickPosts(t *testing.T) {
s, ff, repo := newTestServerRepo(t)
jar := []*http.Cookie{signIn(t, s, ff)}
var info QuickInfo
json.NewDecoder(do(s, "GET", "/api/sites/site/quick", jar, nil).Body).Decode(&info)
if info.Timezone != "UTC" || len(info.Collections) == 0 || info.Collections[0].Name != "articles" {
t.Fatalf("quick info: %+v", info)
}
// A banner, posted and published in one go.
var out map[string]any
rec := post(s, "/api/sites/site/quick", jar, map[string]any{"kind": "banner", "publish": true, "text": "Closed Monday.", "tone": "alert", "link": "/about/", "linkText": "Hours", "until": "2099-01-01 00:00"})
json.NewDecoder(rec.Body).Decode(&out)
if rec.Code != 200 || out["published"] != true || len(ff.merged) != 1 {
t.Fatalf("banner: %d %v merged %v", rec.Code, out, ff.merged)
}
branch := out["branch"].(string)
banners := git(t, repo, "show", branch+":data/banners.yaml")
if !strings.Contains(banners, "text: Closed Monday.") || !strings.Contains(banners, "tone: alert") || !strings.Contains(banners, "until: 2099-01-01 00:00") {
t.Errorf("banners.yaml:\n%s", banners)
}
if !strings.HasPrefix(ff.opened[len(ff.opened)-1]["title"], "Quick post: Banner: Closed Monday.") {
t.Errorf("review title: %v", ff.opened[len(ff.opened)-1])
}
// A news post, left for review when the platform won't let this person merge.
ff.refuseMerge = true
out = map[string]any{}
rec = post(s, "/api/sites/site/quick", jar, map[string]any{"kind": "post", "publish": true, "collection": "articles", "title": "Holiday hours", "summary": "When we're open.", "body": "Closed **Monday**."})
json.NewDecoder(rec.Body).Decode(&out)
if rec.Code != 200 || out["published"] == true || !strings.Contains(out["message"].(string), "couldn't be published from here") {
t.Fatalf("post: %d %v", rec.Code, out)
}
page := git(t, repo, "show", out["branch"].(string)+":content/articles/holiday-hours.md")
if !strings.HasPrefix(page, "---\ntitle: Holiday hours\ndate: ") || !strings.Contains(page, "summary: When we're open.\n---\nClosed **Monday**.") {
t.Errorf("post:\n%s", page)
}
// Mistakes are caught before anything is committed.
for _, bad := range []map[string]any{
{"kind": "banner", "text": ""},
{"kind": "banner", "text": "x", "link": "javascript:alert(1)"},
{"kind": "banner", "text": "x", "until": "2000-01-01 00:00"},
{"kind": "post", "collection": "articles", "title": ""},
{"kind": "post", "collection": "../etc", "title": "x"},
{"kind": "event", "collection": "articles", "title": "x", "date": "2026-11-07"},
} {
if rec := post(s, "/api/sites/site/quick", jar, bad); rec.Code != 400 {
t.Errorf("%v: %d %s", bad, rec.Code, rec.Body)
}
}
}
func TestSafeReturn(t *testing.T) {
for in, want := range map[string]string{
"/sites/x?branch=draft/a": "/sites/x?branch=draft/a",
"/": "/",
"//evil.example/": "/",
"/\t/evil.example/": "/",
"/\\evil.example": "/",
"/%09/evil.example": "/%09/evil.example", // stays a path on the editor: browsers don't decode %09 here
"https://evil.example/": "/",
"/ /x": "/",
"javascript:alert(1)": "/",
"": "/",
} {
if got := safeReturn(in); got != want {
t.Errorf("safeReturn(%q) = %q, want %q", in, got, want)
}
}
}
func TestHostCookiesOverHTTPS(t *testing.T) {
sl, err := newSealer(strings.Repeat("k", 32))
if err != nil {
t.Fatal(err)
}
sl.secure = true
rec := httptest.NewRecorder()
if err := sl.setCookie(rec, accountCookie(0), map[string]string{"a": "b"}, time.Hour); err != nil {
t.Fatal(err)
}
c := rec.Result().Cookies()[0]
if c.Name != "__Host-hotdog_a0" || !c.Secure || c.Path != "/" || c.Domain != "" || !c.HttpOnly {
t.Errorf("cookie over https: %+v", c)
}
}
func TestNewPageOnDraft(t *testing.T) {
s, ff, repo := newTestServerRepo(t)
jar := []*http.Cookie{signIn(t, s, ff)}
var a, b map[string]any
json.NewDecoder(post(s, "/api/sites/site/new", jar, map[string]any{"collection": "", "title": "First"}).Body).Decode(&a)
if a["branch"] != "draft/first" {
t.Fatalf("new page: %v", a)
}
json.NewDecoder(post(s, "/api/sites/site/new", jar, map[string]any{"collection": "", "title": "Second", "branch": "draft/first"}).Body).Decode(&b)
if b["branch"] != "draft/first" {
t.Fatalf("second page started its own draft: %v", b)
}
if tree := git(t, repo, "ls-tree", "-r", "--name-only", "draft/first"); !strings.Contains(tree, "content/first.md") || !strings.Contains(tree, "content/second.md") {
t.Errorf("draft tree:\n%s", tree)
}
}