A page, including a journal day, can be moved into the unlocked vault with its id kept. Links to it elsewhere become [[locked:id]], shown as Locked Page until the vault is unlocked, so its title is kept only in the vault. Move out reverses it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
907 lines
35 KiB
Go
907 lines
35 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"])
|
|
}
|
|
}
|
|
|
|
func TestNothingIsOnDiskInTheClear(t *testing.T) {
|
|
e := setup(t, nil)
|
|
a := e.signIn(alice)
|
|
e.do("POST", "/api/notes", map[string]string{"title": "Harbour plans", "body": "meet at midnight"}, a)
|
|
e.do("POST", "/api/notes", map[string]string{"body": "a quick thought"}, a)
|
|
files := 0
|
|
filepath.WalkDir(filepath.Join(e.dir, "data"), func(p string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil
|
|
}
|
|
files++
|
|
b, _ := os.ReadFile(p)
|
|
for _, s := range []string{"Harbour", "midnight", "quick thought", "Alice", "alice@"} {
|
|
if bytes.Contains(b, []byte(s)) {
|
|
t.Errorf("%s holds %q in the clear", p, s)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if files < 2 {
|
|
t.Fatalf("only %d files written", files)
|
|
}
|
|
}
|
|
|
|
func TestKeyIsMadeOnceAndKept(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "mako.json")
|
|
h, err := config.Load(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !h.NeedsSave() {
|
|
t.Fatal("a new file must be written")
|
|
}
|
|
h.Save()
|
|
key := h.Get().Key
|
|
if len(key) < 32 {
|
|
t.Fatalf("key %q", key)
|
|
}
|
|
h2, _ := config.Load(path)
|
|
if h2.NeedsSave() || h2.Get().Key != key {
|
|
t.Fatal("the key changed or was made again")
|
|
}
|
|
// a file from before encryption has a secret but no key: one is made and must be written
|
|
os.WriteFile(path, []byte(`{"secret":"old-secret"}`), 0o600)
|
|
h3, _ := config.Load(path)
|
|
if !h3.NeedsSave() || len(h3.Get().Key) < 32 {
|
|
t.Fatal("an old file gets no key")
|
|
}
|
|
// a key from the environment wins and is never written to the file
|
|
t.Setenv("MAKO_KEY", strings.Repeat("k", 40))
|
|
os.WriteFile(path, []byte(`{"secret":"old-secret"}`), 0o600)
|
|
h4, _ := config.Load(path)
|
|
h4.Save()
|
|
if h4.Get().Key != strings.Repeat("k", 40) {
|
|
t.Fatal("the environment key was not used")
|
|
}
|
|
if b, _ := os.ReadFile(path); strings.Contains(string(b), "kkkk") || strings.Contains(string(b), `"key"`) {
|
|
t.Fatalf("the environment key was written to the file: %s", b)
|
|
}
|
|
t.Setenv("MAKO_KEY", "too short")
|
|
if err := h4.Get().Check(); err == nil || !strings.Contains(err.Error(), "encryption key") {
|
|
t.Fatalf("a short key was accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---- the vault ----
|
|
|
|
func withVault(t *testing.T) *env {
|
|
t.Helper()
|
|
old := vaultIterations
|
|
vaultIterations = 1000
|
|
t.Cleanup(func() { vaultIterations = old })
|
|
return setup(t, nil)
|
|
}
|
|
|
|
// vaultCookies returns the sign-in cookies plus the vault token from a response.
|
|
func vaultCookies(ses []*http.Cookie, w *httptest.ResponseRecorder) []*http.Cookie {
|
|
if v := cookieNamed(w, vaultCookie); v != nil && v.Value != "" {
|
|
return append(append([]*http.Cookie{}, ses...), v)
|
|
}
|
|
return ses
|
|
}
|
|
|
|
func TestVaultLifecycle(t *testing.T) {
|
|
e := withVault(t)
|
|
a := e.signIn(alice)
|
|
|
|
var info struct{ Exists, Unlocked bool }
|
|
jsonOf(t, e.do("GET", "/api/vault", nil, a), &info)
|
|
if info.Exists || info.Unlocked {
|
|
t.Fatalf("a new person already has a vault: %+v", info)
|
|
}
|
|
if w := e.do("GET", "/api/vault/notes", nil, a); w.Code != http.StatusLocked {
|
|
t.Fatalf("a vault that does not exist is not locked: %d", w.Code)
|
|
}
|
|
if w := e.do("POST", "/api/vault", map[string]string{"passphrase": "short"}, a); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("a short passphrase: %d", w.Code)
|
|
}
|
|
w := e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("create: %d %s", w.Code, w.Body)
|
|
}
|
|
tok := cookieNamed(w, vaultCookie)
|
|
if tok == nil || !tok.HttpOnly || tok.Value == "" {
|
|
t.Fatalf("vault cookie: %+v", tok)
|
|
}
|
|
open := vaultCookies(a, w)
|
|
|
|
// a page in the vault
|
|
var n struct{ ID string }
|
|
if w := e.do("POST", "/api/vault/notes", map[string]string{"title": "Bank codes", "body": "hunter2 and the safe number"}, open); w.Code != http.StatusOK && w.Code != http.StatusCreated {
|
|
t.Fatalf("create page: %d %s", w.Code, w.Body)
|
|
} else {
|
|
jsonOf(t, w, &n)
|
|
}
|
|
if w := e.do("GET", "/api/vault/notes/"+n.ID, nil, open); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "hunter2") {
|
|
t.Fatalf("read page: %d %s", w.Code, w.Body)
|
|
}
|
|
|
|
// it is nowhere in the ordinary notebook
|
|
for _, path := range []string{"/api/notes", "/api/pages", "/api/find?q=hunter2", "/api/find?q=Bank"} {
|
|
if w := e.do("GET", path, nil, open); strings.Contains(w.Body.String(), "hunter2") || strings.Contains(w.Body.String(), "Bank codes") {
|
|
t.Fatalf("%s shows the vault: %s", path, w.Body)
|
|
}
|
|
}
|
|
if w := e.do("GET", "/api/notes/"+n.ID, nil, open); w.Code != http.StatusNotFound {
|
|
t.Fatalf("a vault page opens as an ordinary one: %d", w.Code)
|
|
}
|
|
if w := e.do("GET", "/api/export", nil, open); bytes.Contains(w.Body.Bytes(), []byte("hunter2")) {
|
|
t.Fatal("the vault is in the download")
|
|
}
|
|
var found struct{ Hits []struct{ ID string } }
|
|
jsonOf(t, e.do("GET", "/api/vault/find?q=safe", nil, open), &found)
|
|
if len(found.Hits) != 1 {
|
|
t.Fatalf("vault search: %+v", found)
|
|
}
|
|
|
|
// locking, and the old cookie no longer opens anything
|
|
if w := e.do("POST", "/api/vault/lock", nil, open); w.Code != http.StatusOK {
|
|
t.Fatalf("lock: %d", w.Code)
|
|
}
|
|
if w := e.do("GET", "/api/vault/notes/"+n.ID, nil, open); w.Code != http.StatusLocked {
|
|
t.Fatalf("the vault opened after locking: %d", w.Code)
|
|
}
|
|
|
|
// the wrong passphrase, then the right one
|
|
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "wrong horse battery"}, a); w.Code != http.StatusForbidden {
|
|
t.Fatalf("wrong passphrase: %d", w.Code)
|
|
}
|
|
w = e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "correct horse battery"}, a)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("unlock: %d %s", w.Code, w.Body)
|
|
}
|
|
if w := e.do("GET", "/api/vault/notes/"+n.ID, nil, vaultCookies(a, w)); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "hunter2") {
|
|
t.Fatalf("read after unlock: %d", w.Code)
|
|
}
|
|
if w := e.do("POST", "/api/vault", map[string]string{"passphrase": "another passphrase"}, a); w.Code != http.StatusConflict {
|
|
t.Fatalf("a second vault: %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestVaultIsEncryptedOnDiskAndPrivate(t *testing.T) {
|
|
e := withVault(t)
|
|
a, b := e.signIn(alice), e.signIn(bob)
|
|
w := e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a)
|
|
open := vaultCookies(a, w)
|
|
e.do("POST", "/api/vault/notes", map[string]string{"title": "Moonlight plan", "body": "tell nobody"}, open)
|
|
|
|
filepath.WalkDir(filepath.Join(e.dir, "data"), func(p string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil
|
|
}
|
|
raw, _ := os.ReadFile(p)
|
|
for _, s := range []string{"Moonlight", "tell nobody", "correct horse"} {
|
|
if bytes.Contains(raw, []byte(s)) {
|
|
t.Errorf("%s holds %q in the clear", p, s)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// Bob cannot use Alice's vault cookie, and has no vault of his own
|
|
stolen := append(append([]*http.Cookie{}, b...), cookieNamed(w, vaultCookie))
|
|
if w := e.do("GET", "/api/vault/notes", nil, stolen); w.Code != http.StatusLocked {
|
|
t.Fatalf("Bob opened Alice's vault with her cookie: %d", w.Code)
|
|
}
|
|
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "correct horse battery"}, b); w.Code != http.StatusNotFound {
|
|
t.Fatalf("Bob unlocking a vault he does not have: %d", w.Code)
|
|
}
|
|
// signing out locks it
|
|
e.do("POST", "/api/logout", nil, open)
|
|
if w := e.do("GET", "/api/vault/notes", nil, open); w.Code == http.StatusOK {
|
|
t.Fatal("the vault stayed open after signing out")
|
|
}
|
|
}
|
|
|
|
func TestVaultGuessingIsLimited(t *testing.T) {
|
|
e := withVault(t)
|
|
a := e.signIn(alice)
|
|
e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a)
|
|
e.do("POST", "/api/vault/lock", nil, a)
|
|
for i := 0; i < vaultTries; i++ {
|
|
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": fmt.Sprintf("guess number %d", i)}, a); w.Code != http.StatusForbidden {
|
|
t.Fatalf("guess %d: %d", i, w.Code)
|
|
}
|
|
}
|
|
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "correct horse battery"}, a); w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("even the right passphrase is held back after too many guesses: %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestVaultNeedsSignInAndHeader(t *testing.T) {
|
|
e := withVault(t)
|
|
if w := e.do("GET", "/api/vault", nil, nil); w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("no sign-in: %d", w.Code)
|
|
}
|
|
a := e.signIn(alice)
|
|
if w := e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a, "X-Mako", ""); w.Code != http.StatusForbidden {
|
|
t.Fatalf("no header: %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestMovePagesIntoAndOutOfTheVault(t *testing.T) {
|
|
e := withVault(t)
|
|
a := e.signIn(alice)
|
|
var secret, notes, day struct{ ID, Body, Title string }
|
|
jsonOf(t, e.do("POST", "/api/notes", map[string]string{"title": "Secret plan", "body": "the plan"}, a), &secret)
|
|
jsonOf(t, e.do("POST", "/api/notes", map[string]string{"title": "Notes", "body": "see [[Secret plan]] and [[secret plan|it]]"}, a), ¬es)
|
|
jsonOf(t, e.do("POST", "/api/daily", map[string]string{"date": "2026-10-31"}, a), &day)
|
|
e.do("PUT", "/api/notes/"+day.ID, map[string]string{"body": "dear diary"}, a)
|
|
|
|
// there is nowhere to put it until the vault is open
|
|
if w := e.do("POST", "/api/notes/"+secret.ID+"/vault", nil, a); w.Code != http.StatusLocked {
|
|
t.Fatalf("moving with no vault: %d", w.Code)
|
|
}
|
|
w := e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a)
|
|
open := vaultCookies(a, w)
|
|
|
|
for _, id := range []string{secret.ID, day.ID} {
|
|
if w := e.do("POST", "/api/notes/"+id+"/vault", nil, open); w.Code != http.StatusOK {
|
|
t.Fatalf("move %s in: %d %s", id, w.Code, w.Body)
|
|
}
|
|
}
|
|
if w := e.do("GET", "/api/notes/"+secret.ID, nil, open); w.Code != http.StatusNotFound {
|
|
t.Fatalf("the page is still in the notebook: %d", w.Code)
|
|
}
|
|
var in struct{ ID, Title, Body, Daily string }
|
|
jsonOf(t, e.do("GET", "/api/vault/notes/"+secret.ID, nil, open), &in)
|
|
if in.ID != secret.ID || in.Title != "Secret plan" || in.Body != "the plan" {
|
|
t.Fatalf("in the vault: %+v", in)
|
|
}
|
|
jsonOf(t, e.do("GET", "/api/vault/notes/"+day.ID, nil, open), &in)
|
|
if in.Daily != "2026-10-31" || in.Body != "dear diary" {
|
|
t.Fatalf("the day in the vault: %+v", in)
|
|
}
|
|
|
|
// the page that linked to it now shows a locked link and says nothing of the title
|
|
var n struct{ Body string }
|
|
jsonOf(t, e.do("GET", "/api/notes/"+notes.ID, nil, open), &n)
|
|
if want := "see [[locked:" + secret.ID + "]] and [[locked:" + secret.ID + "]]"; n.Body != want {
|
|
t.Fatalf("links: %q want %q", n.Body, want)
|
|
}
|
|
for _, path := range []string{"/api/notes", "/api/pages", "/api/find?q=Secret", "/api/find?q=diary"} {
|
|
if w := e.do("GET", path, nil, open); strings.Contains(w.Body.String(), "Secret plan") || strings.Contains(w.Body.String(), "diary") {
|
|
t.Fatalf("%s still shows the moved pages: %s", path, w.Body)
|
|
}
|
|
}
|
|
|
|
// the same day cannot be moved in twice
|
|
var again struct{ ID string }
|
|
jsonOf(t, e.do("POST", "/api/daily", map[string]string{"date": "2026-10-31"}, open), &again)
|
|
if w := e.do("POST", "/api/notes/"+again.ID+"/vault", nil, open); w.Code != http.StatusConflict {
|
|
t.Fatalf("a second journal page for the day: %d %s", w.Code, w.Body)
|
|
}
|
|
|
|
// and back out: the links name the page again
|
|
if w := e.do("POST", "/api/vault/notes/"+secret.ID+"/out", nil, open); w.Code != http.StatusOK {
|
|
t.Fatalf("move out: %d %s", w.Code, w.Body)
|
|
}
|
|
if w := e.do("GET", "/api/vault/notes/"+secret.ID, nil, open); w.Code != http.StatusNotFound {
|
|
t.Fatalf("the page is still in the vault: %d", w.Code)
|
|
}
|
|
jsonOf(t, e.do("GET", "/api/notes/"+secret.ID, nil, open), &in)
|
|
if in.Title != "Secret plan" || in.Body != "the plan" {
|
|
t.Fatalf("back in the notebook: %+v", in)
|
|
}
|
|
jsonOf(t, e.do("GET", "/api/notes/"+notes.ID, nil, open), &n)
|
|
if n.Body != "see [[Secret plan]] and [[Secret plan]]" {
|
|
t.Fatalf("links after coming back: %q", n.Body)
|
|
}
|
|
}
|