Basking: sign in with Authentik only, pages that nest and link

Sign-in is now OpenID Connect against Authentik (authorization code flow
with PKCE, state and nonce). The password, API tokens and the add command
are gone; each person who signs in gets a private notebook in a folder
named from a hash of who they are. An optional allowed list is checked on
every request.

Notes become pages with titles, a tree of subpages, [[links]] with
backlinks that follow renames, a Ctrl+K finder, slash commands, list
continuation, and Write and Read modes. Autosave keeps the text through a
dropped connection or an expired session. Export opens as an Obsidian vault.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-31 22:51:26 -06:00
1 parent b548faa258
commit 98f7730913
19 files changed
+3291 -1578

No files matched your search

+528 -253
View File
@@ -3,337 +3,612 @@ package web
import (
"archive/zip"
"bytes"
"encoding/base64"
"encoding/json"
"io"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/store"
"git.veillax.com/Veillax/basking/internal/oidc"
)
const password = "correct horse battery"
type rig struct {
t *testing.T
srv *httptest.Server
cfg *config.Holder
cookie *http.Cookie
// idp is a stand-in for Authentik: it publishes its metadata and trades the code "good" for an ID
// token carrying whatever claims the test set.
type idp struct {
srv *httptest.Server
mu sync.Mutex
nonce string
challenge string
claims map[string]any
down bool
}
func newRig(t *testing.T) *rig {
func newIdP(t *testing.T) *idp {
p := &idp{}
mux := http.NewServeMux()
mux.HandleFunc("/app/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"issuer": p.srv.URL + "/app/", "authorization_endpoint": p.srv.URL + "/authorize", "token_endpoint": p.srv.URL + "/token"})
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
p.mu.Lock()
defer p.mu.Unlock()
r.ParseForm()
if p.down {
http.Error(w, "boom", http.StatusBadGateway)
return
}
if r.Form.Get("code") != "good" || r.Form.Get("client_secret") != "shh" || r.Form.Get("grant_type") != "authorization_code" ||
oidc.Challenge(r.Form.Get("code_verifier")) != p.challenge || r.Form.Get("redirect_uri") != "http://127.0.0.1:8490/auth/callback" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"})
return
}
c := map[string]any{"iss": p.srv.URL + "/app/", "aud": "basking-client", "exp": time.Now().Add(time.Hour).Unix(), "nonce": p.nonce}
for k, v := range p.claims {
c[k] = v
}
raw, _ := json.Marshal(c)
enc := base64.RawURLEncoding.EncodeToString
json.NewEncoder(w).Encode(map[string]string{"id_token": enc([]byte(`{"alg":"none"}`)) + "." + enc(raw) + ".sig", "access_token": "x"})
})
p.srv = httptest.NewServer(mux)
t.Cleanup(p.srv.Close)
return p
}
type env struct {
t *testing.T
s *Server
h http.Handler
idp *idp
dir string
cfg config.Config
}
func setup(t *testing.T, tweak func(map[string]any)) *env {
t.Helper()
p := newIdP(t)
dir := t.TempDir()
h, err := config.Load(filepath.Join(dir, "basking.json"))
conf := map[string]any{
"data_dir": filepath.Join(dir, "data"), "public_url": "http://127.0.0.1:8490", "secret": "test-secret",
"oidc": map[string]string{"issuer": p.srv.URL + "/app/", "client_id": "basking-client", "client_secret": "shh"},
}
if tweak != nil {
tweak(conf)
}
b, _ := json.Marshal(conf)
path := filepath.Join(dir, "basking.json")
os.WriteFile(path, b, 0o600)
h, err := config.Load(path)
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"))
s, err := New(h, "test")
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}
return &env{t: t, s: s, h: s.Handler(), idp: p, dir: dir, cfg: h.Get()}
}
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)
func (e *env) do(method, path string, body any, cookies []*http.Cookie, hdr ...string) *httptest.ResponseRecorder {
e.t.Helper()
var rd *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
} else {
rd = bytes.NewReader(nil)
}
for i := 0; i+1 < len(headers); i += 2 {
req.Header.Set(headers[i], headers[i+1])
r := httptest.NewRequest(method, path, rd)
if body != nil {
r.Header.Set("Content-Type", "application/json")
}
if r.cookie != nil {
req.AddCookie(r.cookie)
if method != http.MethodGet {
r.Header.Set("X-Basking", "1")
}
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
for i := 0; i+1 < len(hdr); i += 2 {
if hdr[i+1] == "" {
r.Header.Del(hdr[i])
} else {
r.Header.Set(hdr[i], hdr[i+1])
}
}
if r.cookie == nil {
r.t.Fatal("no session cookie")
for _, c := range cookies {
r.AddCookie(c)
}
w := httptest.NewRecorder()
e.h.ServeHTTP(w, r)
return w
}
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)
func cookieNamed(w *httptest.ResponseRecorder, name string) *http.Cookie {
for _, c := range w.Result().Cookies() {
if c.Name == name {
return c
}
}
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")
return nil
}
func jsonOf(t *testing.T, w *httptest.ResponseRecorder, v any) {
t.Helper()
if err := json.Unmarshal(w.Body.Bytes(), v); err != nil {
t.Fatalf("bad json %q: %v", w.Body.String(), err)
}
}
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)
// begin starts a sign-in and returns the redirect the browser would follow and the flow cookie.
func (e *env) begin(next string) (*url.URL, *http.Cookie) {
e.t.Helper()
w := e.do("GET", "/auth/login?next="+url.QueryEscape(next), nil, nil)
if w.Code != http.StatusSeeOther {
e.t.Fatalf("login: %d %s", w.Code, w.Body)
}
if resp, _ := r.js("POST", "/api/login", map[string]string{"password": "wrong"}); resp.StatusCode != 401 {
t.Errorf("wrong password: %d", resp.StatusCode)
to, _ := url.Parse(w.Header().Get("Location"))
return to, cookieNamed(w, flowCookie)
}
// signIn runs the whole flow for a person and returns their session cookie.
func (e *env) signIn(claims map[string]any) []*http.Cookie {
e.t.Helper()
to, flow := e.begin("")
e.idp.mu.Lock()
e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), claims
e.idp.mu.Unlock()
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow})
ck := cookieNamed(w, sessionCookie)
if w.Code != http.StatusSeeOther || ck == nil {
e.t.Fatalf("callback: %d %s -> %s", w.Code, w.Body, w.Header().Get("Location"))
}
r.signIn()
resp, _ := r.do("GET", "/api/me", "", "")
if resp.StatusCode != 200 {
t.Fatalf("me: %d", resp.StatusCode)
return []*http.Cookie{ck}
}
var alice = map[string]any{"sub": "alice-sub", "email": "alice@example.com", "name": "Alice", "preferred_username": "alice"}
var bob = map[string]any{"sub": "bob-sub", "email": "bob@example.com", "name": "Bob", "preferred_username": "bob"}
func TestNothingWorksWithoutSigningIn(t *testing.T) {
e := setup(t, nil)
for _, path := range []string{"/api/me", "/api/pages", "/api/notes", "/api/notes/abc", "/api/find?q=x", "/api/export"} {
if w := e.do("GET", path, nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("GET %s without a session: %d", path, w.Code)
}
}
if csp := resp.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") {
t.Errorf("CSP %q", csp)
if w := e.do("POST", "/api/notes", map[string]string{"body": "x"}, nil); w.Code != http.StatusUnauthorized {
t.Errorf("POST without a session: %d", w.Code)
}
if !r.cookie.HttpOnly || r.cookie.SameSite != http.SameSiteStrictMode {
t.Errorf("cookie flags %+v", r.cookie)
if w := e.do("GET", "/", nil, nil); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Basking") {
t.Errorf("the sign-in page itself must load: %d", w.Code)
}
// 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)
if w := e.do("GET", "/healthz", nil, nil); w.Code != http.StatusOK {
t.Errorf("healthz: %d", w.Code)
}
// 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)
if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: "garbage"}}); w.Code != http.StatusUnauthorized {
t.Errorf("garbage cookie: %d", w.Code)
}
}
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
func TestSignInFlow(t *testing.T) {
e := setup(t, nil)
to, flow := e.begin("#/p/abc-123")
q := to.Query()
if !strings.HasPrefix(to.String(), e.idp.srv.URL+"/authorize?") {
t.Fatalf("goes to Authentik: %s", to)
}
if last != 429 {
t.Fatalf("after many failures got %d", last)
for k, want := range map[string]string{"response_type": "code", "client_id": "basking-client", "redirect_uri": "http://127.0.0.1:8490/auth/callback",
"scope": "openid profile email", "code_challenge_method": "S256"} {
if q.Get(k) != want {
t.Errorf("%s = %q, want %q", k, q.Get(k), want)
}
}
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)
for _, k := range []string{"state", "nonce", "code_challenge"} {
if len(q.Get(k)) < 16 {
t.Errorf("%s is too short: %q", k, q.Get(k))
}
}
if !flow.HttpOnly || flow.SameSite != http.SameSiteLaxMode || flow.Path != "/auth/" {
t.Errorf("flow cookie: %+v", flow)
}
e.idp.nonce, e.idp.challenge, e.idp.claims = q.Get("nonce"), q.Get("code_challenge"), alice
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(q.Get("state")), nil, []*http.Cookie{flow})
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/#/p/abc-123" {
t.Fatalf("callback: %d -> %s", w.Code, w.Header().Get("Location"))
}
ses := cookieNamed(w, sessionCookie)
if ses == nil || !ses.HttpOnly || ses.SameSite != http.SameSiteLaxMode || ses.Secure {
t.Fatalf("session cookie: %+v", ses)
}
if c := cookieNamed(w, flowCookie); c == nil || c.MaxAge >= 0 {
t.Error("the flow cookie should be cleared after use")
}
var me struct{ Name, Email string }
jsonOf(t, e.do("GET", "/api/me", nil, []*http.Cookie{ses}), &me)
if me.Name != "Alice" || me.Email != "alice@example.com" {
t.Fatalf("me: %+v", me)
}
// the same flow cookie cannot be used twice by a browser that kept it, and a stale state is refused
if w := e.do("GET", "/auth/callback?code=good&state=wrong", nil, []*http.Cookie{flow}); w.Header().Get("Location") != "/?signin=failed" {
t.Errorf("wrong state: %s", w.Header().Get("Location"))
}
}
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)
func TestSignInIsRefusedWhenAnythingIsOff(t *testing.T) {
e := setup(t, nil)
try := func(name string, mutate func(to *url.URL, flow **http.Cookie) string, want string) {
t.Helper()
to, flow := e.begin("")
e.idp.mu.Lock()
e.idp.nonce, e.idp.challenge, e.idp.claims, e.idp.down = to.Query().Get("nonce"), to.Query().Get("code_challenge"), alice, false
e.idp.mu.Unlock()
target := mutate(to, &flow)
var cks []*http.Cookie
if flow != nil {
cks = append(cks, flow)
}
w := e.do("GET", target, nil, cks)
if got := w.Header().Get("Location"); got != "/?signin="+want || cookieNamed(w, sessionCookie) != nil {
t.Errorf("%s: went to %q (%d), session %v; want %s", name, got, w.Code, cookieNamed(w, sessionCookie), want)
}
}
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)
good := func(to *url.URL) string {
return "/auth/callback?code=good&state=" + url.QueryEscape(to.Query().Get("state"))
}
_, 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)
}
try("no flow cookie", func(to *url.URL, f **http.Cookie) string { *f = nil; return good(to) }, "expired")
try("tampered flow cookie", func(to *url.URL, f **http.Cookie) string { c := *f; c.Value = "x" + c.Value; return good(to) }, "expired")
try("wrong state", func(to *url.URL, f **http.Cookie) string { return "/auth/callback?code=good&state=nope" }, "failed")
try("no code", func(to *url.URL, f **http.Cookie) string { return "/auth/callback?state=" + to.Query().Get("state") }, "failed")
try("denied at Authentik", func(to *url.URL, f **http.Cookie) string {
return "/auth/callback?error=access_denied&state=" + to.Query().Get("state")
}, "denied")
try("bad code", func(to *url.URL, f **http.Cookie) string {
return "/auth/callback?code=stolen&state=" + url.QueryEscape(to.Query().Get("state"))
}, "failed")
try("wrong nonce", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.nonce = "other"
e.idp.mu.Unlock()
return good(to)
}, "failed")
try("wrong audience", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.claims = map[string]any{"sub": "x", "aud": "someone-else"}
e.idp.mu.Unlock()
return good(to)
}, "failed")
try("expired token", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.claims = map[string]any{"sub": "x", "exp": time.Now().Add(-time.Hour).Unix()}
e.idp.mu.Unlock()
return good(to)
}, "failed")
try("wrong issuer", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.claims = map[string]any{"sub": "x", "iss": "https://evil.example.com/"}
e.idp.mu.Unlock()
return good(to)
}, "failed")
try("no subject", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.claims = map[string]any{"sub": ""}
e.idp.mu.Unlock()
return good(to)
}, "failed")
try("Authentik is down", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.down = true
e.idp.mu.Unlock()
return good(to)
}, "unavailable")
// 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)
// and a next address that points anywhere but inside Basking is dropped
for _, bad := range []string{"https://evil.example.com", "//evil.example.com", "/api/export", "#/<script>", "javascript:alert(1)"} {
to, flow := e.begin(bad)
e.idp.mu.Lock()
e.idp.down = false
e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), alice
e.idp.mu.Unlock()
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow})
if w.Header().Get("Location") != "/" {
t.Errorf("next %q led to %q", bad, w.Header().Get("Location"))
}
}
}
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 TestSignInWhenAuthentikIsUnreachable(t *testing.T) {
e := setup(t, nil)
e.idp.srv.Close()
w := e.do("GET", "/auth/login", nil, nil)
if w.Header().Get("Location") != "/?signin=unavailable" {
t.Fatalf("went to %q", w.Header().Get("Location"))
}
}
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)
func TestAllowedList(t *testing.T) {
e := setup(t, func(c map[string]any) { c["allowed"] = []string{"ALICE@example.com", "carol"} })
to, flow := e.begin("")
e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), bob
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow})
if w.Header().Get("Location") != "/?signin=denied" || cookieNamed(w, sessionCookie) != nil {
t.Fatalf("Bob should be turned away: %s", w.Header().Get("Location"))
}
token := tok["token"].(string)
if !strings.HasPrefix(token, "bsk_") {
t.Fatalf("token %q", token)
ses := e.signIn(alice) // matched by email, whatever the case
if w := e.do("GET", "/api/me", nil, ses); w.Code != 200 {
t.Fatalf("Alice: %d", w.Code)
}
if resp, _ := r.js("POST", "/api/tokens", map[string]string{"name": "PHONE"}); resp.StatusCode != 400 {
t.Errorf("duplicate name: %d", resp.StatusCode)
if c := e.signIn(map[string]any{"sub": "carol-sub", "preferred_username": "Carol"}); c == nil { // matched by username
t.Fatal("Carol should get in")
}
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)
// taking someone off the list ends their session at once
e2 := setup(t, func(c map[string]any) { c["allowed"] = []string{"someone-else"} })
old := seal(e2.cfg, "session", sessionData{Sub: "alice-sub", Email: "alice@example.com", Exp: time.Now().Add(time.Hour).Unix()})
if w := e2.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: old}}); w.Code != http.StatusUnauthorized {
t.Fatalf("a valid cookie for someone no longer allowed: %d", w.Code)
}
}
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)
func TestSessionsAreSignedAndExpire(t *testing.T) {
e := setup(t, nil)
ses := e.signIn(alice)
if w := e.do("GET", "/api/me", nil, ses); w.Code != 200 {
t.Fatal("valid session refused")
}
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
payload, sig, _ := strings.Cut(ses[0].Value, ".")
forged := base64.RawURLEncoding.EncodeToString([]byte(`{"s":"mallory","n":"Mallory","x":99999999999}`))
for name, v := range map[string]string{"forged payload": forged + "." + sig, "no signature": payload, "empty": ""} {
if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: v}}); w.Code != http.StatusUnauthorized {
t.Errorf("%s accepted: %d", name, w.Code)
}
}
if r.cookie == old {
t.Fatal("no new cookie")
expired := seal(e.cfg, "session", sessionData{Sub: "alice-sub", Exp: time.Now().Add(-time.Minute).Unix()})
if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: expired}}); w.Code != http.StatusUnauthorized {
t.Errorf("expired session accepted: %d", w.Code)
}
if resp2, _ := r.do("GET", "/api/me", "", ""); resp2.StatusCode != 200 {
t.Errorf("new cookie: %d", resp2.StatusCode)
// a cookie for another purpose, or signed with another secret, is not a session
other := e.cfg
other.Secret = "different"
for name, v := range map[string]string{
"flow cookie as session": seal(e.cfg, "flow", sessionData{Sub: "x", Exp: time.Now().Add(time.Hour).Unix()}),
"other secret": seal(other, "session", sessionData{Sub: "x", Exp: time.Now().Add(time.Hour).Unix()}),
} {
if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: v}}); w.Code != http.StatusUnauthorized {
t.Errorf("%s accepted: %d", name, w.Code)
}
}
w := e.do("POST", "/api/logout", nil, ses)
if c := cookieNamed(w, sessionCookie); w.Code != 200 || c == nil || c.MaxAge >= 0 {
t.Fatalf("logout: %d %+v", w.Code, c)
}
}
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)
func TestWritesNeedTheHeader(t *testing.T) {
e := setup(t, nil)
ses := e.signIn(alice)
if w := e.do("POST", "/api/notes", map[string]string{"body": "x"}, ses, "X-Basking", ""); w.Code != http.StatusForbidden {
t.Fatalf("a write without the header: %d", w.Code)
}
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))
if w := e.do("DELETE", "/api/trash", nil, ses, "X-Basking", ""); w.Code != http.StatusForbidden {
t.Fatalf("a delete without the header: %d", w.Code)
}
}
func TestEachPersonHasTheirOwnNotebook(t *testing.T) {
e := setup(t, nil)
a, b := e.signIn(alice), e.signIn(bob)
var mine struct{ ID string }
jsonOf(t, e.do("POST", "/api/notes", map[string]string{"title": "Alice's secret", "body": "dear diary"}, a), &mine)
if mine.ID == "" {
t.Fatal("no note made")
}
if w := e.do("GET", "/api/notes/"+mine.ID, nil, b); w.Code != http.StatusNotFound {
t.Fatalf("Bob can see Alice's page: %d", w.Code)
}
var list struct{ Notes []struct{ ID string } }
jsonOf(t, e.do("GET", "/api/notes", nil, b), &list)
if len(list.Notes) != 0 {
t.Fatalf("Bob's list: %+v", list)
}
var found struct{ Hits []struct{ ID string } }
jsonOf(t, e.do("GET", "/api/find?q=diary", nil, b), &found)
if len(found.Hits) != 0 {
t.Fatalf("Bob can search Alice's notes: %+v", found)
}
if w := e.do("PUT", "/api/notes/"+mine.ID, map[string]string{"body": "hacked"}, b); w.Code != http.StatusNotFound {
t.Fatalf("Bob can edit Alice's page: %d", w.Code)
}
// on disk each has a folder named from a hash, never from anything they chose
entries, _ := os.ReadDir(filepath.Join(e.dir, "data", "users"))
if len(entries) != 2 {
t.Fatalf("expected two notebooks, got %d", len(entries))
}
for _, d := range entries {
if strings.Contains(d.Name(), "alice") || strings.Contains(d.Name(), "bob") || len(d.Name()) != 32 {
t.Errorf("folder name %q", d.Name())
}
}
}
func TestPageLifecycle(t *testing.T) {
e := setup(t, nil)
ses := e.signIn(alice)
type pg struct {
ID, Title, Name, Parent, Body, Updated string
Backlinks []struct{ ID, Name string }
Error, Code string
Note struct{ Body string }
}
call := func(method, path string, body any) (pg, int) {
w := e.do(method, path, body, ses)
var p pg
json.Unmarshal(w.Body.Bytes(), &p)
return p, w.Code
}
plans, code := call("POST", "/api/notes", map[string]string{"title": "Plans", "body": "first"})
if code != 201 || plans.Name != "Plans" {
t.Fatalf("create: %d %+v", code, plans)
}
if _, code := call("POST", "/api/notes", map[string]string{"title": "plans"}); code != 422 {
t.Fatalf("duplicate title on create: %d", code)
}
trip, _ := call("POST", "/api/notes", map[string]string{"title": "Trip", "body": "see [[Plans]]", "parent": plans.ID})
if trip.Parent != plans.ID {
t.Fatalf("subpage: %+v", trip)
}
if _, code := call("POST", "/api/notes", map[string]string{}); code != 400 {
t.Fatalf("empty create: %d", code)
}
got, _ := call("GET", "/api/notes/"+plans.ID, nil)
if len(got.Backlinks) != 1 || got.Backlinks[0].Name != "Trip" {
t.Fatalf("backlinks: %+v", got.Backlinks)
}
upd, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"body": "second", "base": plans.Updated})
if code != 200 || upd.Body != "second" {
t.Fatalf("update: %d %+v", code, upd)
}
stale, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"body": "third", "base": plans.Updated})
if code != 409 || stale.Note.Body != "second" {
t.Fatalf("stale update: %d %+v", code, stale)
}
bad, code := call("PUT", "/api/notes/"+trip.ID, map[string]any{"title": "PLANS"})
if code != 422 || bad.Code != "title_taken" || !strings.HasPrefix(bad.Error, "Another") {
t.Fatalf("taken title: %d %+v", code, bad)
}
if _, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"title": "Plans 2027"}); code != 200 {
t.Fatalf("rename: %d", code)
}
if moved, _ := call("GET", "/api/notes/"+trip.ID, nil); moved.Body != "see [[Plans 2027]]" {
t.Fatalf("links follow a rename: %q", moved.Body)
}
if _, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"parent": trip.ID}); code != 400 {
t.Fatalf("a page inside its own child: %d", code)
}
var found struct{ Hits []struct{ ID, Name string } }
jsonOf(t, e.do("GET", "/api/find?q=2027", nil, ses), &found)
if len(found.Hits) != 2 || found.Hits[0].ID != plans.ID { // Trip mentions it in a link, but the title match leads
t.Fatalf("find: %+v", found)
}
var tree struct {
Pages []struct{ ID, Parent string }
Counts struct{ All, Trash int }
}
jsonOf(t, e.do("GET", "/api/pages", nil, ses), &tree)
if len(tree.Pages) != 2 || tree.Counts.All != 2 {
t.Fatalf("pages: %+v", tree)
}
if _, code := call("DELETE", "/api/notes/"+plans.ID, nil); code != 200 {
t.Fatalf("trash: %d", code)
}
if kid, _ := call("GET", "/api/notes/"+trip.ID, nil); kid.Parent != "" {
t.Fatalf("subpages move up when their page is trashed: %+v", kid)
}
if _, code := call("DELETE", "/api/notes/"+trip.ID+"?purge=1", nil); code != 400 {
t.Fatalf("purging a live page: %d", code)
}
if _, code := call("POST", "/api/notes/"+plans.ID+"/restore", nil); code != 200 {
t.Fatalf("restore: %d", code)
}
if _, code := call("GET", "/api/notes/..%2f..%2fetc%2fpasswd", nil); code != 404 {
t.Fatalf("path tricks: %d", code)
}
d, code := call("POST", "/api/daily", map[string]string{"date": "2026-10-31"})
if code != 200 || d.Title != "2026-10-31" {
t.Fatalf("daily: %d %+v", code, d)
}
if _, code := call("POST", "/api/daily", map[string]string{"date": "nope"}); code != 400 {
t.Fatalf("bad date: %d", code)
}
w := e.do("GET", "/api/export", nil, ses)
zr, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
if err != nil || w.Header().Get("Content-Type") != "application/zip" {
t.Fatalf("export: %v %s", err, w.Header().Get("Content-Type"))
}
var names []string
for _, f := range zr.File {
names = append(names, f.Name)
}
if fmt.Sprint(names) != "[basking/Journal/2026-10-31.md basking/Plans 2027.md basking/Trip.md]" && len(names) != 3 {
t.Fatalf("export has %v", names)
}
}
func TestSecurityHeaders(t *testing.T) {
e := setup(t, nil)
w := e.do("GET", "/", nil, nil)
csp := w.Header().Get("Content-Security-Policy")
for _, want := range []string{"default-src 'none'", "script-src 'self'", "style-src 'self'", "frame-ancestors 'none'"} {
if !strings.Contains(csp, want) {
t.Errorf("CSP lacks %q: %s", want, csp)
}
}
if w.Header().Get("X-Frame-Options") != "DENY" || w.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Error("framing and sniffing headers missing")
}
if w.Header().Get("Strict-Transport-Security") != "" {
t.Error("no HSTS over plain http")
}
if got := e.do("GET", "/api/me", nil, nil).Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("API responses must not be cached: %q", got)
}
s := setup(t, func(c map[string]any) { c["public_url"] = "https://notes.example.com/" })
if s.do("GET", "/", nil, nil).Header().Get("Strict-Transport-Security") == "" {
t.Error("HSTS expected behind https")
}
if !s.cfg.Secure() || s.cfg.PublicURL != "https://notes.example.com" {
t.Errorf("public url: %+v", s.cfg)
}
}
func TestServerWillNotStartHalfConfigured(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "basking.json")
os.WriteFile(path, []byte(`{"public_url":"https://notes.example.com"}`), 0o600)
h, _ := config.Load(path)
_, err := New(h, "t")
if err == nil {
t.Fatal("started without Authentik settings")
}
for _, want := range []string{"oidc.issuer", "BASKING_OIDC_CLIENT_ID", "client_secret", "/auth/callback"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the message should mention %q:\n%v", want, err)
}
}
os.WriteFile(path, []byte(`{"public_url":"http://notes.example.com","oidc":{"issuer":"https://a.example.com/","client_id":"x","client_secret":"y"}}`), 0o600)
h, _ = config.Load(path)
if _, err := New(h, "t"); err == nil || !strings.Contains(err.Error(), "https") {
t.Errorf("plain http on a real host should be refused: %v", err)
}
t.Setenv("BASKING_PUBLIC_URL", "https://notes.example.com")
h, _ = config.Load(path)
if _, err := New(h, "t"); err != nil {
t.Errorf("environment settings should override the file: %v", err)
}
if b, _ := os.ReadFile(path); strings.Contains(string(b), "notes.example.com\"") && strings.Contains(string(b), "https://notes.example.com") {
t.Error("environment values must never be written to the 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())
fail(w, 400, "éclair is ready")
var out map[string]string
json.Unmarshal(w.Body.Bytes(), &out)
if out["error"] != "Éclair is ready" {
t.Fatalf("%q", out["error"])
}
}