diff --git a/frontend/e2e/plugin-api-bridge.spec.js b/frontend/e2e/plugin-api-bridge.spec.js index 0bbbdff..fd9e05d 100644 --- a/frontend/e2e/plugin-api-bridge.spec.js +++ b/frontend/e2e/plugin-api-bridge.spec.js @@ -104,10 +104,12 @@ test.describe('D: Plugin API bridge', () => { await api.sync.setInterval(15); const configured = await api.sync.status(); const syncNow = await api.sync.now(); + await api.sync.resetKey(); + const reset = await api.sync.status(); await api.sync.disconnect(); const disconnected = await api.sync.status(); api.dispose(); - return { initial, configured, syncNow, disconnected }; + return { initial, configured, syncNow, reset, disconnected }; }); expect(result.initial.statusLabel).toBe('disabled'); @@ -115,6 +117,10 @@ test.describe('D: Plugin API bridge', () => { expect(result.configured.serverUrl).toBe('https://sync.example.test'); expect(result.configured.syncInterval).toBe(15); expect(result.syncNow).toEqual({ pushed: 0, pulled: 0, serverSequence: 0 }); + expect(result.reset.configured).toBe(false); + expect(result.reset.serverUrl).toBe('https://sync.example.test'); + expect(result.reset.tokenStored).toBe(false); + expect(result.reset.statusLabel).toBe('disconnected'); expect(result.disconnected.configured).toBe(false); expect(result.disconnected.statusLabel).toBe('disabled'); }); diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js index 175fcd7..f71baf5 100644 --- a/frontend/src/lib/plugin-host/VerstakPluginAPI.js +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -311,6 +311,12 @@ export function createPluginAPI(pluginId) { return App.PluginSyncSetInterval(pluginId, Number(minutes) || 0); }); }, + resetKey: function() { + assertActive('sync.resetKey'); + return callBackendErrorString(pluginId, 'sync.resetKey', function() { + return App.PluginSyncResetKey(pluginId); + }); + }, now: function() { assertActive('sync.now'); return callBackend(pluginId, 'sync.now', function() { diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js index b410620..59e89ab 100644 --- a/frontend/src/lib/test/wails-mock.js +++ b/frontend/src/lib/test/wails-mock.js @@ -1116,6 +1116,22 @@ syncState.syncInterval = Number(minutes) || 0; return Promise.resolve(''); }, + PluginSyncResetKey: function (pluginId) { + var err = requirePluginSyncPermission(pluginId, false); + if (err) return Promise.resolve(err); + syncState.configured = false; + syncState.deviceId = ''; + syncState.deviceName = ''; + syncState.connected = false; + syncState.revoked = false; + syncState.tokenStored = false; + syncState.lastError = ''; + syncState.statusLabel = 'disconnected'; + pluginSettings[pluginId] = Object.assign({}, pluginSettings[pluginId] || {}, { + syncStatus: syncState.statusLabel + }); + return Promise.resolve(''); + }, PluginSyncNow: function (pluginId) { var err = requirePluginSyncPermission(pluginId, true); if (err) return Promise.resolve([{}, err]); diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index 9e8c0fc..aa4206a 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -84,6 +84,8 @@ export function PluginSyncDisconnect(arg1:string):Promise; export function PluginSyncNow(arg1:string):Promise|string>; +export function PluginSyncResetKey(arg1:string):Promise; + export function PluginSyncSetInterval(arg1:string,arg2:number):Promise; export function PluginSyncStatus(arg1:string):Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index 07d9ada..7d8ef3c 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -154,6 +154,10 @@ export function PluginSyncNow(arg1) { return window['go']['api']['App']['PluginSyncNow'](arg1); } +export function PluginSyncResetKey(arg1) { + return window['go']['api']['App']['PluginSyncResetKey'](arg1); +} + export function PluginSyncSetInterval(arg1, arg2) { return window['go']['api']['App']['PluginSyncSetInterval'](arg1, arg2); } diff --git a/internal/api/app.go b/internal/api/app.go index 5d26352..dd67d8f 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -1550,6 +1550,8 @@ func (a *App) syncStatus() (*SyncStatusDTO, error) { dto.StatusLabel = "connected" case dto.Configured: dto.StatusLabel = "disconnected" + case dto.ServerURL != "": + dto.StatusLabel = "disconnected" default: dto.StatusLabel = "disabled" } @@ -1699,6 +1701,40 @@ func (a *App) PluginSyncSetInterval(pluginID string, minutes int) string { return "" } +func (a *App) syncResetKey() error { + if err := a.requireVault(); err != nil { + return err + } + vaultPath := a.vaultPath() + if err := syncsvc.RemoveDeviceToken(vaultPath); err != nil && !os.IsNotExist(err) { + return err + } + + cfg := a.appSettings.Get() + cfg.Sync.Enabled = false + cfg.Sync.DeviceID = "" + cfg.Sync.DeviceName = "" + cfg.Sync.LastStatus = "disconnected" + cfg.Sync.LastError = "" + if a.syncSvc != nil { + if err := a.syncSvc.SetState(cfg.Sync.ServerURL, ""); err != nil { + return err + } + } + return a.appSettings.UpdateSync(cfg.Sync) +} + +// PluginSyncResetKey clears the stored sync device token for a plugin with sync permission. +func (a *App) PluginSyncResetKey(pluginID string) string { + if err := a.requirePluginSyncAccess(pluginID, false); err != nil { + return err.Error() + } + if err := a.syncResetKey(); err != nil { + return err.Error() + } + return "" +} + func (a *App) syncNow() (map[string]interface{}, error) { if err := a.requireVault(); err != nil { return nil, err diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 80248b2..95d0364 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -1270,6 +1270,48 @@ func TestPluginSyncBridgeRequiresDeclaredPermissions(t *testing.T) { if _, errStr := app.PluginSyncNow("sync.local"); !strings.Contains(errStr, "network.remote") { t.Fatalf("PluginSyncNow err = %q, want network.remote permission error", errStr) } + + app.syncSvc = syncsvc.NewService(app.vaultPath(), "local-device") + 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.Enabled = true + cfg.Sync.ServerURL = "https://sync.example.test" + cfg.Sync.DeviceID = "device-1" + cfg.Sync.DeviceName = "test-device" + cfg.Sync.LastStatus = "connected" + if err := app.appSettings.UpdateSync(cfg.Sync); err != nil { + t.Fatalf("settings UpdateSync: %v", err) + } + if err := syncsvc.SaveDeviceToken(app.vaultPath(), "secret-token"); err != nil { + t.Fatalf("SaveDeviceToken: %v", err) + } + + if errStr := app.PluginSyncResetKey("sync.local"); errStr != "" { + t.Fatalf("PluginSyncResetKey: %s", errStr) + } + if token := syncsvc.LoadDeviceToken(app.vaultPath()); token != "" { + t.Fatalf("device token = %q, want cleared", token) + } + cfg = app.appSettings.Get() + if cfg.Sync.DeviceID != "" || cfg.Sync.DeviceName != "" || cfg.Sync.LastStatus != "disconnected" { + t.Fatalf("sync settings after reset = %#v, want cleared device and disconnected status", cfg.Sync) + } + if cfg.Sync.ServerURL != "https://sync.example.test" { + t.Fatalf("server URL = %q, want preserved", cfg.Sync.ServerURL) + } + status, errStr = app.PluginSyncStatus("sync.local") + if errStr != "" { + t.Fatalf("PluginSyncStatus after reset: %s", errStr) + } + if status.Configured || status.ServerURL != "https://sync.example.test" || status.StatusLabel != "disconnected" { + t.Fatalf("status after reset = %#v, want server preserved, not configured, disconnected", status) + } + if errStr := app.PluginSyncResetKey("no.storage"); !strings.Contains(errStr, "sync.participate") { + t.Fatalf("PluginSyncResetKey err = %q, want sync.participate permission error", errStr) + } } func TestPluginBridgeCapabilitiesCommandsAndEventsAreChecked(t *testing.T) {