package web import ( "archive/zip" "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "git.veillax.com/Veillax/basking/internal/config" "git.veillax.com/Veillax/basking/internal/store" ) const password = "correct horse battery" type rig struct { t *testing.T srv *httptest.Server cfg *config.Holder cookie *http.Cookie } func newRig(t *testing.T) *rig { t.Helper() dir := t.TempDir() h, err := config.Load(filepath.Join(dir, "basking.json")) if err != nil { t.Fatal(err) } hash, _ := config.HashPassword(password) if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil { t.Fatal(err) } st, err := store.Open(filepath.Join(dir, "data")) if err != nil { t.Fatal(err) } srv := httptest.NewServer(New(h, st, "test").Handler()) t.Cleanup(srv.Close) return &rig{t: t, srv: srv, cfg: h} } func (r *rig) do(method, path, ctype, body string, headers ...string) (*http.Response, []byte) { r.t.Helper() req, _ := http.NewRequest(method, r.srv.URL+path, strings.NewReader(body)) if ctype != "" { req.Header.Set("Content-Type", ctype) } for i := 0; i+1 < len(headers); i += 2 { req.Header.Set(headers[i], headers[i+1]) } if r.cookie != nil { req.AddCookie(r.cookie) } resp, err := http.DefaultClient.Do(req) if err != nil { r.t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return resp, b } // js sends JSON as a browser would, with the CSRF header. func (r *rig) js(method, path string, v any) (*http.Response, map[string]any) { r.t.Helper() var body string if v != nil { b, _ := json.Marshal(v) body = string(b) } resp, b := r.do(method, path, "application/json", body, "X-Basking", "1") var out map[string]any json.Unmarshal(b, &out) return resp, out } func (r *rig) signIn() { r.t.Helper() resp, _ := r.js("POST", "/api/login", map[string]string{"password": password}) if resp.StatusCode != 200 { r.t.Fatalf("login: %d", resp.StatusCode) } for _, c := range resp.Cookies() { if c.Name == cookieName { r.cookie = c } } if r.cookie == nil { r.t.Fatal("no session cookie") } } func TestRequiresSignIn(t *testing.T) { r := newRig(t) for _, p := range []string{"/api/notes", "/api/summary", "/api/export", "/api/tokens", "/api/me"} { if resp, _ := r.do("GET", p, "", ""); resp.StatusCode != 401 { t.Errorf("GET %s without signing in: %d", p, resp.StatusCode) } } if resp, _ := r.do("GET", "/healthz", "", ""); resp.StatusCode != 200 { t.Error("healthz should be open") } if resp, _ := r.do("GET", "/", "", ""); resp.StatusCode != 200 { t.Error("the page itself should load") } } func TestLoginAndHeaders(t *testing.T) { r := newRig(t) if resp, _ := r.do("POST", "/api/login", "application/json", `{"password":"`+password+`"}`); resp.StatusCode != 403 { t.Errorf("login without the CSRF header: %d", resp.StatusCode) } if resp, _ := r.js("POST", "/api/login", map[string]string{"password": "wrong"}); resp.StatusCode != 401 { t.Errorf("wrong password: %d", resp.StatusCode) } r.signIn() resp, _ := r.do("GET", "/api/me", "", "") if resp.StatusCode != 200 { t.Fatalf("me: %d", resp.StatusCode) } if csp := resp.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") { t.Errorf("CSP %q", csp) } if !r.cookie.HttpOnly || r.cookie.SameSite != http.SameSiteStrictMode { t.Errorf("cookie flags %+v", r.cookie) } // a browser write needs the header if resp, _ := r.do("POST", "/api/notes", "application/json", `{"body":"x"}`); resp.StatusCode != 403 { t.Errorf("write without the header: %d", resp.StatusCode) } // a forged cookie does nothing r.cookie = &http.Cookie{Name: cookieName, Value: "9999999999.deadbeef"} if resp, _ := r.do("GET", "/api/me", "", ""); resp.StatusCode != 401 { t.Errorf("forged cookie: %d", resp.StatusCode) } } func TestLoginIsRateLimited(t *testing.T) { r := newRig(t) var last int for i := 0; i < maxLoginFails+2; i++ { resp, _ := r.js("POST", "/api/login", map[string]string{"password": "nope"}) last = resp.StatusCode } if last != 429 { t.Fatalf("after many failures got %d", last) } if resp, _ := r.js("POST", "/api/login", map[string]string{"password": password}); resp.StatusCode != 429 { t.Fatalf("even the right password waits: %d", resp.StatusCode) } } func TestNoteLifecycle(t *testing.T) { r := newRig(t) r.signIn() resp, n := r.js("POST", "/api/notes", map[string]string{"body": "remember the milk #errands"}) if resp.StatusCode != 201 { t.Fatalf("create: %d %v", resp.StatusCode, n) } id, _ := n["id"].(string) if id == "" || n["body"] != "remember the milk #errands" { t.Fatalf("created %v", n) } if resp, _ := r.js("POST", "/api/notes", map[string]string{"body": " "}); resp.StatusCode != 400 { t.Errorf("empty note: %d", resp.StatusCode) } _, list := r.js("GET", "/api/notes?q=milk", nil) if list["total"] != float64(1) { t.Fatalf("search: %v", list) } _, list = r.js("GET", "/api/notes?tag=errands", nil) if list["total"] != float64(1) { t.Fatalf("tag: %v", list) } // edit with the version we have, then again with a stale one resp, n2 := r.js("PUT", "/api/notes/"+id, map[string]any{"body": "remember the oat milk", "base": n["updated"]}) if resp.StatusCode != 200 || n2["body"] != "remember the oat milk" { t.Fatalf("update: %d %v", resp.StatusCode, n2) } resp, c := r.js("PUT", "/api/notes/"+id, map[string]any{"body": "stale", "base": n["updated"]}) if resp.StatusCode != 409 { t.Fatalf("stale update: %d", resp.StatusCode) } if cur, _ := c["note"].(map[string]any); cur["body"] != "remember the oat milk" { t.Errorf("conflict should return the current note: %v", c) } if resp, _ := r.js("PUT", "/api/notes/"+id, map[string]any{"pinned": true}); resp.StatusCode != 200 { t.Errorf("pin: %d", resp.StatusCode) } _, list = r.js("GET", "/api/notes?view=pinned", nil) if list["total"] != float64(1) { t.Errorf("pinned view: %v", list) } if resp, _ := r.js("DELETE", "/api/notes/"+id+"?purge=1", nil); resp.StatusCode != 400 { t.Errorf("purge of a live note: %d", resp.StatusCode) } if resp, _ := r.js("DELETE", "/api/notes/"+id, nil); resp.StatusCode != 200 { t.Errorf("trash: %d", resp.StatusCode) } _, sum := r.js("GET", "/api/summary", nil) if counts := sum["counts"].(map[string]any); counts["trash"] != float64(1) || counts["all"] != float64(0) { t.Errorf("summary after trashing: %v", sum) } if resp, _ := r.js("POST", "/api/notes/"+id+"/restore", nil); resp.StatusCode != 200 { t.Errorf("restore: %d", resp.StatusCode) } r.js("DELETE", "/api/notes/"+id, nil) if resp, _ := r.js("DELETE", "/api/trash", nil); resp.StatusCode != 200 { t.Errorf("empty trash: %d", resp.StatusCode) } if resp, _ := r.js("GET", "/api/notes/"+id, nil); resp.StatusCode != 404 { t.Errorf("gone: %d", resp.StatusCode) } for _, bad := range []string{"..%2f..%2fbasking.json", "UPPER", "a.b"} { if resp, _ := r.js("GET", "/api/notes/"+bad, nil); resp.StatusCode != 404 { t.Errorf("odd id %q: %d", bad, resp.StatusCode) } } } func TestDailyEndpoint(t *testing.T) { r := newRig(t) r.signIn() resp, n := r.js("POST", "/api/daily", map[string]string{"date": "2026-10-31"}) if resp.StatusCode != 200 || n["id"] != "daily-2026-10-31" { t.Fatalf("%d %v", resp.StatusCode, n) } if resp, _ := r.js("POST", "/api/daily", map[string]string{"date": "yesterday"}); resp.StatusCode != 400 { t.Errorf("bad date: %d", resp.StatusCode) } } func TestTokensAndPlainTextCapture(t *testing.T) { r := newRig(t) r.signIn() resp, tok := r.js("POST", "/api/tokens", map[string]string{"name": "phone"}) if resp.StatusCode != 200 { t.Fatalf("new token: %d %v", resp.StatusCode, tok) } token := tok["token"].(string) if !strings.HasPrefix(token, "bsk_") { t.Fatalf("token %q", token) } if resp, _ := r.js("POST", "/api/tokens", map[string]string{"name": "PHONE"}); resp.StatusCode != 400 { t.Errorf("duplicate name: %d", resp.StatusCode) } if b, _ := json.Marshal(r.cfg.Get()); strings.Contains(string(b), token) { t.Error("the token itself must not be stored") } // a script with only the token: no cookie, no CSRF header, plain text body api := &rig{t: t, srv: r.srv, cfg: r.cfg} resp, b := api.do("POST", "/api/notes", "text/plain", "call mum #family", "Authorization", "Bearer "+token) if resp.StatusCode != 201 { t.Fatalf("capture: %d %s", resp.StatusCode, b) } if !strings.Contains(string(b), `"body":"call mum #family"`) { t.Errorf("body %s", b) } if resp, _ := api.do("POST", "/api/notes", "text/plain", "x", "Authorization", "Bearer bsk_wrong"); resp.StatusCode != 401 { t.Errorf("wrong token: %d", resp.StatusCode) } // a token cannot mint more tokens if resp, _ := api.do("POST", "/api/tokens", "application/json", `{"name":"x"}`, "Authorization", "Bearer "+token); resp.StatusCode != 403 { t.Errorf("token making a token: %d", resp.StatusCode) } _, list := r.js("GET", "/api/tokens", nil) _ = list if resp, _ := r.js("DELETE", "/api/tokens/phone", nil); resp.StatusCode != 200 { t.Errorf("revoke: %d", resp.StatusCode) } if resp, _ := api.do("GET", "/api/notes", "", "", "Authorization", "Bearer "+token); resp.StatusCode != 401 { t.Errorf("revoked token: %d", resp.StatusCode) } } func TestChangePassword(t *testing.T) { r := newRig(t) r.signIn() old := r.cookie if resp, _ := r.js("POST", "/api/password", map[string]string{"current": "wrong", "new": "a brand new password"}); resp.StatusCode != 403 { t.Errorf("wrong current: %d", resp.StatusCode) } if resp, _ := r.js("POST", "/api/password", map[string]string{"current": password, "new": "short"}); resp.StatusCode != 400 { t.Errorf("short: %d", resp.StatusCode) } resp, _ := r.js("POST", "/api/password", map[string]string{"current": password, "new": "a brand new password"}) if resp.StatusCode != 200 { t.Fatalf("change: %d", resp.StatusCode) } // the old cookie stops working, the new one (sent back in the response) works if resp2, _ := r.do("GET", "/api/me", "", ""); resp2.StatusCode != 401 { t.Errorf("old cookie after a password change: %d", resp2.StatusCode) } for _, c := range resp.Cookies() { if c.Name == cookieName { r.cookie = c } } if r.cookie == old { t.Fatal("no new cookie") } if resp2, _ := r.do("GET", "/api/me", "", ""); resp2.StatusCode != 200 { t.Errorf("new cookie: %d", resp2.StatusCode) } } func TestExport(t *testing.T) { r := newRig(t) r.signIn() r.js("POST", "/api/notes", map[string]string{"body": "one"}) r.js("POST", "/api/notes", map[string]string{"body": "two"}) resp, b := r.do("GET", "/api/export", "", "") if resp.StatusCode != 200 || !strings.Contains(resp.Header.Get("Content-Disposition"), "attachment") { t.Fatalf("export: %d %v", resp.StatusCode, resp.Header) } zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) if err != nil || len(zr.File) != 2 { t.Fatalf("zip: %v, %d files", err, len(zr.File)) } } func TestFailCapitalizes(t *testing.T) { w := httptest.NewRecorder() fail(w, 400, "écrit mal") if !strings.Contains(w.Body.String(), "Écrit mal") { t.Errorf("%s", w.Body.String()) } }