package api import ( "context" "encoding/json" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/verstak/verstak-desktop/internal/core/appsettings" "github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/events" corefiles "github.com/verstak/verstak-desktop/internal/core/files" "github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/storage" syncsvc "github.com/verstak/verstak-desktop/internal/core/sync" "github.com/verstak/verstak-desktop/internal/core/vault" "github.com/verstak/verstak-desktop/internal/core/workspace" ) func newLocalHTTPTestServer(t *testing.T, handler http.Handler) *httptest.Server { t.Helper() listener, err := net.Listen("tcp4", "127.0.0.1:0") if err != nil { t.Fatalf("listen local test server: %v", err) } server := httptest.NewUnstartedServer(handler) server.Listener = listener server.Start() return server } // newTestApp creates an App with a mocked plugin list for testing. func newTestApp(tmpRoot string) *App { return &App{ plugins: []plugin.Plugin{ { Manifest: plugin.Manifest{ ID: "test.plugin", Name: "Test Plugin", Version: "1.0.0", Icon: "🧪", Frontend: &plugin.FrontendConfig{ Entry: "frontend/dist/index.js", Style: "frontend/style.css", }, Provides: []string{"test/cap/v1"}, Permissions: []string{"test.perm"}, }, RootPath: tmpRoot, Status: plugin.StatusLoaded, Enabled: true, }, { Manifest: plugin.Manifest{ ID: "no-fe.plugin", Name: "No Frontend Plugin", Provides: []string{"test/nofe/v1"}, Permissions: []string{"test.perm"}, }, RootPath: "/tmp/no-fe-plugin", Status: plugin.StatusLoaded, Enabled: true, }, }, } } func newFilesTestApp(t *testing.T, perms []string) (*App, string) { t.Helper() v := vault.NewVault(nil) if err := v.CreateVault(t.TempDir()); err != nil { t.Fatalf("CreateVault: %v", err) } return &App{ files: corefiles.NewService(v), vault: v, plugins: []plugin.Plugin{ { Manifest: plugin.Manifest{ ID: "files.plugin", Name: "Files Plugin", Version: "1.0.0", Provides: []string{"files/plugin/v1"}, Permissions: perms, }, Status: plugin.StatusLoaded, Enabled: true, }, }, }, v.GetVaultPath() } type testExternalOpenService struct { open func(path string) error } func newTestExternalOpenService(open func(path string) error) *testExternalOpenService { return &testExternalOpenService{open: open} } func (s *testExternalOpenService) OpenPath(path string) error { return s.open(path) } func (s *testExternalOpenService) ShowInFolder(path string, _ bool) error { return s.open(path) } func newSyncFilesTestApp(t *testing.T, perms []string, deviceID string) (*App, string) { t.Helper() app, root := newFilesTestApp(t, perms) app.syncSvc = syncsvc.NewService(root, deviceID) app.appSettings = appsettings.NewManager(filepath.Join(t.TempDir(), "config.json")) if err := app.appSettings.Load(); err != nil { t.Fatalf("settings Load: %v", err) } cfg := app.appSettings.Get() cfg.Sync.DeviceID = deviceID if err := app.appSettings.UpdateSync(cfg.Sync); err != nil { t.Fatalf("settings UpdateSync: %v", err) } return app, root } // TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that // GetPluginFrontendInfo returns correct metadata for a plugin with a frontend. func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) { app := newTestApp("/tmp/test-plugin") info := app.GetPluginFrontendInfo("test.plugin") if info["status"] != nil { t.Errorf("unexpected status key: expected no status, got %v", info["status"]) } if info["pluginId"] != "test.plugin" { t.Errorf("pluginId: expected %q, got %v", "test.plugin", info["pluginId"]) } if info["name"] != "Test Plugin" { t.Errorf("name: expected %q, got %v", "Test Plugin", info["name"]) } if info["icon"] != "🧪" { t.Errorf("icon: expected %q, got %v", "🧪", info["icon"]) } if info["version"] != "1.0.0" { t.Errorf("version: expected %q, got %v", "1.0.0", info["version"]) } if info["entry"] != "frontend/dist/index.js" { t.Errorf("entry: expected %q, got %v", "frontend/dist/index.js", info["entry"]) } if info["style"] != "frontend/style.css" { t.Errorf("style: expected %q, got %v", "frontend/style.css", info["style"]) } if info["rootPath"] != "/tmp/test-plugin" { t.Errorf("rootPath: expected %q, got %v", "/tmp/test-plugin", info["rootPath"]) } } // TestGetPluginFrontendInfo_PluginWithoutFrontend verifies that // GetPluginFrontendInfo returns {"status": "no-frontend"} for a plugin // that has no FrontendConfig. func TestGetPluginFrontendInfo_PluginWithoutFrontend(t *testing.T) { app := newTestApp("/tmp/test-plugin") info := app.GetPluginFrontendInfo("no-fe.plugin") if info["status"] != "no-frontend" { t.Errorf("expected status %q, got %v", "no-frontend", info["status"]) } } // TestGetPluginFrontendInfo_UnknownPlugin verifies that // GetPluginFrontendInfo returns {"status": "not-found"} for a plugin ID // that does not exist. func TestGetPluginFrontendInfo_UnknownPlugin(t *testing.T) { app := newTestApp("/tmp/test-plugin") info := app.GetPluginFrontendInfo("nonexistent.plugin") if info["status"] != "not-found" { t.Errorf("expected status %q, got %v", "not-found", info["status"]) } } // TestGetPluginAssetContent_ExistingFile verifies that GetPluginAssetContent // can read an existing frontend file from a plugin directory. func TestGetPluginAssetContent_ExistingFile(t *testing.T) { tmpDir := t.TempDir() // Create a test frontend file frontendDir := filepath.Join(tmpDir, "frontend", "dist") if err := os.MkdirAll(frontendDir, 0755); err != nil { t.Fatal(err) } content := "console.log('test');\n" if err := os.WriteFile(filepath.Join(frontendDir, "index.js"), []byte(content), 0644); err != nil { t.Fatal(err) } app := newTestApp(tmpDir) got, errStr := app.GetPluginAssetContent("test.plugin", "frontend/dist/index.js") if errStr != "" { t.Errorf("unexpected error: %s", errStr) } if got != content { t.Errorf("content mismatch:\n expected: %q\n got: %q", content, got) } } // TestGetPluginAssetContent_ExistingStyleFile verifies reading a style file. func TestGetPluginAssetContent_ExistingStyleFile(t *testing.T) { tmpDir := t.TempDir() frontendDir := filepath.Join(tmpDir, "frontend") if err := os.MkdirAll(frontendDir, 0755); err != nil { t.Fatal(err) } content := "body { color: red; }\n" if err := os.WriteFile(filepath.Join(frontendDir, "style.css"), []byte(content), 0644); err != nil { t.Fatal(err) } app := newTestApp(tmpDir) got, errStr := app.GetPluginAssetContent("test.plugin", "frontend/style.css") if errStr != "" { t.Errorf("unexpected error: %s", errStr) } if got != content { t.Errorf("content mismatch:\n expected: %q\n got: %q", content, got) } } // TestGetPluginAssetContent_AbsolutePathRejected verifies that asset paths // starting with "/" are rejected. func TestGetPluginAssetContent_AbsolutePathRejected(t *testing.T) { app := newTestApp("/tmp/test-plugin") _, errStr := app.GetPluginAssetContent("test.plugin", "/etc/passwd") if errStr == "" { t.Error("expected error for absolute path, got empty") } if !strings.Contains(strings.ToLower(errStr), "absolute") { t.Errorf("error should mention 'absolute', got: %s", errStr) } // Also test Windows-style absolute path _, errStr = app.GetPluginAssetContent("test.plugin", "\\etc\\passwd") if errStr == "" { t.Error("expected error for windows-style absolute path, got empty") } } // TestGetPluginAssetContent_PathTraversalRejected verifies that asset paths // containing ".." are rejected. func TestGetPluginAssetContent_PathTraversalRejected(t *testing.T) { app := newTestApp("/tmp/test-plugin") _, errStr := app.GetPluginAssetContent("test.plugin", "../../etc/passwd") if errStr == "" { t.Error("expected error for path traversal, got empty") } if !strings.Contains(strings.ToLower(errStr), "traversal") { t.Errorf("error should mention 'traversal', got: %s", errStr) } } // TestGetPluginAssetContent_PathEscapeRejected verifies that paths that // resolve outside the plugin root directory are rejected after Join. // This tests the absRoot-prefix check in GetPluginAssetContent. func TestGetPluginAssetContent_PathEscapeRejected(t *testing.T) { tmpDir := t.TempDir() // Do NOT create the traversed-to file — the security check happens // before os.ReadFile, so the file should not matter. app := newTestApp(tmpDir) // Use a relative path with ".." that would escape the plugin root // The code checks strings.Contains(assetPath, "..") first, so this // would be caught at the traversal check. But let's also test a case // where ".." is NOT in the path but Join resolves outside root. // For instance: symlink-based escape (not testable easily) or // Join "/tmp/root" + "foo/../../../etc" — but ".." is in there. // // Instead, test that the absRoot prefix check works: create a path // that after cleaning technically starts differently but doesn't use "..". // This is hard to reproduce without symlinks. The ".." check catches // common cases. Let's just ensure the ".." check is solid: _, errStr := app.GetPluginAssetContent("test.plugin", "frontend/../../etc/passwd") if errStr == "" { t.Error("expected error for path traversal via nested '..', got empty") } } // TestGetPluginAssetContent_PluginNotFound verifies that GetPluginAssetContent // returns an error for a nonexistent plugin ID. func TestGetPluginAssetContent_PluginNotFound(t *testing.T) { app := newTestApp("/tmp/test-plugin") _, errStr := app.GetPluginAssetContent("nonexistent.plugin", "frontend/dist/index.js") if errStr == "" { t.Error("expected error for nonexistent plugin, got empty") } if !strings.Contains(strings.ToLower(errStr), "not found") && !strings.Contains(strings.ToLower(errStr), "no frontend") { t.Errorf("error should mention 'not found' or 'no frontend', got: %s", errStr) } } // TestGetPluginAssetContent_NoFrontend verifies that GetPluginAssetContent // returns an error for a plugin that exists but has no frontend config. func TestGetPluginAssetContent_NoFrontend(t *testing.T) { app := newTestApp("/tmp/test-plugin") _, errStr := app.GetPluginAssetContent("no-fe.plugin", "frontend/dist/index.js") if errStr == "" { t.Error("expected error for plugin without frontend, got empty") } if !strings.Contains(strings.ToLower(errStr), "no frontend") { t.Errorf("error should mention 'no frontend', got: %s", errStr) } } // TestGetPluginAssetContent_NonexistentFile verifies that GetPluginAssetContent // returns an error when the asset file does not exist on disk. func TestGetPluginAssetContent_NonexistentFile(t *testing.T) { tmpDir := t.TempDir() app := newTestApp(tmpDir) _, errStr := app.GetPluginAssetContent("test.plugin", "frontend/dist/missing.js") if errStr == "" { t.Error("expected error for nonexistent file, got empty") } if !strings.Contains(strings.ToLower(errStr), "failed to read") { t.Errorf("error should mention 'failed to read', got: %s", errStr) } } func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) { app, root := newFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}) if errStr := app.CreateVaultFolder("files.plugin", "Docs"); errStr != "" { t.Fatalf("CreateVaultFolder: %s", errStr) } if errStr := app.WriteVaultTextFile("files.plugin", "Docs/one.txt", "hello", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" { t.Fatalf("WriteVaultTextFile: %s", errStr) } text, errStr := app.ReadVaultTextFile("files.plugin", "Docs/one.txt") if errStr != "" { t.Fatalf("ReadVaultTextFile: %s", errStr) } if text != "hello" { t.Fatalf("text = %q", text) } entries, errStr := app.ListVaultFiles("files.plugin", "Docs") if errStr != "" { t.Fatalf("ListVaultFiles: %s", errStr) } if len(entries) != 1 || entries[0].RelativePath != "Docs/one.txt" { t.Fatalf("entries = %+v", entries) } meta, errStr := app.GetVaultFileMetadata("files.plugin", "Docs/one.txt") if errStr != "" { t.Fatalf("GetVaultFileMetadata: %s", errStr) } if meta.Type != corefiles.FileTypeFile || !meta.IsText { t.Fatalf("metadata = %+v", meta) } if errStr := app.MoveVaultPath("files.plugin", "Docs/one.txt", "Docs/two.txt", corefiles.MoveOptions{}); errStr != "" { t.Fatalf("MoveVaultPath: %s", errStr) } trash, errStr := app.TrashVaultPath("files.plugin", "Docs/two.txt") if errStr != "" { t.Fatalf("TrashVaultPath: %s", errStr) } if trash.OriginalPath != "Docs/two.txt" || trash.TrashID == "" { t.Fatalf("trash result = %+v", trash) } if _, err := os.Stat(filepath.Join(root, trash.TrashPath)); err != nil { t.Fatalf("trash path missing: %v", err) } } func TestFilesBridgeOpenExternalUsesVaultPathPolicyAndPermission(t *testing.T) { app, root := newFilesTestApp(t, []string{"files.openExternal"}) filePath := filepath.Join(root, "Docs", "one.txt") if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } if err := os.WriteFile(filePath, []byte("hello"), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } var opened []string app.externalOpen = newTestExternalOpenService(func(path string) error { opened = append(opened, path) return nil }) if errStr := app.OpenVaultPathExternal("files.plugin", "Docs/one.txt"); errStr != "" { t.Fatalf("OpenVaultPathExternal: %s", errStr) } if len(opened) != 1 || opened[0] != filePath { t.Fatalf("opened = %#v, want %q", opened, filePath) } if errStr := app.OpenVaultPathExternal("files.plugin", ".verstak/vault.json"); errStr == "" || !strings.Contains(errStr, "reserved-path") { t.Fatalf("reserved path error = %q, want reserved-path", errStr) } if len(opened) != 1 { t.Fatalf("reserved path should not open, opened = %#v", opened) } } func TestFilesBridgeShowInFolderUsesVaultPathPolicyAndPermission(t *testing.T) { app, root := newFilesTestApp(t, []string{"files.openExternal"}) filePath := filepath.Join(root, "Docs", "one.txt") if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } if err := os.WriteFile(filePath, []byte("hello"), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } var shown []string app.externalOpen = newTestExternalOpenService(func(path string) error { shown = append(shown, path) return nil }) if errStr := app.ShowVaultPathInFolder("files.plugin", "Docs/one.txt"); errStr != "" { t.Fatalf("ShowVaultPathInFolder: %s", errStr) } if len(shown) != 1 || shown[0] != filePath { t.Fatalf("shown = %#v, want %q", shown, filePath) } } func TestFilesBridgePermissions(t *testing.T) { cases := []struct { name string perms []string call func(*App) string wantPhrase string }{ { name: "list requires read", perms: []string{"files.write", "files.delete"}, call: func(app *App) string { _, errStr := app.ListVaultFiles("files.plugin", ""); return errStr }, wantPhrase: "files.read", }, { name: "metadata requires read", perms: []string{"files.write", "files.delete"}, call: func(app *App) string { _, errStr := app.GetVaultFileMetadata("files.plugin", "one.txt"); return errStr }, wantPhrase: "files.read", }, { name: "read requires read", perms: []string{"files.write", "files.delete"}, call: func(app *App) string { _, errStr := app.ReadVaultTextFile("files.plugin", "one.txt"); return errStr }, wantPhrase: "files.read", }, { name: "write requires write", perms: []string{"files.read", "files.delete"}, call: func(app *App) string { return app.WriteVaultTextFile("files.plugin", "one.txt", "x", corefiles.WriteOptions{CreateIfMissing: true}) }, wantPhrase: "files.write", }, { name: "create folder requires write", perms: []string{"files.read", "files.delete"}, call: func(app *App) string { return app.CreateVaultFolder("files.plugin", "Folder") }, wantPhrase: "files.write", }, { name: "move requires write", perms: []string{"files.read", "files.delete"}, call: func(app *App) string { return app.MoveVaultPath("files.plugin", "one.txt", "two.txt", corefiles.MoveOptions{}) }, wantPhrase: "files.write", }, { name: "trash requires delete", perms: []string{"files.read", "files.write"}, call: func(app *App) string { _, errStr := app.TrashVaultPath("files.plugin", "one.txt"); return errStr }, wantPhrase: "files.delete", }, { name: "open external requires openExternal", perms: []string{"files.read", "files.write", "files.delete"}, call: func(app *App) string { return app.OpenVaultPathExternal("files.plugin", "one.txt") }, wantPhrase: "files.openExternal", }, { name: "show in folder requires openExternal", perms: []string{"files.read", "files.write", "files.delete"}, call: func(app *App) string { return app.ShowVaultPathInFolder("files.plugin", "one.txt") }, wantPhrase: "files.openExternal", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { app, _ := newFilesTestApp(t, tc.perms) errStr := tc.call(app) if errStr == "" { t.Fatal("expected permission error") } if !strings.Contains(errStr, tc.wantPhrase) { t.Fatalf("error = %q, want %q", errStr, tc.wantPhrase) } }) } } func TestFilesBridgeRequiresLoadedPluginAndOpenVault(t *testing.T) { app, _ := newFilesTestApp(t, []string{"files.read"}) app.plugins[0].Enabled = false if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr == "" || !strings.Contains(errStr, "not enabled") { t.Fatalf("disabled plugin error = %q", errStr) } app, _ = newFilesTestApp(t, []string{"files.read"}) app.plugins[0].Status = plugin.StatusFailed if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr == "" || !strings.Contains(errStr, "not enabled") { t.Fatalf("failed plugin error = %q", errStr) } app, _ = newFilesTestApp(t, []string{"files.read"}) app.plugins[0].Status = plugin.StatusDegraded if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr != "" { t.Fatalf("degraded plugin should be allowed, got %q", errStr) } app, _ = newFilesTestApp(t, []string{"files.read"}) if _, errStr := app.ListVaultFiles("missing.plugin", ""); errStr == "" || !strings.Contains(errStr, "not found") { t.Fatalf("missing plugin error = %q", errStr) } app, _ = newFilesTestApp(t, []string{"files.read"}) app.vault.CloseVault() if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr == "" || !strings.Contains(errStr, "vault-not-open") { t.Fatalf("closed vault error = %q", errStr) } } func TestApplyRemoteFileOps(t *testing.T) { app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device") ops := []syncsvc.Op{ { OpID: "folder-create", DeviceID: "remote-device", EntityType: syncsvc.EntityFolder, EntityID: "Docs", OpType: syncsvc.OpCreate, PayloadJSON: `{"path":"Docs"}`, }, { OpID: "file-create", DeviceID: "remote-device", EntityType: syncsvc.EntityFile, EntityID: "Docs/one.txt", OpType: syncsvc.OpCreate, PayloadJSON: `{"path":"Docs/one.txt","content":"hello"}`, }, { OpID: "file-update", DeviceID: "remote-device", EntityType: syncsvc.EntityFile, EntityID: "Docs/one.txt", OpType: syncsvc.OpUpdate, PayloadJSON: `{"path":"Docs/one.txt","content":"updated"}`, }, { OpID: "file-move", DeviceID: "remote-device", EntityType: syncsvc.EntityFile, EntityID: "Docs/one.txt", OpType: syncsvc.OpMove, PayloadJSON: `{"fromPath":"Docs/one.txt","toPath":"Docs/two.txt"}`, }, } for _, op := range ops { if err := app.applyRemoteOp(op); err != nil { t.Fatalf("applyRemoteOp(%s): %v", op.OpID, err) } } text, errStr := app.ReadVaultTextFile("files.plugin", "Docs/two.txt") if errStr != "" { t.Fatalf("ReadVaultTextFile: %s", errStr) } if text != "updated" { t.Fatalf("content = %q, want updated", text) } if _, errStr := app.GetVaultFileMetadata("files.plugin", "Docs/one.txt"); !strings.Contains(errStr, "not-found") { t.Fatalf("old path metadata err = %q, want not-found", errStr) } if err := app.applyRemoteOp(syncsvc.Op{ OpID: "file-delete", DeviceID: "remote-device", EntityType: syncsvc.EntityFile, EntityID: "Docs/two.txt", OpType: syncsvc.OpDelete, PayloadJSON: `{"path":"Docs/two.txt"}`, }); err != nil { t.Fatalf("applyRemoteOp(file-delete): %v", err) } if _, errStr := app.GetVaultFileMetadata("files.plugin", "Docs/two.txt"); !strings.Contains(errStr, "not-found") { t.Fatalf("deleted path metadata err = %q, want not-found", errStr) } } func TestApplyRemoteFolderOps(t *testing.T) { app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device") ops := []syncsvc.Op{ { OpID: "folder-create", DeviceID: "remote-device", EntityType: syncsvc.EntityFolder, EntityID: "Projects", OpType: syncsvc.OpCreate, PayloadJSON: `{"path":"Projects"}`, }, { OpID: "folder-move", DeviceID: "remote-device", EntityType: syncsvc.EntityFolder, EntityID: "Projects", OpType: syncsvc.OpMove, PayloadJSON: `{"fromPath":"Projects","toPath":"Archive"}`, }, { OpID: "folder-delete", DeviceID: "remote-device", EntityType: syncsvc.EntityFolder, EntityID: "Archive", OpType: syncsvc.OpDelete, PayloadJSON: `{"path":"Archive"}`, }, } for _, op := range ops { if err := app.applyRemoteOp(op); err != nil { t.Fatalf("applyRemoteOp(%s): %v", op.OpID, err) } } if _, errStr := app.GetVaultFileMetadata("files.plugin", "Archive"); !strings.Contains(errStr, "not-found") { t.Fatalf("deleted folder metadata err = %q, want not-found", errStr) } } func TestApplyRemoteOpSkipsLocalDevice(t *testing.T) { app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device") if errStr := app.WriteVaultTextFile("files.plugin", "local.txt", "keep", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" { t.Fatalf("WriteVaultTextFile: %s", errStr) } err := app.applyRemoteOp(syncsvc.Op{ OpID: "own-delete", DeviceID: "local-device", EntityType: syncsvc.EntityFile, EntityID: "local.txt", OpType: syncsvc.OpDelete, PayloadJSON: `{"path":"local.txt"}`, }) if err != nil { t.Fatalf("applyRemoteOp: %v", err) } text, errStr := app.ReadVaultTextFile("files.plugin", "local.txt") if errStr != "" { t.Fatalf("ReadVaultTextFile: %s", errStr) } if text != "keep" { t.Fatalf("content = %q, want keep", text) } } func TestFileBridgeRecordsSyncOps(t *testing.T) { app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device") if errStr := app.CreateVaultFolder("files.plugin", "Docs"); errStr != "" { t.Fatalf("CreateVaultFolder: %s", errStr) } if errStr := app.WriteVaultTextFile("files.plugin", "Docs/one.txt", "hello", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" { t.Fatalf("WriteVaultTextFile create: %s", errStr) } if errStr := app.WriteVaultTextFile("files.plugin", "Docs/one.txt", "updated", corefiles.WriteOptions{Overwrite: true}); errStr != "" { t.Fatalf("WriteVaultTextFile update: %s", errStr) } if errStr := app.MoveVaultPath("files.plugin", "Docs/one.txt", "Docs/two.txt", corefiles.MoveOptions{}); errStr != "" { t.Fatalf("MoveVaultPath: %s", errStr) } if _, errStr := app.TrashVaultPath("files.plugin", "Docs/two.txt"); errStr != "" { t.Fatalf("TrashVaultPath: %s", errStr) } ops, err := app.syncSvc.GetUnpushedOps() if err != nil { t.Fatalf("GetUnpushedOps: %v", err) } if len(ops) != 5 { t.Fatalf("ops len = %d, want 5: %#v", len(ops), ops) } want := []struct { entityType string entityID string opType string payload string }{ {syncsvc.EntityFolder, "Docs", syncsvc.OpCreate, `"path":"Docs"`}, {syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpCreate, `"content":"hello"`}, {syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpUpdate, `"content":"updated"`}, {syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpMove, `"toPath":"Docs/two.txt"`}, {syncsvc.EntityFile, "Docs/two.txt", syncsvc.OpDelete, `"path":"Docs/two.txt"`}, } for i, w := range want { if ops[i].DeviceID != "local-device" || ops[i].EntityType != w.entityType || ops[i].EntityID != w.entityID || ops[i].OpType != w.opType { t.Fatalf("op[%d] = %+v, want device/local %s %s %s", i, ops[i], w.entityType, w.entityID, w.opType) } if !strings.Contains(ops[i].PayloadJSON, w.payload) { t.Fatalf("op[%d] payload = %s, want contains %s", i, ops[i].PayloadJSON, w.payload) } } } func TestSyncNowPushesLocalOpsAndAppliesPulledFileOps(t *testing.T) { app, root := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device") var pushedOps []struct { OpID string `json:"op_id"` EntityType string `json:"entity_type"` EntityID string `json:"entity_id"` OpType string `json:"op_type"` PayloadJSON string `json:"payload_json"` ClientSequence int `json:"client_sequence"` LastSeenServerSeq int `json:"last_seen_server_seq"` CreatedAt string `json:"created_at"` } var pushedDeviceID string server := newLocalHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer device-token" { http.Error(w, "missing auth", http.StatusUnauthorized) return } w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/api/v1/sync/push": var req struct { DeviceID string `json:"device_id"` Ops []struct { OpID string `json:"op_id"` EntityType string `json:"entity_type"` EntityID string `json:"entity_id"` OpType string `json:"op_type"` PayloadJSON string `json:"payload_json"` ClientSequence int `json:"client_sequence"` LastSeenServerSeq int `json:"last_seen_server_seq"` CreatedAt string `json:"created_at"` } `json:"ops"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } pushedDeviceID = req.DeviceID pushedOps = req.Ops accepted := make([]string, 0, len(req.Ops)) for _, op := range req.Ops { accepted = append(accepted, op.OpID) } _ = json.NewEncoder(w).Encode(map[string]interface{}{ "accepted": accepted, "count": len(accepted), "conflicts": []map[string]interface{}{}, }) case "/api/v1/sync/pull": _ = json.NewEncoder(w).Encode(map[string]interface{}{ "server_sequence": 2, "ops": []map[string]interface{}{ { "op_id": "remote-folder", "server_sequence": 1, "device_id": "remote-device", "entity_type": syncsvc.EntityFolder, "entity_id": "Remote", "op_type": syncsvc.OpCreate, "payload_json": `{"path":"Remote"}`, "created_at": "2026-06-27T00:00:00Z", }, { "op_id": "remote-file", "server_sequence": 2, "device_id": "remote-device", "entity_type": syncsvc.EntityFile, "entity_id": "Remote/hello.txt", "op_type": syncsvc.OpCreate, "payload_json": `{"path":"Remote/hello.txt","content":"from remote"}`, "created_at": "2026-06-27T00:00:01Z", }, }, }) default: http.NotFound(w, r) } })) defer server.Close() if err := app.syncSvc.SetState(server.URL, ""); err != nil { t.Fatalf("SetState: %v", err) } if err := syncsvc.SaveDeviceToken(root, "device-token"); err != nil { t.Fatalf("SaveDeviceToken: %v", err) } if errStr := app.CreateVaultFolder("files.plugin", "Local"); errStr != "" { t.Fatalf("CreateVaultFolder: %s", errStr) } if errStr := app.WriteVaultTextFile("files.plugin", "Local/one.txt", "local", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" { t.Fatalf("WriteVaultTextFile: %s", errStr) } result, err := app.syncNow() if err != nil { t.Fatalf("syncNow: %v", err) } if result["pushed"] != 2 || result["pulled"] != 2 || result["serverSequence"] != 2 { t.Fatalf("sync result = %#v", result) } if pushedDeviceID != "local-device" { t.Fatalf("pushed device = %q, want local-device", pushedDeviceID) } if len(pushedOps) != 2 { t.Fatalf("pushed ops len = %d, want 2", len(pushedOps)) } for i, op := range pushedOps { if op.LastSeenServerSeq != 0 { t.Fatalf("pushed op[%d] last seen = %d, want 0", i, op.LastSeenServerSeq) } } text, errStr := app.ReadVaultTextFile("files.plugin", "Remote/hello.txt") if errStr != "" { t.Fatalf("ReadVaultTextFile remote: %s", errStr) } if text != "from remote" { t.Fatalf("remote content = %q, want from remote", text) } unpushed, err := app.syncSvc.GetUnpushedOps() if err != nil { t.Fatalf("GetUnpushedOps: %v", err) } if len(unpushed) != 0 { t.Fatalf("unpushed len = %d, want 0: %#v", len(unpushed), unpushed) } _, _, lastPullSeq, _, err := app.syncSvc.GetState() if err != nil { t.Fatalf("GetState: %v", err) } if lastPullSeq != 2 { t.Fatalf("last pull seq = %d, want 2", lastPullSeq) } } func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) { tmpDir := t.TempDir() vaultParent := filepath.Join(tmpDir, "vault-parent") if err := os.MkdirAll(vaultParent, 0o755); err != nil { t.Fatal(err) } bus := events.NewBus() vaultService := vault.NewVault(bus) if err := vaultService.CreateVault(vaultParent); err != nil { t.Fatalf("CreateVault: %v", err) } vaultService.CloseVault() settings := appsettings.NewManager(filepath.Join(tmpDir, "config.json")) if err := settings.Load(); err != nil { t.Fatalf("settings Load: %v", err) } app := &App{ capRegistry: capability.NewRegistry(), vault: vaultService, appSettings: settings, workspace: nil, } if errStr := app.SetCurrentVault(vaultParent); errStr != "" { t.Fatalf("SetCurrentVault: %s", errStr) } tree := app.GetWorkspaceTree() if tree["status"] == "not initialized" { t.Fatal("workspace should be initialized after SetCurrentVault") } nodes, ok := tree["nodes"].([]workspace.WorkspaceNode) if !ok { t.Fatalf("workspace nodes type: got %T", tree["nodes"]) } if len(nodes) == 0 { t.Fatal("workspace nodes should not be empty") } if nodes[0].Path != "" { t.Fatalf("compatibility node should not expose workspace path mapping: %+v", nodes[0]) } if !app.capRegistry.Has("verstak/core/workspace/v1") { t.Fatal("workspace capability should be registered after SetCurrentVault") } } func TestWorkspaceAPIUsesTopLevelFoldersAndMetadataSnapshot(t *testing.T) { app, vaultDir := newFilesTestApp(t, []string{"files.read"}) app.workspace = workspace.NewManager(vaultDir) if err := app.workspace.Load(); err != nil { t.Fatalf("workspace Load: %v", err) } ws, errStr := app.CreateWorkspace("Project", "client-project") if errStr != "" { t.Fatalf("CreateWorkspace: %s", errStr) } if ws.RootPath != "Project" { t.Fatalf("workspace = %+v, want rootPath Project", ws) } if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes")); err != nil { t.Fatalf("template notes folder missing: %v", err) } if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); !os.IsNotExist(err) { t.Fatalf("template should not create overview file, stat err=%v", err) } meta, errStr := app.GetWorkspaceMetadata("Project") if errStr != "" { t.Fatalf("GetWorkspaceMetadata: %s", errStr) } if meta.CreatedFromTemplate == nil || meta.CreatedFromTemplate.TemplateID != "client-project" { t.Fatalf("metadata snapshot = %+v", meta.CreatedFromTemplate) } if errStr := app.RenameWorkspace("Project", "Renamed"); errStr != "" { t.Fatalf("RenameWorkspace: %s", errStr) } if _, err := os.Stat(filepath.Join(vaultDir, "Renamed")); err != nil { t.Fatalf("renamed folder missing: %v", err) } result, errStr := app.TrashWorkspace("Renamed") if errStr != "" { t.Fatalf("TrashWorkspace: %s", errStr) } if result.TrashPath == "" { t.Fatalf("trash result = %+v", result) } if _, err := os.Stat(filepath.Join(vaultDir, "Renamed")); !os.IsNotExist(err) { t.Fatalf("workspace should be moved out of top level, stat err=%v", err) } } func TestMoveWorkspaceNodeCompatibilityIsUnsupported(t *testing.T) { app, vaultDir := newFilesTestApp(t, []string{"files.read"}) app.workspace = workspace.NewManager(vaultDir) if err := app.workspace.Load(); err != nil { t.Fatalf("workspace Load: %v", err) } if _, errStr := app.CreateWorkspace("Project", "default"); errStr != "" { t.Fatalf("CreateWorkspace Project: %s", errStr) } if _, errStr := app.CreateWorkspace("Test", "default"); errStr != "" { t.Fatalf("CreateWorkspace Test: %s", errStr) } errStr := app.MoveWorkspaceNode("Project", "Test") if errStr == "" || !strings.Contains(errStr, "top-level only") { t.Fatalf("MoveWorkspaceNode error = %q, want top-level only", errStr) } if _, err := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(err) { t.Fatalf("MoveWorkspaceNode created nested mapped workspace, stat err=%v", err) } } func newBridgeTestApp(t *testing.T) *App { t.Helper() tmpDir := t.TempDir() vaultParent := filepath.Join(tmpDir, "vault-parent") if err := os.MkdirAll(vaultParent, 0o755); err != nil { t.Fatal(err) } bus := events.NewBus() vaultService := vault.NewVault(bus) if err := vaultService.CreateVault(vaultParent); err != nil { t.Fatalf("CreateVault: %v", err) } capReg := capability.NewRegistry() if err := capReg.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil { t.Fatal(err) } if err := capReg.Register("bridge.plugin", []string{"bridge/cap/v1"}); err != nil { t.Fatal(err) } contribReg := contribution.NewRegistry() contribReg.Register("bridge.plugin", &plugin.Contributions{ Commands: []plugin.ContributionCommand{ {ID: "bridge.command", Title: "Bridge Command", Handler: "runBridgeCommand"}, }, StatusBarItems: []plugin.ContributionStatusBarItem{ {ID: "bridge.status", Label: "Bridge Ready", Position: "right", Handler: "openBridgeStatus"}, }, OpenProviders: []plugin.ContributionOpenProvider{ { ID: "bridge.markdown", Title: "Bridge Markdown", Priority: 100, Component: "BridgeMarkdown", Supports: []plugin.OpenProviderSupport{ {Kind: "vault-file", Extensions: []string{".md"}, Contexts: []string{"generic-markdown", "notes-markdown"}}, }, }, }, }) return &App{ capRegistry: capReg, contribRegistry: contribReg, eventBus: bus, vault: vaultService, storage: storage.New(vaultService), plugins: []plugin.Plugin{ { Manifest: plugin.Manifest{ ID: "bridge.plugin", Name: "Bridge Plugin", Version: "1.0.0", Provides: []string{"bridge/cap/v1"}, Requires: []string{"verstak/core/capability-registry/v1"}, Permissions: []string{"storage.namespace", "commands.register", "events.publish", "events.subscribe", "workbench.open"}, }, Status: plugin.StatusLoaded, Enabled: true, }, { Manifest: plugin.Manifest{ ID: "no.storage", Name: "No Storage", Version: "1.0.0", Provides: []string{"no/storage/v1"}, Permissions: []string{"events.publish"}, }, Status: plugin.StatusLoaded, Enabled: true, }, { Manifest: plugin.Manifest{ ID: "disabled.plugin", Name: "Disabled", Version: "1.0.0", Provides: []string{"disabled/cap/v1"}, Permissions: []string{"storage.namespace"}, }, Status: plugin.StatusDisabled, Enabled: false, }, }, } } func TestContributionSummaryIncludesOpenProviders(t *testing.T) { app := newBridgeTestApp(t) summary := app.GetContributions() if len(summary.OpenProviders) != 1 { t.Fatalf("OpenProviders count = %d, want 1", len(summary.OpenProviders)) } provider := summary.OpenProviders[0] if provider.PluginID != "bridge.plugin" || provider.ID != "bridge.markdown" || provider.Component != "BridgeMarkdown" { t.Fatalf("provider = %+v", provider) } if len(provider.Supports) != 1 || provider.Supports[0].Contexts[1] != "notes-markdown" { t.Fatalf("supports = %+v", provider.Supports) } } func TestContributionSummaryIncludesStatusBarItems(t *testing.T) { app := newBridgeTestApp(t) summary := app.GetContributions() if len(summary.StatusBarItems) != 1 { t.Fatalf("StatusBarItems count = %d, want 1", len(summary.StatusBarItems)) } item := summary.StatusBarItems[0] if item.PluginID != "bridge.plugin" || item.ID != "bridge.status" || item.Label != "Bridge Ready" || item.Position != "right" || item.Handler != "openBridgeStatus" { t.Fatalf("status item = %+v", item) } } func TestWorkbenchOpenAndEditResourceRouteToProvider(t *testing.T) { app := newBridgeTestApp(t) app.contribRegistry.Register("disabled.plugin", &plugin.Contributions{ OpenProviders: []plugin.ContributionOpenProvider{ { ID: "disabled.markdown", Title: "Disabled Markdown", Priority: 1000, Component: "DisabledMarkdown", Supports: []plugin.OpenProviderSupport{ {Kind: "vault-file", Extensions: []string{".md"}, Contexts: []string{"generic-markdown", "notes-markdown"}}, }, }, }, }) result, errStr := app.OpenWorkbenchResource("bridge.plugin", map[string]interface{}{ "kind": "vault-file", "path": "Notes/Overview.md", "extension": ".md", "context": map[string]interface{}{ "sourceView": "notes", "isInsideNotesFolder": true, "notesMode": true, }, }) if errStr != "" { t.Fatalf("OpenWorkbenchResource: %s", errStr) } if result.ProviderID != "bridge.markdown" || result.ProviderComponent != "BridgeMarkdown" || result.Request.Mode != "view" { t.Fatalf("open result = %+v", result) } editResult, errStr := app.EditWorkbenchResource("bridge.plugin", map[string]interface{}{ "kind": "vault-file", "path": "Notes/Overview.md", "extension": ".md", "context": map[string]interface{}{ "sourceView": "notes", "isInsideNotesFolder": true, "notesMode": true, }, }) if errStr != "" { t.Fatalf("EditWorkbenchResource: %s", errStr) } if editResult.Request.Mode != "edit" { t.Fatalf("edit mode = %q", editResult.Request.Mode) } opened := app.GetWorkbenchOpenedResources() if len(opened) != 2 { t.Fatalf("opened resources = %+v", opened) } } func TestWorkbenchOpenResourceReturnsNoProviderFallback(t *testing.T) { app := newBridgeTestApp(t) result, errStr := app.OpenWorkbenchResource("bridge.plugin", map[string]interface{}{ "kind": "vault-file", "path": "Images/logo.png", }) if errStr != "" { t.Fatalf("OpenWorkbenchResource: %s", errStr) } if result.Status != "no-provider" || result.Request.Path != "Images/logo.png" { t.Fatalf("result = %+v", result) } } func TestWorkbenchOpenResourceRequiresPermission(t *testing.T) { app := newBridgeTestApp(t) _, errStr := app.OpenWorkbenchResource("no.storage", map[string]interface{}{ "kind": "vault-file", "path": "Docs/readme.md", }) if !strings.Contains(errStr, "workbench.open") { t.Fatalf("err = %q, want workbench.open permission error", errStr) } } func TestWorkbenchDisabledPluginProviderExcluded(t *testing.T) { app := newBridgeTestApp(t) result, errStr := app.OpenWorkbenchResource("bridge.plugin", map[string]interface{}{ "kind": "vault-file", "path": "Docs/readme.md", "extension": ".md", }) if errStr != "" { t.Fatalf("OpenWorkbenchResource: %s", errStr) } if result.ProviderPluginID != "bridge.plugin" { t.Fatalf("expected bridge.plugin provider, got providerPluginId=%q", result.ProviderPluginID) } if result.ProviderID == "disabled.markdown" { t.Fatal("disabled plugin provider should be excluded from selection") } } func TestPluginBridgeSettingsRequireLoadedPluginAndStoragePermission(t *testing.T) { app := newBridgeTestApp(t) if errStr := app.WritePluginSettings("bridge.plugin", map[string]interface{}{"savedText": "hello"}); errStr != "" { t.Fatalf("WritePluginSettings: %s", errStr) } settings, errStr := app.ReadPluginSettings("bridge.plugin") if errStr != "" { t.Fatalf("ReadPluginSettings: %s", errStr) } if settings["savedText"] != "hello" { t.Fatalf("savedText = %v, want hello", settings["savedText"]) } if _, errStr := app.ReadPluginSettings("missing.plugin"); errStr == "" { t.Fatal("expected error for missing plugin") } if _, errStr := app.ReadPluginSettings("disabled.plugin"); errStr == "" { t.Fatal("expected error for disabled plugin") } if _, errStr := app.ReadPluginSettings("no.storage"); errStr == "" { t.Fatal("expected error for plugin without storage.namespace") } } func TestPluginSyncBridgeRequiresDeclaredPermissions(t *testing.T) { app := newBridgeTestApp(t) app.plugins = append(app.plugins, plugin.Plugin{ Manifest: plugin.Manifest{ ID: "sync.local", Name: "Sync Local", Version: "1.0.0", Provides: []string{"sync/local/v1"}, Permissions: []string{"sync.participate"}, }, Status: plugin.StatusLoaded, Enabled: true, }, ) status, errStr := app.PluginSyncStatus("sync.local") if errStr != "" { t.Fatalf("PluginSyncStatus: %s", errStr) } if status == nil { t.Fatal("PluginSyncStatus returned nil status") } if _, errStr := app.PluginSyncStatus("no.storage"); !strings.Contains(errStr, "sync.participate") { t.Fatalf("PluginSyncStatus err = %q, want sync.participate permission error", errStr) } if _, errStr := app.PluginSyncNow("sync.local"); !strings.Contains(errStr, "network.remote") { t.Fatalf("PluginSyncNow err = %q, want network.remote permission error", errStr) } } func TestPluginBridgeCapabilitiesCommandsAndEventsAreChecked(t *testing.T) { app := newBridgeTestApp(t) capInfo, errStr := app.GetPluginCapability("bridge.plugin", "bridge/cap/v1") if errStr != "" { t.Fatalf("GetPluginCapability: %s", errStr) } if capInfo["available"] != true { t.Fatalf("capability should be available: %#v", capInfo) } if _, errStr := app.GetPluginCapability("no.storage", "bridge/cap/v1"); errStr == "" { t.Fatal("expected capability dependency error") } commandResult, errStr := app.ExecutePluginCommand("bridge.plugin", "bridge.command", map[string]interface{}{"value": "x"}) if errStr != "" { t.Fatalf("ExecutePluginCommand: %s", errStr) } if commandResult["status"] != "declared" { t.Fatalf("command status = %v, want declared", commandResult["status"]) } if errStr := app.PublishPluginEvent("bridge.plugin", "bridge.event", map[string]interface{}{"ok": true}); errStr != "" { t.Fatalf("PublishPluginEvent: %s", errStr) } if errStr := app.SubscribePluginEvent("bridge.plugin", "bridge.event"); errStr != "" { t.Fatalf("SubscribePluginEvent: %s", errStr) } if errStr := app.SubscribePluginEvent("no.storage", "bridge.event"); errStr == "" { t.Fatal("expected subscribe permission error") } if _, errStr := app.ExecutePluginCommand("no.storage", "bridge.command", nil); errStr == "" { t.Fatal("expected command permission/ownership error") } } func TestSubscribePluginEventRegistersBackendEventBridge(t *testing.T) { app := newBridgeTestApp(t) emitted := make(chan map[string]interface{}, 1) originalEmit := emitFrontendEvent emitFrontendEvent = func(_ context.Context, eventName string, data ...interface{}) { if eventName != pluginEventRuntimeName { t.Errorf("eventName = %q, want %q", eventName, pluginEventRuntimeName) } if len(data) != 1 { t.Errorf("data length = %d, want 1", len(data)) return } payload, ok := data[0].(map[string]interface{}) if !ok { t.Errorf("data[0] type = %T, want map[string]interface{}", data[0]) return } emitted <- payload } t.Cleanup(func() { emitFrontendEvent = originalEmit }) if errStr := app.SubscribePluginEvent("bridge.plugin", "browser.capture.page"); errStr != "" { t.Fatalf("SubscribePluginEvent: %s", errStr) } if !app.eventBus.HasSubscribers("browser.capture.page") { t.Fatal("expected backend event bus subscriber") } app.eventBus.Publish(events.Event{ Name: "browser.capture.page", Timestamp: "2026-06-27T00:00:00.000Z", Payload: map[string]interface{}{"url": "https://example.com"}, }) event := <-emitted if event["name"] != "browser.capture.page" { t.Fatalf("event name = %v, want browser.capture.page", event["name"]) } if event["timestamp"] != "2026-06-27T00:00:00.000Z" { t.Fatalf("event timestamp = %v, want documented timestamp", event["timestamp"]) } payload, ok := event["payload"].(map[string]interface{}) if !ok { t.Fatalf("event payload type = %T, want map[string]interface{}", event["payload"]) } if payload["url"] != "https://example.com" { t.Fatalf("payload url = %v, want https://example.com", payload["url"]) } }