Basking: a place to dump thoughts, jot notes and keep a journal

Plain markdown files, tags by typing #hashtags, a page per day, search,
pin and trash with undo, autosave with conflict warnings, API tokens,
zip export, and the Lightwell dashboard. Includes install.sh, CI and
release workflows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-31 20:16:33 -06:00
commit b548faa258
23 files changed
+4026

No files matched your search

+339
View File
@@ -0,0 +1,339 @@
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())
}
}