From 8b766a54f2a358ad83f024f9923966b0e4183c8e Mon Sep 17 00:00:00 2001 From: stan44 Date: Fri, 27 Feb 2026 15:52:14 -0600 Subject: [PATCH] feat: Introduce core command handling, better handling between web gateway and .exe --- Journal.Core/Entry.cs | 50 ++++++- .../Database/DatabaseSessionService.cs | 17 +++ .../Database/IDatabaseSessionService.cs | 1 + Journal.SmokeTests/Program.VaultTests.cs | 140 ++++++++++++++++++ Journal.SmokeTests/Program.cs | 2 + sdt-workspace.json | 10 ++ 6 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 sdt-workspace.json diff --git a/Journal.Core/Entry.cs b/Journal.Core/Entry.cs index 06b57dc..6430c2f 100644 --- a/Journal.Core/Entry.cs +++ b/Journal.Core/Entry.cs @@ -42,6 +42,25 @@ public class Entry( private readonly IListService _lists = lists; private readonly ITodoService _todos = todos; private readonly CommandLogger _logger = logger; + private static readonly HashSet VaultSyncActions = new(StringComparer.Ordinal) + { + "entries.save", + "entries.delete", + "templates.save", + "templates.delete", + "fragments.create", + "fragments.update", + "fragments.delete", + "lists.create", + "lists.update", + "lists.delete", + "todos.create", + "todos.update", + "todos.delete", + "todos.items.create", + "todos.items.update", + "todos.items.delete" + }; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true @@ -324,8 +343,10 @@ public class Entry( var loadPayload = DeserializePayload(cmd.Payload); if (loadPayload is null) return Error("Missing or invalid payload"); - result = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, loadPayload.DataDirectory); - _databaseSession.SetPassword(loadPayload.Password, loadPayload.DataDirectory); + var loaded = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, loadPayload.DataDirectory); + if (loaded) + _databaseSession.SetPassword(loadPayload.Password, loadPayload.DataDirectory); + result = loaded; break; case "vault.save_current_month": var saveCurrentPayload = DeserializePayload(cmd.Payload); @@ -415,6 +436,7 @@ public class Entry( return Error("Internal error"); } + TryAutoSyncVault(action, correlationId); CommandLogger.LogSuccess(action, correlationId); return JsonSerializer.Serialize(new { ok = true, data = result }); } @@ -445,4 +467,28 @@ public class Entry( throw new ArgumentException("Invalid nowUtc value. Expected ISO date/time."); } + + private void TryAutoSyncVault(string action, string correlationId) + { + if (!VaultSyncActions.Contains(action)) + return; + + if (!_databaseSession.TryGetSession(out var password, out var sessionDataDirectory)) + return; + + try + { + var config = _config.Current; + var dataDirectory = string.IsNullOrWhiteSpace(sessionDataDirectory) + ? config.DataDirectory + : sessionDataDirectory; + + _databaseSession.CloseConnection(); + _vaultStorage.RebuildAllVaults(password, config.VaultDirectory, dataDirectory); + } + catch (Exception ex) + { + CommandLogger.LogFailure(action, correlationId, "vault_auto_sync_failed", ex.Message); + } + } } diff --git a/Journal.Core/Services/Database/DatabaseSessionService.cs b/Journal.Core/Services/Database/DatabaseSessionService.cs index 8942876..67e3f11 100644 --- a/Journal.Core/Services/Database/DatabaseSessionService.cs +++ b/Journal.Core/Services/Database/DatabaseSessionService.cs @@ -37,6 +37,23 @@ public sealed class DatabaseSessionService(IJournalDatabaseService database) : I } } + public bool TryGetSession(out string password, out string? dataDirectory) + { + lock (_lock) + { + if (string.IsNullOrWhiteSpace(_password)) + { + password = ""; + dataDirectory = null; + return false; + } + + password = _password; + dataDirectory = _dataDirectory; + return true; + } + } + public SqliteConnection GetConnection() { lock (_lock) diff --git a/Journal.Core/Services/Database/IDatabaseSessionService.cs b/Journal.Core/Services/Database/IDatabaseSessionService.cs index 89ddafb..6c29e85 100644 --- a/Journal.Core/Services/Database/IDatabaseSessionService.cs +++ b/Journal.Core/Services/Database/IDatabaseSessionService.cs @@ -6,6 +6,7 @@ public interface IDatabaseSessionService { bool IsUnlocked { get; } void SetPassword(string password, string? dataDirectory = null); + bool TryGetSession(out string password, out string? dataDirectory); SqliteConnection GetConnection(); void CloseConnection(); } diff --git a/Journal.SmokeTests/Program.VaultTests.cs b/Journal.SmokeTests/Program.VaultTests.cs index d24029b..795250a 100644 --- a/Journal.SmokeTests/Program.VaultTests.cs +++ b/Journal.SmokeTests/Program.VaultTests.cs @@ -432,5 +432,145 @@ internal static partial class Program return Task.CompletedTask; } + + static async Task TestEntryVaultLoadWrongPasswordKeepsSessionLockedAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + try + { + var zipBytes = CreateZipBytes(new Dictionary + { + ["2026-02-01.md"] = "hello from vault" + }); + var crypto = new VaultCryptoService(); + var encrypted = crypto.EncryptData(zipBytes, "vault-pass-123"); + File.WriteAllBytes(Path.Combine(vaultDir, "2026-02.vault"), encrypted); + + var entry = NewEntry(); + + var loadRequest = JsonSerializer.Serialize(new + { + action = "vault.load_all", + payload = new + { + password = "wrong-password", + vaultDirectory = vaultDir, + dataDirectory = dataDir + } + }); + var loadResponse = await entry.HandleCommandAsync(loadRequest); + using var loadDoc = JsonDocument.Parse(loadResponse); + Assert(loadDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected vault.load_all response envelope to be ok=true."); + Assert(!loadDoc.RootElement.GetProperty("data").GetBoolean(), "Expected vault.load_all data=false with wrong password."); + + var listRequest = JsonSerializer.Serialize(new + { + action = "lists.list" + }); + var listResponse = await entry.HandleCommandAsync(listRequest); + using var listDoc = JsonDocument.Parse(listResponse); + Assert(!listDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected lists.list to fail while locked."); + var error = listDoc.RootElement.GetProperty("error").GetString() ?? ""; + Assert(error.Contains("database is locked", StringComparison.OrdinalIgnoreCase), "Expected locked-session error after failed vault.load_all."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + static async Task TestEntryTemplateSaveAutoSyncsVaultAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-entry-vault-sync-smoke", Guid.NewGuid().ToString("N")); + var projectRoot = Path.Combine(root, "project"); + var appDirectory = Path.Combine(projectRoot, "journal"); + var vaultDir = Path.Combine(appDirectory, "vault"); + var dataDir = Path.Combine(appDirectory, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + var previousProjectRoot = Environment.GetEnvironmentVariable("JOURNAL_PROJECT_ROOT"); + var previousDataDir = Environment.GetEnvironmentVariable("JOURNAL_DATA_DIR"); + var previousVaultDir = Environment.GetEnvironmentVariable("JOURNAL_VAULT_DIR"); + Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", projectRoot); + Environment.SetEnvironmentVariable("JOURNAL_DATA_DIR", dataDir); + Environment.SetEnvironmentVariable("JOURNAL_VAULT_DIR", vaultDir); + + try + { + var entry = NewEntry(); + var password = "vault-pass-123"; + + var unlockRequest = JsonSerializer.Serialize(new + { + action = "vault.load_all", + payload = new + { + password, + vaultDirectory = vaultDir, + dataDirectory = dataDir + } + }); + var unlockResponse = await entry.HandleCommandAsync(unlockRequest); + using var unlockDoc = JsonDocument.Parse(unlockResponse); + Assert(unlockDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected vault.load_all envelope to succeed."); + Assert(unlockDoc.RootElement.GetProperty("data").GetBoolean(), "Expected vault.load_all data=true for empty vault."); + + var saveTemplateRequest = JsonSerializer.Serialize(new + { + action = "templates.save", + payload = new + { + name = "Weekly Review", + content = "## Wins\n- shipped feature", + dataDirectory = dataDir + } + }); + var saveTemplateResponse = await entry.HandleCommandAsync(saveTemplateRequest); + using var saveTemplateDoc = JsonDocument.Parse(saveTemplateResponse); + Assert(saveTemplateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected templates.save to succeed."); + + var customVaultPath = Path.Combine(vaultDir, "_custom_entries.vault"); + Assert(File.Exists(customVaultPath), "Expected template save to auto-sync custom entries vault."); + + var entries = ReadVaultEntryTexts(customVaultPath, password); + Assert(entries.ContainsKey("Weekly Review.template.md"), "Expected template file in custom vault archive."); + + var clearRequest = JsonSerializer.Serialize(new + { + action = "vault.clear_data_directory", + payload = new + { + dataDirectory = dataDir + } + }); + var clearResponse = await entry.HandleCommandAsync(clearRequest); + using var clearDoc = JsonDocument.Parse(clearResponse); + Assert(clearDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected vault.clear_data_directory to succeed."); + + var reloadResponse = await entry.HandleCommandAsync(unlockRequest); + using var reloadDoc = JsonDocument.Parse(reloadResponse); + Assert(reloadDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected second vault.load_all envelope to succeed."); + Assert(reloadDoc.RootElement.GetProperty("data").GetBoolean(), "Expected second vault.load_all data=true."); + + var restoredTemplatePath = Path.Combine(dataDir, "Weekly Review.template.md"); + Assert(File.Exists(restoredTemplatePath), "Expected template to be restored from vault after reload."); + } + finally + { + Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", previousProjectRoot); + Environment.SetEnvironmentVariable("JOURNAL_DATA_DIR", previousDataDir); + Environment.SetEnvironmentVariable("JOURNAL_VAULT_DIR", previousVaultDir); + + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } } diff --git a/Journal.SmokeTests/Program.cs b/Journal.SmokeTests/Program.cs index 41a32bb..6e99b60 100644 --- a/Journal.SmokeTests/Program.cs +++ b/Journal.SmokeTests/Program.cs @@ -69,7 +69,9 @@ internal static partial class Program ("Python sidecar speech service surfaces unavailable engine errors", TestPythonSidecarSpeechServiceErrorAsync), ("Python sidecar speech service times out deterministically", TestPythonSidecarSpeechServiceTimeoutAsync), ("Entry vault.load_all succeeds for empty vault directory", TestEntryVaultLoadAllEmptyAsync), + ("Entry vault.load_all wrong password keeps database session locked", TestEntryVaultLoadWrongPasswordKeepsSessionLockedAsync), ("Entry vault.clear_data_directory removes files", TestEntryVaultClearDataDirectoryAsync), + ("Entry templates.save auto-syncs vault and survives reload", TestEntryTemplateSaveAutoSyncsVaultAsync), ("Sidecar vault CLI load succeeds with --password", TestSidecarVaultCliLoadAsync), ("Sidecar vault CLI save writes monthly vault with --password", TestSidecarVaultCliSaveAsync), ("Sidecar search CLI returns matching entries with filters", TestSidecarSearchCliFilteredAsync), diff --git a/sdt-workspace.json b/sdt-workspace.json new file mode 100644 index 0000000..0df7071 --- /dev/null +++ b/sdt-workspace.json @@ -0,0 +1,10 @@ +{ + "name": "Stan's Dev Projects", + "projects": [ + { + "name": "Project Journal", + "description": "Encrypted journal — Tauri desktop + C#/.NET backend + SvelteKit UI", + "path": "journal" + } + ] +} \ No newline at end of file