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:
commit
b548faa258
23 files changed
+4026
No files matched your search
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user