Files
Mako/internal/web/web_test.go
T
VeillaxandClaude Sonnet 5.5 e3147597e8
CI / test (push) Successful in 2m9s
Release / release (push) Successful in 2m16s
Rename Nurse to Mako
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 00:07:43 -06:00

615 lines
23 KiB
Go

package web
import (
"archive/zip"
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.veillax.com/Veillax/mako/internal/config"
"git.veillax.com/Veillax/mako/internal/oidc"
)
// 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 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": "mako-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()
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": "mako-client", "client_secret": "shh"},
}
if tweak != nil {
tweak(conf)
}
b, _ := json.Marshal(conf)
path := filepath.Join(dir, "mako.json")
os.WriteFile(path, b, 0o600)
h, err := config.Load(path)
if err != nil {
t.Fatal(err)
}
s, err := New(h, "test")
if err != nil {
t.Fatal(err)
}
return &env{t: t, s: s, h: s.Handler(), idp: p, dir: dir, cfg: h.Get()}
}
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)
}
r := httptest.NewRequest(method, path, rd)
if body != nil {
r.Header.Set("Content-Type", "application/json")
}
if method != http.MethodGet {
r.Header.Set("X-Mako", "1")
}
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])
}
}
for _, c := range cookies {
r.AddCookie(c)
}
w := httptest.NewRecorder()
e.h.ServeHTTP(w, r)
return w
}
func cookieNamed(w *httptest.ResponseRecorder, name string) *http.Cookie {
for _, c := range w.Result().Cookies() {
if c.Name == name {
return c
}
}
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)
}
}
// 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)
}
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"))
}
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 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 w := e.do("GET", "/", nil, nil); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Mako") {
t.Errorf("the sign-in page itself must load: %d", w.Code)
}
if w := e.do("GET", "/healthz", nil, nil); w.Code != http.StatusOK {
t.Errorf("healthz: %d", w.Code)
}
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 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)
}
for k, want := range map[string]string{"response_type": "code", "client_id": "mako-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)
}
}
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 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)
}
}
good := func(to *url.URL) string {
return "/auth/callback?code=good&state=" + url.QueryEscape(to.Query().Get("state"))
}
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")
// and a next address that points anywhere but inside Mako 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 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 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"))
}
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 c := e.signIn(map[string]any{"sub": "carol-sub", "preferred_username": "Carol"}); c == nil { // matched by username
t.Fatal("Carol should get in")
}
// 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 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")
}
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)
}
}
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)
}
// 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 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-Mako", ""); w.Code != http.StatusForbidden {
t.Fatalf("a write without the header: %d", w.Code)
}
if w := e.do("DELETE", "/api/trash", nil, ses, "X-Mako", ""); 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) != "[mako/Journal/2026-10-31.md mako/Plans 2027.md mako/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, "mako.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", "MAKO_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("MAKO_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, "é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"])
}
}