Files
Mako/internal/store/store_test.go
T
VeillaxandClaude Sonnet 5.5 9f2fe4a416
CI / test (push) Successful in 2m2s
Release / release (push) Successful in 2m15s
Encrypt pages at rest with a key per person
Pages are sealed with AES-256-GCM. Each person's key is derived (HKDF) from a master key in the settings file plus their Authentik issuer and subject. Plain files from earlier versions are read and re-saved encrypted. Mako refuses to open a notebook it cannot decrypt rather than overwrite it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 00:25:37 -06:00

579 lines
19 KiB
Go

package store
import (
"archive/zip"
"bytes"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"git.veillax.com/Veillax/mako/internal/vault"
)
func open(t *testing.T) (*Store, string) {
t.Helper()
dir := t.TempDir()
s, err := Open(dir, testBox(t))
if err != nil {
t.Fatal(err)
}
return s, dir
}
func mk(t *testing.T, s *Store, title, body string) Note {
t.Helper()
n, err := s.Create(NewNote{Title: title, Body: body})
if err != nil {
t.Fatal(err)
}
return n
}
func str(s string) *string { return &s }
func TestTags(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"buy milk #errands #Errands and #home/garden", []string{"errands", "home/garden"}},
{"issue #12 is not a tag, nor is a#b", []string{}},
{"# Heading is not a tag\n#idea at the start", []string{"idea"}},
{"`#code` and\n```\n#fenced\n```\n#real", []string{"real"}},
{"(#paren) and #trailing-", []string{"paren", "trailing"}},
{"#ünï and #日本語", []string{"ünï", "日本語"}},
}
for _, c := range cases {
if got := Tags(c.in); !reflect.DeepEqual(got, c.want) {
t.Errorf("Tags(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestLinks(t *testing.T) {
got := Links("see [[Trip Plan]] and [[trip plan|the plan]] and [[ Ideas ]], not `[[code]]` or\n```\n[[fenced]]\n```\n[[]] [[a|b]]")
want := []string{"trip plan", "ideas", "a"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Links = %v, want %v", got, want)
}
}
func TestNameComesFromTitleOrFirstLine(t *testing.T) {
s, _ := open(t)
cases := []struct{ title, body, want string }{
{"Trip", "ignored", "Trip"},
{"", "# Heading line\nmore", "Heading line"},
{"", "- [ ] buy **milk** [[Shop]]", "buy milk Shop"},
{"", "\n\n plain \n", "plain"},
{"", strings.Repeat("word ", 30), strings.TrimSpace(strings.Repeat("word ", 30)[:60]) + "..."},
}
for _, c := range cases {
n := mk(t, s, c.title, c.body)
if n.Name != c.want {
t.Errorf("name of %q/%q = %q, want %q", c.title, c.body, n.Name, c.want)
}
}
if _, err := s.Create(NewNote{}); !errors.Is(err, ErrEmpty) {
t.Fatalf("empty page: %v", err)
}
only := mk(t, s, "Just a title", "")
if only.Name != "Just a title" {
t.Fatal("a page can be only a title")
}
}
func TestCreateRoundTripsThroughDisk(t *testing.T) {
s, dir := open(t)
parent := mk(t, s, "Projects", "")
n, err := s.Create(NewNote{Title: " Mako: a \"notebook\" [x]|y ", Body: "first thought #idea\n\nwith a second paragraph \n\n", Parent: parent.ID})
if err != nil {
t.Fatal(err)
}
if n.Body != "first thought #idea\n\nwith a second paragraph" || n.Title != `Mako: a "notebook" xy` {
t.Fatalf("clean-up: %q %q", n.Body, n.Title)
}
if !ValidID(n.ID) {
t.Fatalf("id %q", n.ID)
}
if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err != nil {
t.Fatal(err)
}
s2, err := Open(dir, testBox(t))
if err != nil {
t.Fatal(err)
}
got, err := s2.Get(n.ID)
if err != nil {
t.Fatal(err)
}
if got.Body != n.Body || got.Title != n.Title || got.Parent != parent.ID || !got.Created.Equal(n.Created) || !got.Updated.Equal(n.Updated) || !reflect.DeepEqual(got.Tags, []string{"idea"}) {
t.Fatalf("reloaded %+v, want %+v", got, n)
}
}
func TestCreateRejectsHuge(t *testing.T) {
s, _ := open(t)
if _, err := s.Create(NewNote{Body: strings.Repeat("x", MaxBody+1)}); !errors.Is(err, ErrTooLong) {
t.Fatalf("huge: %v", err)
}
}
func TestPlainMarkdownFileIsAPage(t *testing.T) {
_, dir := open(t)
path := filepath.Join(dir, "notes", "from-elsewhere.md")
os.WriteFile(path, []byte("# Dropped in\r\nby hand #manual\r\n"), 0o644)
mod := time.Date(2025, 3, 4, 5, 6, 7, 0, time.UTC)
os.Chtimes(path, mod, mod)
s2, err := Open(dir, testBox(t))
if err != nil {
t.Fatal(err)
}
n, err := s2.Get("from-elsewhere")
if err != nil {
t.Fatal(err)
}
if n.Body != "# Dropped in\nby hand #manual" || !n.Created.Equal(mod) || !reflect.DeepEqual(n.Tags, []string{"manual"}) || n.Name != "Dropped in" {
t.Fatalf("%+v", n)
}
}
func TestUpdateConflictAndPin(t *testing.T) {
s, _ := open(t)
n := mk(t, s, "", "one")
n2, err := s.Update(n.ID, Change{Body: str("two")}, n.Updated)
if err != nil || n2.Body != "two" || !n2.Updated.After(n.Updated) {
t.Fatalf("update: %v %+v", err, n2)
}
cur, err := s.Update(n.ID, Change{Body: str("three")}, n.Updated)
if !errors.Is(err, ErrConflict) || cur.Body != "two" {
t.Fatalf("stale write: %v %+v", err, cur)
}
if _, err := s.Update(n.ID, Change{Title: str("Late title")}, n.Updated); !errors.Is(err, ErrConflict) {
t.Fatalf("a stale title should conflict too: %v", err)
}
yes := true
n3, err := s.Update(n.ID, Change{Pinned: &yes}, time.Time{})
if err != nil || !n3.Pinned || !n3.Updated.Equal(n2.Updated) {
t.Fatalf("pin should not move updated: %v %+v", err, n3)
}
if _, err := s.Update(n.ID, Change{Body: str("four")}, time.Time{}); err != nil {
t.Fatal(err) // a write without a base always goes through
}
if _, err := s.Update("nope", Change{Body: str("x")}, time.Time{}); !errors.Is(err, ErrNotFound) {
t.Fatalf("missing: %v", err)
}
}
func TestTitlesAreUnique(t *testing.T) {
s, _ := open(t)
a := mk(t, s, "Trip Plan", "")
if _, err := s.Create(NewNote{Title: "trip plan"}); !errors.Is(err, ErrTitleTaken) {
t.Fatalf("same title, other case: %v", err)
}
b := mk(t, s, "Other", "")
if _, err := s.Update(b.ID, Change{Title: str("TRIP PLAN")}, time.Time{}); !errors.Is(err, ErrTitleTaken) {
t.Fatalf("rename onto a taken title: %v", err)
}
if got, _ := s.Get(b.ID); got.Title != "Other" {
t.Fatal("a refused rename must change nothing")
}
if _, err := s.Update(a.ID, Change{Title: str("trip plan")}, time.Time{}); err != nil {
t.Fatalf("a page may change the case of its own title: %v", err)
}
if _, err := s.Create(NewNote{Title: "2026-10-31"}); !errors.Is(err, ErrTitleTaken) {
t.Fatalf("dates belong to the journal: %v", err)
}
if _, err := s.Update(b.ID, Change{Title: str("")}, time.Time{}); err != nil {
t.Fatalf("clearing a title: %v", err)
}
}
func TestRenameRewritesLinks(t *testing.T) {
s, _ := open(t)
target := mk(t, s, "Trip Plan", "details")
a := mk(t, s, "", "see [[Trip Plan]] and [[trip plan|the plan]]\n```\n[[Trip Plan]]\n```\nand `[[Trip Plan]]`")
b := mk(t, s, "", "nothing here, [[Elsewhere]]")
if _, err := s.Update(target.ID, Change{Title: str("Lisbon Trip")}, time.Time{}); err != nil {
t.Fatal(err)
}
got, _ := s.Get(a.ID)
want := "see [[Lisbon Trip]] and [[Lisbon Trip|the plan]]\n```\n[[Trip Plan]]\n```\nand `[[Lisbon Trip]]`"
if got.Body != want {
t.Fatalf("body:\n%s\nwant:\n%s", got.Body, want)
}
if !got.Updated.After(a.Updated) {
t.Fatal("a rewritten page should move forward so open editors notice")
}
if other, _ := s.Get(b.ID); other.Body != b.Body || !other.Updated.Equal(b.Updated) {
t.Fatal("unrelated pages must not be touched")
}
s2, _ := Open(s.dir, testBox(t))
if again, _ := s2.Get(a.ID); again.Body != want {
t.Fatal("rewrite was not saved")
}
}
func TestBacklinks(t *testing.T) {
s, _ := open(t)
target := mk(t, s, "Ideas", "")
mk(t, s, "Zeta", "mentions [[ideas]]")
mk(t, s, "Alpha", "also [[Ideas|my ideas]]")
mk(t, s, "Nope", "[[Something else]]")
gone := mk(t, s, "Deleted", "[[Ideas]]")
s.Trash(gone.ID)
got := s.Backlinks(target.ID)
if len(got) != 2 || got[0].Name != "Alpha" || got[1].Name != "Zeta" {
t.Fatalf("backlinks %+v", got)
}
if b := s.Backlinks(mk(t, s, "", "no title").ID); len(b) != 0 {
t.Fatal("an untitled page cannot be linked to")
}
}
func TestTreeParentsAndCycles(t *testing.T) {
s, dir := open(t)
a := mk(t, s, "A", "")
b, _ := s.Create(NewNote{Title: "B", Parent: a.ID})
c, _ := s.Create(NewNote{Title: "C", Parent: b.ID})
if _, err := s.Update(a.ID, Change{Parent: &c.ID}, time.Time{}); !errors.Is(err, ErrBadParent) {
t.Fatalf("a page inside its own descendant: %v", err)
}
if _, err := s.Update(a.ID, Change{Parent: &a.ID}, time.Time{}); !errors.Is(err, ErrBadParent) {
t.Fatalf("a page inside itself: %v", err)
}
if _, err := s.Create(NewNote{Title: "D", Parent: "nope"}); !errors.Is(err, ErrBadParent) {
t.Fatalf("a missing parent: %v", err)
}
root := ""
if got, err := s.Update(c.ID, Change{Parent: &root}, time.Time{}); err != nil || got.Parent != "" {
t.Fatalf("moving to the top level: %v %+v", err, got)
}
// a hand-edited file with a circle of parents is mended on load
os.WriteFile(filepath.Join(dir, "notes", "x1.md"), []byte("---\nparent: x2\n---\none"), 0o644)
os.WriteFile(filepath.Join(dir, "notes", "x2.md"), []byte("---\nparent: x1\n---\ntwo"), 0o644)
os.WriteFile(filepath.Join(dir, "notes", "x3.md"), []byte("---\nparent: ghost\n---\nthree"), 0o644)
s2, err := Open(dir, testBox(t))
if err != nil {
t.Fatal(err)
}
for _, id := range []string{"x1", "x2", "x3"} {
n, _ := s2.Get(id)
if id == "x3" && n.Parent != "" {
t.Errorf("%s keeps a parent that does not exist", id)
}
}
x1, _ := s2.Get("x1")
x2, _ := s2.Get("x2")
if x1.Parent != "" && x2.Parent != "" {
t.Error("a circle of parents must be broken")
}
}
func TestTrashRestorePurge(t *testing.T) {
s, dir := open(t)
parent := mk(t, s, "Parent", "")
n, _ := s.Create(NewNote{Title: "Child", Body: "to be thrown away", Parent: parent.ID})
kid, _ := s.Create(NewNote{Title: "Grandchild", Parent: n.ID})
if err := s.Purge(n.ID); !errors.Is(err, ErrNotTrash) {
t.Fatalf("purge of a live page: %v", err)
}
if _, err := s.Trash(n.ID); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "trash", n.ID+".md")); err != nil {
t.Fatal("not moved to trash:", err)
}
if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err == nil {
t.Fatal("the live copy should be gone")
}
if got, _ := s.Get(kid.ID); got.Parent != parent.ID {
t.Fatalf("pages inside a trashed page move up a level, got parent %q", got.Parent)
}
if list, total := s.List(Query{View: "trash"}); len(list) != 1 || total != 1 {
t.Fatal("trash view empty")
}
// a new page takes the title while it is away; restoring must not make two
if _, err := s.Create(NewNote{Title: "Child"}); err != nil {
t.Fatal(err)
}
s2, _ := Open(dir, testBox(t))
back, err := s2.Restore(n.ID)
if err != nil || back.Title != "Child 2" || back.Parent != parent.ID {
t.Fatalf("restore: %v %+v", err, back)
}
s2.Trash(parent.ID)
s2.Trash(n.ID)
again, _ := s2.Restore(n.ID)
if again.Parent != "" {
t.Fatal("restoring under a parent that is gone should go to the top level")
}
s2.Trash(n.ID)
if err := s2.Purge(n.ID); err != nil {
t.Fatal(err)
}
if _, err := s2.Get(n.ID); !errors.Is(err, ErrNotFound) {
t.Fatal("purged page is still there")
}
if k, err := s2.EmptyTrash(); err != nil || k != 1 {
t.Fatalf("empty trash: %d %v", k, err)
}
}
func TestDaily(t *testing.T) {
s, _ := open(t)
d1, err := s.Daily("2026-10-31")
if err != nil || d1.ID != "daily-2026-10-31" || d1.Daily != "2026-10-31" || d1.Title != "2026-10-31" {
t.Fatalf("%v %+v", err, d1)
}
d2, _ := s.Daily("2026-10-31")
if d1.ID != d2.ID || !d1.Created.Equal(d2.Created) {
t.Fatal("asking twice should give the same page")
}
for _, bad := range []string{"", "2026-13-01", "../etc/passwd", "today"} {
if _, err := s.Daily(bad); !errors.Is(err, ErrBadDate) {
t.Errorf("%q: %v", bad, err)
}
}
if _, err := s.Update(d1.ID, Change{Title: str("Renamed")}, time.Time{}); !errors.Is(err, ErrDailyTitle) {
t.Fatalf("renaming a day: %v", err)
}
s.Trash(d1.ID)
d3, _ := s.Daily("2026-10-31")
if d3.Trashed {
t.Fatal("asking for a trashed day should bring it back")
}
s.Daily("2026-10-30")
list, _ := s.List(Query{View: "daily"})
if len(list) != 2 || list[0].Daily != "2026-10-31" {
t.Fatalf("daily view order: %+v", list)
}
linker := mk(t, s, "", "on [[2026-10-31]] I wrote")
if got := s.Backlinks(d1.ID); len(got) != 1 || got[0].ID != linker.ID {
t.Fatalf("a day can be linked by its date: %+v", got)
}
// reload keeps the title from the date, and an empty day stays out of navigation
s2, _ := Open(s.dir, testBox(t))
if n, _ := s2.Get(d1.ID); n.Title != "2026-10-31" {
t.Fatalf("title after reload: %q", n.Title)
}
for _, r := range s2.Pages() {
if r.Daily != "" {
t.Fatalf("an empty day should not be listed: %+v", r)
}
}
}
func TestSearchAndTags(t *testing.T) {
s, _ := open(t)
s.Create(NewNote{Body: "Buy oat MILK #errands"})
time.Sleep(2 * time.Millisecond)
s.Create(NewNote{Body: "Call the dentist #errands #health"})
time.Sleep(2 * time.Millisecond)
s.Create(NewNote{Title: "Roadmap", Body: "Idea: a #projects/mako dashboard"})
if list, _ := s.List(Query{Text: "milk"}); len(list) != 1 {
t.Fatalf("case-insensitive search: %d", len(list))
}
if list, _ := s.List(Query{Text: "roadmap"}); len(list) != 1 {
t.Fatalf("titles are searched: %d", len(list))
}
if list, _ := s.List(Query{Text: "milk dentist"}); len(list) != 0 {
t.Fatalf("words in different pages: %d", len(list))
}
if list, _ := s.List(Query{Tag: "errands"}); len(list) != 2 {
t.Fatalf("tag filter: %d", len(list))
}
if list, _ := s.List(Query{Text: "#errands #health"}); len(list) != 1 {
t.Fatalf("two tags in the query: %d", len(list))
}
if list, _ := s.List(Query{Tag: "projects"}); len(list) != 1 {
t.Fatalf("a parent tag finds its children: %d", len(list))
}
all, total := s.List(Query{Limit: 2})
if len(all) != 2 || total != 3 || all[0].Title != "Roadmap" {
t.Fatalf("newest first with a page size: %d of %d", len(all), total)
}
counts, tags := s.Summary()
if counts.All != 3 || tags[0].Tag != "errands" || tags[0].Count != 2 {
t.Fatalf("summary %+v %+v", counts, tags)
}
}
func TestFind(t *testing.T) {
s, _ := open(t)
mk(t, s, "Trip to Lisbon", "flights and hotels")
time.Sleep(2 * time.Millisecond)
mk(t, s, "Packing list", "passport, charger\nlisbon weather is warm")
time.Sleep(2 * time.Millisecond)
mk(t, s, "Lis", "")
time.Sleep(2 * time.Millisecond)
mk(t, s, "Unrelated", "nothing")
hits := s.Find("lis", 10)
var names []string
for _, h := range hits {
names = append(names, h.Name)
}
if !reflect.DeepEqual(names, []string{"Lis", "Packing list", "Trip to Lisbon"}) {
t.Fatalf("exact title first, then newer titles ahead of older: %v", names)
}
body := s.Find("warm", 10)
if len(body) != 1 || body[0].Name != "Packing list" || body[0].Snippet != "lisbon weather is warm" {
t.Fatalf("body match: %+v", body)
}
if recent := s.Find("", 2); len(recent) != 2 || recent[0].Name != "Unrelated" {
t.Fatalf("no query lists recent pages: %+v", recent)
}
}
func TestPagesListsWhatBelongsInNavigation(t *testing.T) {
s, _ := open(t)
titled := mk(t, s, "Titled", "")
quick := mk(t, s, "", "a quick thought")
holder := mk(t, s, "", "untitled but holds a page")
child, _ := s.Create(NewNote{Body: "child", Parent: holder.ID})
pinned := mk(t, s, "", "pinned thought")
yes := true
s.Update(pinned.ID, Change{Pinned: &yes}, time.Time{})
gone := mk(t, s, "Gone", "")
s.Trash(gone.ID)
ids := map[string]bool{}
for _, r := range s.Pages() {
ids[r.ID] = true
}
for _, id := range []string{titled.ID, holder.ID, child.ID, pinned.ID} {
if !ids[id] {
t.Errorf("%s should be listed", id)
}
}
for _, id := range []string{quick.ID, gone.ID} {
if ids[id] {
t.Errorf("%s should not be listed", id)
}
}
}
func TestIDsCannotEscape(t *testing.T) {
for _, id := range []string{"", "..", "../x", "a/b", `a\b`, "A", ".hidden", strings.Repeat("a", 65)} {
if ValidID(id) {
t.Errorf("%q should not be a valid id", id)
}
}
_, dir := open(t)
os.WriteFile(filepath.Join(dir, "notes", "Bad Name.md"), []byte("ignored"), 0o644)
s2, _ := Open(dir, testBox(t))
if list, _ := s2.List(Query{}); len(list) != 0 {
t.Fatal("a file whose name is not a valid id should be ignored")
}
}
func TestZipIsAnObsidianVault(t *testing.T) {
s, _ := open(t)
mk(t, s, "Trip: Lisbon/Porto", "see [[Packing]]")
mk(t, s, "Packing", "")
mk(t, s, "", "# A quick one")
mk(t, s, "", "# A quick one")
bin := mk(t, s, "Bin me", "")
s.Trash(bin.ID)
s.Daily("2026-10-31")
var buf bytes.Buffer
if err := s.WriteZip(&buf); err != nil {
t.Fatal(err)
}
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
t.Fatal(err)
}
var names []string
for _, f := range zr.File {
names = append(names, f.Name)
}
want := map[string]bool{"mako/Trip- Lisbon-Porto.md": true, "mako/Packing.md": true, "mako/A quick one.md": true, "mako/A quick one 2.md": true, "mako/Journal/2026-10-31.md": true}
if len(names) != len(want) {
t.Fatalf("zip has %v", names)
}
for _, n := range names {
if !want[n] {
t.Errorf("unexpected %q in %v", n, names)
}
}
}
func testBox(t *testing.T) *vault.Cipher {
t.Helper()
b, err := vault.New("0123456789abcdef0123456789abcdef", "https://auth.example.com/", "alice")
if err != nil {
t.Fatal(err)
}
return b
}
func TestFilesAreEncrypted(t *testing.T) {
s, dir := open(t)
n := mk(t, s, "Diary", "meet at the #harbour")
b, err := os.ReadFile(filepath.Join(dir, "notes", n.ID+".md"))
if err != nil {
t.Fatal(err)
}
if !vault.IsSealed(b) || bytes.Contains(b, []byte("harbour")) || bytes.Contains(b, []byte("Diary")) {
t.Fatalf("the page is on disk in the clear: %q", b)
}
// trashing keeps it encrypted, and everything reads back
if _, err := s.Trash(n.ID); err != nil {
t.Fatal(err)
}
b, _ = os.ReadFile(filepath.Join(dir, "trash", n.ID+".md"))
if !vault.IsSealed(b) {
t.Fatalf("a trashed page is in the clear: %q", b)
}
s2, err := Open(dir, testBox(t))
if err != nil {
t.Fatal(err)
}
if got, err := s2.Get(n.ID); err != nil || got.Body != "meet at the #harbour" || !got.Trashed {
t.Fatalf("reload: %+v %v", got, err)
}
}
func TestPlainFilesAreEncryptedWhenOpened(t *testing.T) {
_, dir := open(t)
path := filepath.Join(dir, "notes", "old-page.md")
os.WriteFile(path, []byte("---\ntitle: \"Old\"\n---\n\nwritten before encryption\n"), 0o644)
s, err := Open(dir, testBox(t))
if err != nil {
t.Fatal(err)
}
if n, err := s.Get("old-page"); err != nil || n.Title != "Old" {
t.Fatalf("%+v %v", n, err)
}
b, _ := os.ReadFile(path)
if !vault.IsSealed(b) || bytes.Contains(b, []byte("before encryption")) {
t.Fatalf("still in the clear: %q", b)
}
}
func TestWrongKeyRefusesInsteadOfLosingPages(t *testing.T) {
s, dir := open(t)
n := mk(t, s, "Keep", "me")
other, _ := vault.New("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "https://auth.example.com/", "alice")
if _, err := Open(dir, other); !errors.Is(err, vault.ErrWrongKey) {
t.Fatalf("want ErrWrongKey, got %v", err)
}
// a page file copied over another page's name does not open either
src := filepath.Join(dir, "notes", n.ID+".md")
b, _ := os.ReadFile(src)
os.WriteFile(filepath.Join(dir, "notes", "other-id.md"), b, 0o600)
if _, err := Open(dir, testBox(t)); !errors.Is(err, vault.ErrWrongKey) {
t.Fatalf("a swapped file opened: %v", err)
}
if _, err := Open(dir, nil); err == nil {
t.Fatal("opened with no key")
}
}