diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..16e8d65 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Shared contract fixtures are checksummed against provenance recorded from the +# main repository, so they must check out with LF on every platform, including +# Windows runners whose Git defaults to CRLF conversion. +internal/remote/testdata/** text eol=lf +internal/vault/testdata/** text eol=lf diff --git a/README.md b/README.md index bcca499..fd4980d 100644 --- a/README.md +++ b/README.md @@ -348,3 +348,15 @@ implementation, so the two stay interchangeable. ## License MIT + +## Compatibility development + +See [shared contract fixtures](docs/shared-contracts.md) for the task-format and +self-hosted HTTP boundary checks, including optional tests against a real server. + +### Desktop migration and creation dates + +Desktop-managed commands keep following the desktop vault, while `zn tui` keeps +its own terminal selection. Atomic note saves preserve creation dates in portable +metadata without changing Markdown. See [Desktop CLI compatibility](docs/desktop-integration.md) +for the metadata format, client compatibility and rollback behavior. diff --git a/docs/shared-contracts.md b/docs/shared-contracts.md new file mode 100644 index 0000000..07275e9 --- /dev/null +++ b/docs/shared-contracts.md @@ -0,0 +1,28 @@ +# Shared compatibility fixtures + +The TUI owns its Go implementation and its release. It consumes versioned +behavior fixtures from `ZenNotes/zennotes`, not another repository's source tree. + +- `internal/vault/testdata/task-roundtrip.json` covers stable task identity and + exact Markdown reads and due-date writes in Los Angeles and Auckland. +- `internal/remote/testdata/self-hosted-http.json` covers authentication, note + metadata, exact Unicode/whitespace, missing resources and invalid paths at both + `/` and `/notes`. +- Adjacent `.source.json` files record each upstream path and SHA-256. Tests + verify the checked-in bytes before interpreting the contract. + +`go test ./...` runs all fixtures without a server checkout. To additionally +verify the real server implementation, build the API-only server binary and run: + +```sh +ZENNOTES_SERVER_CONTRACT_BINARY=/absolute/path/to/server go test ./internal/remote +``` + +The integration test starts that binary on loopback with disposable vault and +configuration directories, verifies persisted note bytes, and cleans up its +child process. It does not contact a deployed server or use a real account. + +When updating a contract, copy the upstream fixture and update its provenance +hash together. Review semantic changes as an API compatibility change; do not +regenerate expected results from the TUI implementation. Existing release clients +remain supported until a documented support window permits deprecation. diff --git a/internal/remote/contract_test.go b/internal/remote/contract_test.go new file mode 100644 index 0000000..1e3e48f --- /dev/null +++ b/internal/remote/contract_test.go @@ -0,0 +1,280 @@ +package remote + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +type httpContract struct { + SchemaVersion int + Protocol string + MountPaths []string + Note struct { + Path, Body, UpdatedBody string + AssetEmbeds, UpdatedAssetEmbeds []string + } + RequiredCapabilities []string + RequiredNoteFields []string + Errors struct { + Unauthenticated, MissingNote, DirectoryAsNote int + Challenge string + } +} + +const contractToken = "isolated-tui-contract-token" + +func readHTTPContract(t *testing.T) httpContract { + t.Helper() + data, err := os.ReadFile("testdata/self-hosted-http.json") + if err != nil { + t.Fatal(err) + } + source, err := os.ReadFile("testdata/self-hosted-http.json.source.json") + if err != nil { + t.Fatal(err) + } + var provenance struct{ Sha256 string } + if err = json.Unmarshal(source, &provenance); err != nil { + t.Fatal(err) + } + if fmt.Sprintf("%x", sha256.Sum256(data)) != provenance.Sha256 { + t.Fatal("shared HTTP fixture checksum differs") + } + var fixture httpContract + if err = json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + if fixture.SchemaVersion != 1 || fixture.Protocol != "self-hosted-http-v1" { + t.Fatal("unsupported HTTP contract") + } + return fixture +} + +// The same client assertions run against a deterministic fixture in ordinary CI +// and an independently supplied server binary in the cross-repository rehearsal. +func checkHTTPClientContract(t *testing.T, base string, f httpContract) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + anonymous := NewClient(base, "") + caps, err := anonymous.Capabilities(ctx) + if err != nil { + t.Fatal(err) + } + for _, key := range f.RequiredCapabilities { + if _, ok := caps[key]; !ok { + t.Errorf("missing capability %s", key) + } + } + if _, err := anonymous.ReadNote(ctx, f.Note.Path); StatusOf(err) != f.Errors.Unauthenticated { + t.Fatalf("anonymous read: %v", err) + } + client := NewClient(base, contractToken) + checkWireNote := func(expectedEmbeds []string) { + t.Helper() + var raw map[string]json.RawMessage + if err := client.Get(ctx, "/api/notes/read?path="+url.QueryEscape(f.Note.Path), &raw); err != nil { + t.Fatal(err) + } + for _, field := range f.RequiredNoteFields { + if _, ok := raw[field]; !ok { + t.Errorf("missing wire field %s", field) + } + } + var embeds []string + if err := json.Unmarshal(raw["assetEmbeds"], &embeds); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(embeds, expectedEmbeds) { + t.Fatalf("wire embeds: got %#v want %#v", embeds, expectedEmbeds) + } + } + checkWireNote(f.Note.AssetEmbeds) + note, err := client.ReadNote(ctx, f.Note.Path) + if err != nil { + t.Fatal(err) + } + if note.Body != f.Note.Body { + t.Fatalf("initial note differs: %+v", note) + } + if note.Link == "" { + t.Fatal("remote note did not acquire its desktop deep link") + } + meta, err := client.WriteNote(ctx, f.Note.Path, f.Note.UpdatedBody) + if err != nil { + t.Fatal(err) + } + if meta.Path != f.Note.Path { + t.Fatalf("write metadata differs: %+v", meta) + } + checkWireNote(f.Note.UpdatedAssetEmbeds) + updated, err := client.ReadNote(ctx, f.Note.Path) + if err != nil { + t.Fatal(err) + } + if updated.Body != f.Note.UpdatedBody { + t.Fatalf("updated Markdown bytes changed: %q", updated.Body) + } + for path, status := range map[string]int{"missing.md": f.Errors.MissingNote, "inbox": f.Errors.DirectoryAsNote} { + if _, err := client.ReadNote(ctx, path); StatusOf(err) != status { + t.Errorf("read %s: expected status %d, got %v", path, status, err) + } + } + wrong := NewClient(base, "incorrect-isolated-token") + if _, err := wrong.WriteNote(ctx, f.Note.Path, "must not replace content"); StatusOf(err) != f.Errors.Unauthenticated { + t.Fatalf("wrong-token write: %v", err) + } + retained, err := client.ReadNote(ctx, f.Note.Path) + if err != nil || retained.Body != f.Note.UpdatedBody { + t.Fatalf("unauthorized write changed note: %v", err) + } +} + +func TestSharedHTTPClientContract(t *testing.T) { + fixture := readHTTPContract(t) + for _, mount := range fixture.MountPaths { + t.Run("mount="+mount, func(t *testing.T) { + body := fixture.Note.Body + metadata := func() map[string]any { + embeds := fixture.Note.AssetEmbeds + if body == fixture.Note.UpdatedBody { + embeds = fixture.Note.UpdatedAssetEmbeds + } + return map[string]any{"path": fixture.Note.Path, "title": "Contract", "folder": "inbox", "siblingOrder": 0, "createdAt": 1, "updatedAt": 2, "size": len(body), "tags": []string{}, "wikilinks": []string{}, "assetEmbeds": embeds, "hasAttachments": true, "excerpt": "Contract"} + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if !strings.HasPrefix(r.URL.Path, mount+"/api/") { + http.NotFound(w, r) + return + } + path := strings.TrimPrefix(r.URL.Path, mount) + if path == "/api/capabilities" { + caps := map[string]any{} + for _, key := range fixture.RequiredCapabilities { + caps[key] = true + } + caps["version"] = "fixture" + caps["platform"] = "linux" + json.NewEncoder(w).Encode(caps) + return + } + if r.Header.Get("Authorization") != "Bearer "+contractToken { + w.Header().Set("WWW-Authenticate", fixture.Errors.Challenge) + http.Error(w, "unauthorized", fixture.Errors.Unauthenticated) + return + } + switch path { + case "/api/notes/read": + if r.Method != http.MethodGet { + t.Errorf("unexpected read method: %s", r.Method) + } + rel := r.URL.Query().Get("path") + if rel == "inbox" { + http.Error(w, "directory", fixture.Errors.DirectoryAsNote) + return + } + if rel != fixture.Note.Path { + http.Error(w, "missing", fixture.Errors.MissingNote) + return + } + note := metadata() + note["body"] = body + json.NewEncoder(w).Encode(note) + case "/api/notes/write": + if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" { + t.Error("invalid write transport") + } + var payload struct{ Path, Body string } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Error(err) + http.Error(w, "bad request", 400) + return + } + if payload.Path != fixture.Note.Path { + t.Errorf("wrong relative path: %q", payload.Path) + } + body = payload.Body + json.NewEncoder(w).Encode(metadata()) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + checkHTTPClientContract(t, server.URL+mount, fixture) + }) + } +} + +func TestIndependentServerHTTPContract(t *testing.T) { + binary := os.Getenv("ZENNOTES_SERVER_CONTRACT_BINARY") + if binary == "" { + t.Skip("set ZENNOTES_SERVER_CONTRACT_BINARY to an independently built server to run the cross-repository rehearsal") + } + fixture := readHTTPContract(t) + for _, mount := range fixture.MountPaths { + t.Run("mount="+mount, func(t *testing.T) { + root := t.TempDir() + vaultRoot := filepath.Join(root, "vault") + path := filepath.Join(vaultRoot, filepath.FromSlash(fixture.Note.Path)) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(fixture.Note.Body), 0600); err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + listener.Close() + log, err := os.Create(filepath.Join(root, "server.log")) + if err != nil { + t.Fatal(err) + } + defer log.Close() + command := exec.Command(binary) + command.Dir = root + command.Env = append(os.Environ(), "ZENNOTES_BIND="+address, "ZENNOTES_DEFAULT_VAULT_PATH="+vaultRoot, "ZENNOTES_CONFIG_PATH="+filepath.Join(root, "server.json"), "ZENNOTES_BROWSE_ROOTS="+vaultRoot, "ZENNOTES_AUTH_TOKEN="+contractToken, "ZENNOTES_BASE_PATH="+mount) + command.Stdout = log + command.Stderr = log + if err := command.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { command.Process.Kill(); command.Wait() }) + base := "http://" + address + mount + deadline := time.Now().Add(10 * time.Second) + for { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + _, err := NewClient(base, "").Capabilities(ctx) + cancel() + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("server did not become ready: ", err) + } + time.Sleep(50 * time.Millisecond) + } + checkHTTPClientContract(t, base, fixture) + stored, err := os.ReadFile(path) + if err != nil || string(stored) != fixture.Note.UpdatedBody { + t.Fatalf("server persisted different bytes: %v", err) + } + }) + } +} diff --git a/internal/remote/testdata/self-hosted-http.json b/internal/remote/testdata/self-hosted-http.json new file mode 100644 index 0000000..d799b7b --- /dev/null +++ b/internal/remote/testdata/self-hosted-http.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 1, + "protocol": "self-hosted-http-v1", + "mountPaths": [ + "", + "/notes" + ], + "note": { + "path": "inbox/Contract.md", + "body": "# Contract\n\nUnicode café 日本語. \n\n![[photo.png]]\n![](assets/document.pdf)\n", + "updatedBody": "# Contract\n\nUpdated café 日本語. \n\n![Photo]()\n\n", + "assetEmbeds": [ + "photo.png", + "assets/document.pdf" + ], + "updatedAssetEmbeds": [ + "assets/photo two.png" + ] + }, + "requiredNoteFields": [ + "path", + "title", + "folder", + "siblingOrder", + "createdAt", + "updatedAt", + "size", + "tags", + "wikilinks", + "assetEmbeds", + "hasAttachments", + "excerpt" + ], + "requiredCapabilities": [ + "version", + "platform", + "authRequired", + "supportsSessionLogin", + "browseRootsEnforced", + "supportsVaultSelection", + "supportsDirectoryBrowsing", + "supportsWatch", + "reportsMissingAsNotFound", + "supportsAssetOps", + "supportsWorkflows", + "supportsCustomTemplates" + ], + "errors": { + "unauthenticated": 401, + "challenge": "Bearer realm=\"ZenNotes\"", + "missingNote": 404, + "directoryAsNote": 400 + }, + "routePrefixes": [ + "/api", + "" + ] +} diff --git a/internal/remote/testdata/self-hosted-http.json.source.json b/internal/remote/testdata/self-hosted-http.json.source.json new file mode 100644 index 0000000..096d2fe --- /dev/null +++ b/internal/remote/testdata/self-hosted-http.json.source.json @@ -0,0 +1,5 @@ +{ + "sourceRepository": "https://github.com/ZenNotes/zennotes", + "sourcePath": "packages/bridge-contract/fixtures/self-hosted-http.json", + "sha256": "a52743639aa8641ac206a8a887874e7d6034828118725fb89502a661c5890437" +} diff --git a/internal/vault/task_roundtrip_contract_test.go b/internal/vault/task_roundtrip_contract_test.go new file mode 100644 index 0000000..8f8bf78 --- /dev/null +++ b/internal/vault/task_roundtrip_contract_test.go @@ -0,0 +1,114 @@ +package vault + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "reflect" + "testing" + "time" +) + +func TestSharedTaskRoundtripContract(t *testing.T) { + data, err := os.ReadFile("testdata/task-roundtrip.json") + if err != nil { + t.Fatal(err) + } + var fixture struct { + SchemaVersion int `json:"schemaVersion"` + Cases []struct { + ID string `json:"id"` + Note struct { + Path string `json:"path"` + Title string `json:"title"` + Folder NoteFolder `json:"folder"` + } `json:"note"` + Body string `json:"body"` + ExpectedBody string `json:"expectedBody"` + Due string `json:"due"` + LocalNow []int `json:"localNow"` + TaskIndex int `json:"taskIndex"` + ExpectedBefore map[string]any `json:"expectedBefore"` + ExpectedAfter map[string]any `json:"expectedAfter"` + ExpectedTaskCount int `json:"expectedTaskCount"` + } `json:"cases"` + } + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + if fixture.SchemaVersion != 1 || len(fixture.Cases) == 0 { + t.Fatal("unsupported or empty task contract fixture") + } + var provenance struct{ Sha256 string } + source, err := os.ReadFile("testdata/task-roundtrip.json.source.json") + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(source, &provenance); err != nil { + t.Fatal(err) + } + if fmt.Sprintf("%x", sha256.Sum256(data)) != provenance.Sha256 { + t.Fatal("shared fixture checksum differs") + } + for _, tc := range fixture.Cases { + t.Run(tc.ID, func(t *testing.T) { + v := newTestVault(t) + for _, zone := range []string{"America/Los_Angeles", "Pacific/Auckland"} { + location, err := time.LoadLocation(zone) + if err != nil { + t.Fatal(err) + } + due := tc.Due + if len(tc.LocalNow) == 5 { + n := tc.LocalNow + due = TodayISO(time.Date(n[0], time.Month(n[1]), n[2], n[3], n[4], 0, 0, location)) + } + if actual := SetTaskDue(tc.Body, tc.TaskIndex, due); actual != tc.ExpectedBody { + t.Fatalf("%s mutation changed unexpected bytes: got %q, want %q", zone, actual, tc.ExpectedBody) + } + } + var originalID string + // The client transforms Markdown; Go stores those exact bytes and + // parses the resulting task state for the next client read. + for index, phase := range []struct { + body string + want map[string]any + }{{tc.Body, tc.ExpectedBefore}, {tc.ExpectedBody, tc.ExpectedAfter}} { + if _, err := v.WriteNote(tc.Note.Path, phase.body); err != nil { + t.Fatal(err) + } + note, err := v.ReadNote(tc.Note.Path) + if err != nil { + t.Fatal(err) + } + if note.Body != phase.body { + t.Fatal("storage changed Markdown bytes") + } + tasks := ParseTasks(tc.Note.Path, tc.Note.Title, tc.Note.Folder, note.Body, ParseTasksOptions{Dialect: DialectApp}) + if len(tasks) != tc.ExpectedTaskCount || tc.TaskIndex < 0 || tc.TaskIndex >= len(tasks) { + t.Fatalf("got %d tasks, want %d with index %d", len(tasks), tc.ExpectedTaskCount, tc.TaskIndex) + } + task := tasks[tc.TaskIndex] + if index == 0 { + originalID = task.ID + } else if task.ID != originalID { + t.Fatal("task identity changed after editing") + } + encoded, err := json.Marshal(task) + if err != nil { + t.Fatal(err) + } + var actual map[string]any + if err := json.Unmarshal(encoded, &actual); err != nil { + t.Fatal(err) + } + for field, want := range phase.want { + if !reflect.DeepEqual(actual[field], want) { + t.Errorf("phase %d field %s: got %#v, want %#v", index, field, actual[field], want) + } + } + } + }) + } +} diff --git a/internal/vault/testdata/task-roundtrip.json b/internal/vault/testdata/task-roundtrip.json new file mode 100644 index 0000000..b3988ca --- /dev/null +++ b/internal/vault/testdata/task-roundtrip.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "reschedule-in-progress-task-without-changing-other-content", + "note": { "path": "inbox/Release.md", "title": "Release", "folder": "inbox" }, + "body": "---\ntitle: Release\n---\n# Release\n\nKeep these two spaces. \n\n```md\n- [ ] Example due:2026-01-01\n```\n\n- [/] Ship release due:2026-09-15 !high #release\n- [ ] Next item\n", + "taskIndex": 0, + "due": "2026-09-16", + "expectedBefore": { "due": "2026-09-15", "inProgress": true }, + "expectedBody": "---\ntitle: Release\n---\n# Release\n\nKeep these two spaces. \n\n```md\n- [ ] Example due:2026-01-01\n```\n\n- [/] Ship release !high #release due:2026-09-16\n- [ ] Next item\n", + "expectedAfter": { "due": "2026-09-16", "inProgress": true, "checked": false, "priority": "high", "tags": ["release"] }, + "expectedTaskCount": 2 + }, + { + "id": "assign-local-today-near-midnight", + "note": { "path": "inbox/Today.md", "title": "Today", "folder": "inbox" }, + "body": "# Today\n\n- [ ] Review notes\n", + "taskIndex": 0, + "localNow": [2026, 9, 15, 0, 15], + "expectedBefore": { "checked": false }, + "expectedBody": "# Today\n\n- [ ] Review notes due:2026-09-15\n", + "expectedAfter": { "due": "2026-09-15", "checked": false }, + "expectedTaskCount": 1 + }, + { + "id": "assign-local-today-late-at-night", + "note": { "path": "inbox/Today.md", "title": "Today", "folder": "inbox" }, + "body": "# Today\n\n- [/] Review notes\n", + "taskIndex": 0, + "localNow": [2026, 9, 15, 23, 45], + "expectedBefore": { "inProgress": true }, + "expectedBody": "# Today\n\n- [/] Review notes due:2026-09-15\n", + "expectedAfter": { "due": "2026-09-15", "checked": false, "inProgress": true }, + "expectedTaskCount": 1 + } + ] +} diff --git a/internal/vault/testdata/task-roundtrip.json.source.json b/internal/vault/testdata/task-roundtrip.json.source.json new file mode 100644 index 0000000..c9f327d --- /dev/null +++ b/internal/vault/testdata/task-roundtrip.json.source.json @@ -0,0 +1,5 @@ +{ + "sourceRepository": "https://github.com/ZenNotes/zennotes", + "sourcePath": "packages/bridge-contract/fixtures/task-roundtrip.json", + "sha256": "59705ba724a1a96822ec47a2cbcc7fd09e140036972ad1769869eac9b4cac37f" +}