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>
579 lines
19 KiB
Go
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")
|
|
}
|
|
}
|