diff --git a/src/ClaudeDo.Worker/DpapiTokenStore.cs b/src/ClaudeDo.Worker/DpapiTokenStore.cs new file mode 100644 index 00000000..2d732316 --- /dev/null +++ b/src/ClaudeDo.Worker/DpapiTokenStore.cs @@ -0,0 +1,57 @@ +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using ClaudeDo.Data; + +namespace ClaudeDo.Worker; + +/// +/// Persists a single secret encrypted with DPAPI (CurrentUser scope), one instance per file. +/// Windows-only. Used for the Online-Inbox refresh token (~/.claudeDo/online-inbox.token) and +/// the ticket-system PAT (~/.claudeDo/ticket.pat). +/// +[SupportedOSPlatform("windows")] +public sealed class DpapiTokenStore +{ + private readonly string _tokenPath; + + public DpapiTokenStore(string tokenPath) => _tokenPath = tokenPath; + + public static DpapiTokenStore InAppData(string fileName) + => new(Path.Combine(Paths.AppDataRoot(), fileName)); + + public void Save(string secret) + { + ArgumentException.ThrowIfNullOrEmpty(secret); + var cipher = ProtectedData.Protect(Encoding.UTF8.GetBytes(secret), null, DataProtectionScope.CurrentUser); + Directory.CreateDirectory(Path.GetDirectoryName(_tokenPath)!); + File.WriteAllBytes(_tokenPath, cipher); + } + + public string? Read() + { + if (!File.Exists(_tokenPath)) return null; + try + { + var plain = ProtectedData.Unprotect(File.ReadAllBytes(_tokenPath), null, DataProtectionScope.CurrentUser); + return Encoding.UTF8.GetString(plain); + } + catch + { + return null; + } + } + + public bool Exists() => File.Exists(_tokenPath); + + public void Clear() + { + if (File.Exists(_tokenPath)) File.Delete(_tokenPath); + } +} + +/// DI-Marker: der Online-Inbox-Refresh-Token. +public sealed record OnlineRefreshTokenStore(DpapiTokenStore Store); + +/// DI-Marker: der Personal Access Token des Ticketsystems. +public sealed record TicketPatStore(DpapiTokenStore Store); diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 69818515..b30d5562 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -69,7 +69,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub private readonly RefineRunner _refineRunner; private readonly WorkerConfig _cfg; private readonly OnlineInboxConfig _onlineInboxConfig; - private readonly OnlineTokenStore _onlineTokenStore; + private readonly DpapiTokenStore _onlineTokenStore; private readonly Runner.PendingQuestionRegistry _pendingQuestions; private readonly LogRingBuffer? _logBuffer; private readonly ISessionSkillRegistry _skillRegistry; @@ -104,7 +104,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub RefineRunner refineRunner, WorkerConfig cfg, OnlineInboxConfig onlineInboxConfig, - OnlineTokenStore onlineTokenStore, + OnlineRefreshTokenStore onlineTokenStore, Runner.PendingQuestionRegistry pendingQuestions, ISessionSkillRegistry skillRegistry, LogRingBuffer? logBuffer = null, @@ -138,7 +138,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub _refineRunner = refineRunner; _cfg = cfg; _onlineInboxConfig = onlineInboxConfig; - _onlineTokenStore = onlineTokenStore; + _onlineTokenStore = onlineTokenStore.Store; _pendingQuestions = pendingQuestions; _skillRegistry = skillRegistry; _logBuffer = logBuffer; diff --git a/src/ClaudeDo.Worker/Online/OnlineTokenStore.cs b/src/ClaudeDo.Worker/Online/OnlineTokenStore.cs deleted file mode 100644 index b07d9697..00000000 --- a/src/ClaudeDo.Worker/Online/OnlineTokenStore.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Runtime.Versioning; -using System.Security.Cryptography; -using System.Text; -using ClaudeDo.Data; - -namespace ClaudeDo.Worker.Online; - -/// -/// Persists the Zitadel refresh token encrypted with DPAPI (CurrentUser scope). -/// Windows-only; the file lives at ~/.claudeDo/online-inbox.token. -/// -[SupportedOSPlatform("windows")] -public sealed class OnlineTokenStore -{ - private readonly string _tokenPath; - - public OnlineTokenStore() - : this(Path.Combine(Paths.AppDataRoot(), "online-inbox.token")) { } - - internal OnlineTokenStore(string tokenPath) - { - _tokenPath = tokenPath; - } - - public void Save(string refreshToken) - { - ArgumentException.ThrowIfNullOrEmpty(refreshToken); - var plain = Encoding.UTF8.GetBytes(refreshToken); - var cipher = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser); - Directory.CreateDirectory(Path.GetDirectoryName(_tokenPath)!); - File.WriteAllBytes(_tokenPath, cipher); - } - - public string? Read() - { - if (!File.Exists(_tokenPath)) return null; - try - { - var cipher = File.ReadAllBytes(_tokenPath); - var plain = ProtectedData.Unprotect(cipher, null, DataProtectionScope.CurrentUser); - return Encoding.UTF8.GetString(plain); - } - catch - { - return null; - } - } - - public void Clear() - { - if (File.Exists(_tokenPath)) - File.Delete(_tokenPath); - } -} diff --git a/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs b/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs index 3cee47e3..0b66824f 100644 --- a/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs +++ b/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs @@ -11,7 +11,7 @@ namespace ClaudeDo.Worker.Online; public sealed class ZitadelAuthProvider : IOnlineAuthProvider { private readonly IHttpClientFactory _httpClientFactory; - private readonly OnlineTokenStore _tokenStore; + private readonly DpapiTokenStore _tokenStore; private readonly OnlineInboxConfig _config; private readonly ILogger _logger; @@ -29,12 +29,12 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider public ZitadelAuthProvider( IHttpClientFactory httpClientFactory, - OnlineTokenStore tokenStore, + OnlineRefreshTokenStore tokenStore, OnlineInboxConfig config, ILogger logger) { _httpClientFactory = httpClientFactory; - _tokenStore = tokenStore; + _tokenStore = tokenStore.Store; _config = config; _logger = logger; } diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 64850d0b..5126053c 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -2,6 +2,7 @@ using System.Threading; using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker; using ClaudeDo.Worker.Agents; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.External; @@ -206,12 +207,13 @@ builder.Services.AddMcpServer() .WithTools() .WithTools(); -// OnlineInboxConfig and OnlineTokenStore are always registered so hub methods work +// OnlineInboxConfig and the refresh-token store are always registered so hub methods work // even when sync is disabled. The sync stack (api client, auth, hosted service) is // only registered when enabled. builder.Services.AddSingleton(cfg.OnlineInbox); #pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here. -builder.Services.AddSingleton(); +builder.Services.AddSingleton(new OnlineRefreshTokenStore(DpapiTokenStore.InAppData("online-inbox.token"))); +builder.Services.AddSingleton(new TicketPatStore(DpapiTokenStore.InAppData("ticket.pat"))); #pragma warning restore CA1416 if (cfg.OnlineInbox.Enabled) diff --git a/tests/ClaudeDo.Worker.Tests/Hub/ClearMyDayHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/ClearMyDayHubTests.cs index 7e4ac855..b57125a9 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/ClearMyDayHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/ClearMyDayHubTests.cs @@ -20,7 +20,7 @@ public sealed class ClearMyDayHubTests : IDisposable var hub = new WorkerHub( null!, null!, null!, null!, broadcaster, _db.CreateFactory(), null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, - null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); hub.Context = new FakeHubCallerContext(); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/DeleteTaskHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/DeleteTaskHubTests.cs index a0ff1339..de3d4cbd 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/DeleteTaskHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/DeleteTaskHubTests.cs @@ -25,7 +25,7 @@ public sealed class DeleteTaskHubTests : IDisposable null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!, - null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); hub.Context = new FakeHubCallerContext(); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/ListConfigHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/ListConfigHubTests.cs index d3a2bc6d..373ed443 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/ListConfigHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/ListConfigHubTests.cs @@ -23,7 +23,7 @@ public sealed class ListConfigHubTests : IDisposable var hub = new WorkerHub( null!, null!, null!, null!, broadcaster, factory, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, - null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); hub.Context = new FakeHubCallerContext(); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs index 35038e78..ed3535c3 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs @@ -71,7 +71,7 @@ public sealed class MergeHelperTaskHubTests : IDisposable var hub = new WorkerHub( null!, null!, null!, null!, null!, factory, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!, - null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!, logBuffer: null, interactiveLaunchSpec: interactiveLaunchSpec, worktreeManager: wtManager, git: git, interactiveReviewSubmission: reviewSubmission); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/OnlineInboxHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/OnlineInboxHubTests.cs index c654fd15..99f0b432 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/OnlineInboxHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/OnlineInboxHubTests.cs @@ -1,3 +1,4 @@ +using ClaudeDo.Worker; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Online; @@ -21,17 +22,17 @@ public sealed class OnlineInboxHubTests : IDisposable try { File.Delete(_tokenPath); } catch { } } - private (WorkerHub hub, OnlineInboxConfig inboxCfg, OnlineTokenStore store) CreateHub( - OnlineInboxConfig? inboxCfg = null, OnlineTokenStore? store = null) + private (WorkerHub hub, OnlineInboxConfig inboxCfg, DpapiTokenStore store) CreateHub( + OnlineInboxConfig? inboxCfg = null, DpapiTokenStore? store = null) { var cfg = new WorkerConfig(); inboxCfg ??= cfg.OnlineInbox; - store ??= new OnlineTokenStore(_tokenPath); + store ??= new DpapiTokenStore(_tokenPath); var broadcaster = new HubBroadcaster(new CapturingHubContext()); var hub = new WorkerHub( null!, null!, null!, null!, broadcaster, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, - cfg, inboxCfg, store, new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); + cfg, inboxCfg, new OnlineRefreshTokenStore(store), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); hub.Context = new FakeHubCallerContext(); return (hub, inboxCfg, store); @@ -95,7 +96,7 @@ public sealed class OnlineInboxHubTests : IDisposable var cfg = new WorkerConfig(); // point SaveOnlineInbox to our temp file var inboxCfg = cfg.OnlineInbox; - var store = new OnlineTokenStore(_tokenPath); + var store = new DpapiTokenStore(_tokenPath); var broadcaster = new HubBroadcaster(new CapturingHubContext()); // Patch cfg to save to our temp path by writing an initial file there diff --git a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs index fad6a070..1bcbd7e9 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs @@ -56,7 +56,7 @@ public sealed class PlanningHubTests : IDisposable var hub = new WorkerHub( null!, null!, null!, null!, null!, _db.CreateFactory(), null!, null!, null!, _planning, _launcher, null!, null!, null!, null!, null!, null!, null!, null!, - null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); hub.Clients = new FakeHubCallerClients(_proxy); hub.Context = new FakeHubCallerContext(); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/RepoImportFoldersHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/RepoImportFoldersHubTests.cs index 9daf46c1..ec902b39 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/RepoImportFoldersHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/RepoImportFoldersHubTests.cs @@ -18,7 +18,7 @@ public sealed class RepoImportFoldersHubTests : IDisposable var hub = new WorkerHub( null!, null!, null!, null!, broadcaster, _db.CreateFactory(), null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, - null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); hub.Context = new FakeHubCallerContext(); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/SessionSkillsHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/SessionSkillsHubTests.cs index afae35f4..ec63f3d8 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/SessionSkillsHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/SessionSkillsHubTests.cs @@ -51,7 +51,7 @@ public sealed class SessionSkillsHubTests : IDisposable var hub = new WorkerHub( null!, null!, null!, null!, broadcaster, _db.CreateFactory(), null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, - null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), registry); hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); hub.Context = new FakeHubCallerContext(); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/SetTaskStatusHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/SetTaskStatusHubTests.cs index ccdbb563..382c8b88 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/SetTaskStatusHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/SetTaskStatusHubTests.cs @@ -25,7 +25,7 @@ public sealed class SetTaskStatusHubTests : IDisposable null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!, - null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); hub.Context = new FakeHubCallerContext(); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/TaskDoneDequeueHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/TaskDoneDequeueHubTests.cs index 32571b56..8368199d 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/TaskDoneDequeueHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/TaskDoneDequeueHubTests.cs @@ -40,7 +40,7 @@ public sealed class TaskDoneDequeueHubTests : IDisposable var hub = new WorkerHub( null!, null!, null!, null!, null!, factory, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!, - null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); hub.Clients = new FakeHubCallerClients(_proxy); hub.Context = new FakeHubCallerContext(); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/TaskUsageHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/TaskUsageHubTests.cs index 2fc4d7a7..83feaa23 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/TaskUsageHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/TaskUsageHubTests.cs @@ -19,7 +19,7 @@ public sealed class TaskUsageHubTests : IDisposable var hub = new WorkerHub( null!, null!, null!, null!, broadcaster, _db.CreateFactory(), null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, - null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); hub.Context = new FakeHubCallerContext(); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/WorkerBuildInfoHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/WorkerBuildInfoHubTests.cs index 64624422..c4a3d4db 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/WorkerBuildInfoHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/WorkerBuildInfoHubTests.cs @@ -25,7 +25,7 @@ public sealed class WorkerBuildInfoHubTests var hub = new WorkerHub( null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, - null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); // No assertion on the actual SHA value (depends on the build environment) — just that diff --git a/tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs index ee803906..9207103d 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs @@ -20,7 +20,7 @@ public sealed class WorktreeStateHubTests : IDisposable var hub = new WorkerHub( null!, null!, null!, null!, broadcaster, _db.CreateFactory(), null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, - null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); hub.Context = new FakeHubCallerContext(); diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineTokenStoreTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineTokenStoreTests.cs deleted file mode 100644 index 404351bc..00000000 --- a/tests/ClaudeDo.Worker.Tests/Online/OnlineTokenStoreTests.cs +++ /dev/null @@ -1,53 +0,0 @@ -using ClaudeDo.Worker.Online; - -namespace ClaudeDo.Worker.Tests.Online; - -public sealed class OnlineTokenStoreTests : IDisposable -{ - private readonly string _tokenPath = Path.Combine(Path.GetTempPath(), $"online_token_{Guid.NewGuid():N}.bin"); - - public void Dispose() - { - try { File.Delete(_tokenPath); } catch { } - } - - [Fact] - public void Save_Read_RoundTrips() - { - if (!OperatingSystem.IsWindows()) return; // DPAPI is Windows-only - - var store = new OnlineTokenStore(_tokenPath); - store.Save("my-refresh-token"); - var result = store.Read(); - Assert.Equal("my-refresh-token", result); - } - - [Fact] - public void Clear_Removes_Token() - { - if (!OperatingSystem.IsWindows()) return; - - var store = new OnlineTokenStore(_tokenPath); - store.Save("token"); - store.Clear(); - Assert.Null(store.Read()); - } - - [Fact] - public void Read_WhenFileAbsent_Returns_Null() - { - if (!OperatingSystem.IsWindows()) return; - - var store = new OnlineTokenStore(_tokenPath); - Assert.Null(store.Read()); - } - - [Fact] - public void Clear_WhenFileAbsent_DoesNotThrow() - { - if (!OperatingSystem.IsWindows()) return; - - var store = new OnlineTokenStore(_tokenPath); - store.Clear(); // no exception expected - } -} diff --git a/tests/ClaudeDo.Worker.Tests/Online/ZitadelAuthProviderTests.cs b/tests/ClaudeDo.Worker.Tests/Online/ZitadelAuthProviderTests.cs index c8c502d9..8ef8f2cc 100644 --- a/tests/ClaudeDo.Worker.Tests/Online/ZitadelAuthProviderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Online/ZitadelAuthProviderTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text; using System.Text.Json; +using ClaudeDo.Worker; using ClaudeDo.Worker.Online; using Microsoft.Extensions.Logging.Abstractions; @@ -8,8 +9,8 @@ namespace ClaudeDo.Worker.Tests.Online; /// /// Tests for using a stub . -/// Token-store tests use a real temp-dir (DPAPI) and are -/// Windows-only, consistent with . +/// Token-store tests use a real temp-dir (DPAPI) and are +/// Windows-only. /// public sealed class ZitadelAuthProviderTests : IDisposable { @@ -86,14 +87,14 @@ public sealed class ZitadelAuthProviderTests : IDisposable }, }; - private (ZitadelAuthProvider Provider, StubHandler Handler, OnlineTokenStore Store) Build() + private (ZitadelAuthProvider Provider, StubHandler Handler, DpapiTokenStore Store) Build() { var handler = new StubHandler(); var factory = new StubHttpClientFactory(handler); - var store = new OnlineTokenStore(_tokenPath); + var store = new DpapiTokenStore(_tokenPath); var config = MakeConfig(); var provider = new ZitadelAuthProvider( - factory, store, config, NullLogger.Instance); + factory, new OnlineRefreshTokenStore(store), config, NullLogger.Instance); return (provider, handler, store); } diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/DpapiTokenStoreTests.cs b/tests/ClaudeDo.Worker.Tests/Tickets/DpapiTokenStoreTests.cs new file mode 100644 index 00000000..2a62f958 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Tickets/DpapiTokenStoreTests.cs @@ -0,0 +1,45 @@ +using ClaudeDo.Worker; + +namespace ClaudeDo.Worker.Tests.Tickets; + +public sealed class DpapiTokenStoreTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "cdo-tok-" + Guid.NewGuid().ToString("N")); + + [Fact] + public void Save_and_read_round_trip() + { + var store = new DpapiTokenStore(Path.Combine(_dir, "ticket.pat")); + store.Save("tsp_secret"); + Assert.Equal("tsp_secret", store.Read()); + } + + [Fact] + public void Read_returns_null_when_missing() + => Assert.Null(new DpapiTokenStore(Path.Combine(_dir, "nope.pat")).Read()); + + [Fact] + public void Two_stores_with_different_files_do_not_share_state() + { + var a = new DpapiTokenStore(Path.Combine(_dir, "a.token")); + var b = new DpapiTokenStore(Path.Combine(_dir, "b.token")); + a.Save("aaa"); + b.Save("bbb"); + Assert.Equal("aaa", a.Read()); + Assert.Equal("bbb", b.Read()); + } + + [Fact] + public void Clear_removes_the_file() + { + var store = new DpapiTokenStore(Path.Combine(_dir, "c.token")); + store.Save("x"); + store.Clear(); + Assert.Null(store.Read()); + } + + public void Dispose() + { + if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true); + } +}