Skip to content

Commit af9380b

Browse files
committed
fix(config): persist channel connection resets
1 parent 1b2c847 commit af9380b

5 files changed

Lines changed: 156 additions & 65 deletions

File tree

docs/ui/TUI-002-netclaw-config-wireframes.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -420,9 +420,8 @@ this first pass.
420420

421421
The same menu is used for Slack, Discord, and Mattermost. Disable/enable only
422422
changes `<Adapter>.Enabled`; dormant channel fields and stored credentials are
423-
preserved. Reset stages deletion of the adapter config section and secrets,
424-
then returns to the picker. The deletion is written only when the operator
425-
saves from the picker.
423+
preserved. Reset is immediate: confirming reset deletes the adapter config
424+
section and its secrets before returning to the picker/saved screen.
426425

427426
### 3.3 Channels and permissions
428427

src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
// </copyright>
55
// -----------------------------------------------------------------------
66
using Microsoft.Extensions.DependencyInjection;
7+
using Netclaw.Actors.Channels;
78
using Netclaw.Cli.Tests.Tui;
89
using Netclaw.Cli.Tui;
910
using Netclaw.Cli.Tui.Config;
11+
using Netclaw.Cli.Tui.Wizard.Steps;
1012
using Netclaw.Configuration;
1113
using Netclaw.Tests.Utilities;
1214
using Termina;
@@ -57,6 +59,35 @@ public async Task Channels_Escape_ReturnsToDashboardUsingTerminaHistory()
5759
Assert.Equal("/config", app.CurrentPath);
5860
}
5961

62+
[Fact]
63+
public async Task Channels_RotateCredentials_AcceptsTypedSecretInput()
64+
{
65+
var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm);
66+
dashboardVm.SelectedIndex.Value = dashboardVm.Items
67+
.Select((item, index) => (item, index))
68+
.Single(entry => entry.item.Label == "Channels")
69+
.index;
70+
dashboardVm.ActivateSelected();
71+
72+
input.EnqueueKey(ConsoleKey.Enter); // Open configured Slack management.
73+
input.EnqueueKey(ConsoleKey.DownArrow);
74+
input.EnqueueKey(ConsoleKey.DownArrow);
75+
input.EnqueueKey(ConsoleKey.DownArrow);
76+
input.EnqueueKey(ConsoleKey.DownArrow);
77+
input.EnqueueKey(ConsoleKey.Enter); // Rotate credentials.
78+
input.EnqueueString("xoxb-typed-token");
79+
input.EnqueueKey(ConsoleKey.Enter);
80+
input.EnqueueKey(ConsoleKey.Q, false, false, true);
81+
82+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
83+
await app.RunAsync(cts.Token);
84+
85+
var channelsVm = Assert.IsType<ChannelsConfigViewModel>(getChannelsVm());
86+
var slack = channelsVm.Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack);
87+
Assert.Equal("xoxb-typed-token", slack.BotToken);
88+
Assert.Equal("Credential changes staged. Press d to save.", channelsVm.Status.Value.Text);
89+
}
90+
6091
private TerminaApplication CreateHeadlessApp(
6192
out VirtualInputSource input,
6293
out ConfigDashboardViewModel dashboardVm,

src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ public sealed class ChannelsConfigViewModelTests : IDisposable
2020
private readonly DisposableTempDir _dir = new();
2121
private readonly NetclawPaths _paths;
2222

23+
public static TheoryData<ChannelType, string, string[]> ResetConnectionCases { get; } = new()
24+
{
25+
{ ChannelType.Slack, "Slack", ["Slack.BotToken", "Slack.AppToken"] },
26+
{ ChannelType.Discord, "Discord", ["Discord.BotToken"] },
27+
{ ChannelType.Mattermost, "Mattermost", ["Mattermost.BotToken"] }
28+
};
29+
30+
public static TheoryData<ChannelType> ChannelTypes { get; } = new()
31+
{
32+
ChannelType.Slack,
33+
ChannelType.Discord,
34+
ChannelType.Mattermost
35+
};
36+
2337
public ChannelsConfigViewModelTests()
2438
{
2539
_paths = new NetclawPaths(_dir.Path);
@@ -291,29 +305,45 @@ public void Rotate_credentials_preserves_blank_secret_and_updates_nonblank_secre
291305
Assert.Equal("xapp-test", ConfigFileHelper.DecryptIfEncrypted(_paths, appToken?.ToString()));
292306
}
293307

294-
[Fact]
295-
public void Reset_connection_deletes_config_section_and_secrets_on_save()
308+
[Theory]
309+
[MemberData(nameof(ResetConnectionCases))]
310+
public void Reset_connection_deletes_config_section_and_secrets_immediately(
311+
ChannelType type,
312+
string configSection,
313+
string[] secretPaths)
296314
{
297-
WriteChannelConfig();
298-
WriteChannelSecrets();
315+
WriteAllChannelConfig();
316+
WriteAllChannelSecrets();
299317
using var vm = CreateViewModel();
300-
vm.OpenAdapterManagement(ChannelType.Slack);
301-
var resetIndex = vm.GetManagementMenuItems()
302-
.Select((item, index) => (item, index))
303-
.Single(entry => entry.item.Action == ChannelsManagementAction.ResetConnection)
304-
.index;
305-
vm.MoveManagementMenu(resetIndex);
306-
vm.ActivateManagementMenuItem();
307-
vm.MoveResetConfirmation(1);
308318

309-
vm.ApplyResetConfirmation();
310-
vm.Save();
319+
ConfirmReset(vm, type);
311320

312321
var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath);
313-
Assert.False(ConfigFileHelper.TryGetPathValue(config, "Slack", out _));
322+
Assert.False(ConfigFileHelper.TryGetPathValue(config, configSection, out _));
314323
var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath);
315-
Assert.False(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out _));
316-
Assert.False(ConfigFileHelper.TryGetPathValue(secrets, "Slack.AppToken", out _));
324+
foreach (var secretPath in secretPaths)
325+
Assert.False(ConfigFileHelper.TryGetPathValue(secrets, secretPath, out _));
326+
Assert.True(vm.IsSaved.Value);
327+
Assert.Equal($"{type} reset saved.", vm.Status.Value.Text);
328+
}
329+
330+
[Theory]
331+
[MemberData(nameof(ChannelTypes))]
332+
public void Reset_connection_survives_reopening_channels_editor_without_outer_save(
333+
ChannelType type)
334+
{
335+
WriteAllChannelConfig();
336+
WriteAllChannelSecrets();
337+
using (var vm = CreateViewModel())
338+
{
339+
ConfirmReset(vm, type);
340+
}
341+
342+
using var reopened = CreateViewModel();
343+
344+
Assert.False(reopened.Step.IsAdapterKnown(type));
345+
Assert.False(reopened.Step.IsAdapterEnabled(type));
346+
Assert.Null(reopened.Step.GetAdapterSummary(GetAdapterIndex(reopened, type)));
317347
}
318348

319349
[Theory]
@@ -345,6 +375,25 @@ public void Add_channel_management_is_generic_for_discord_and_mattermost(
345375
private ChannelsConfigViewModel CreateViewModel()
346376
=> new(_paths, new FakeSlackProbe(), new FakeDiscordProbe());
347377

378+
private static void ConfirmReset(ChannelsConfigViewModel vm, ChannelType type)
379+
{
380+
vm.OpenAdapterManagement(type);
381+
var resetIndex = vm.GetManagementMenuItems()
382+
.Select((item, index) => (item, index))
383+
.Single(entry => entry.item.Action == ChannelsManagementAction.ResetConnection)
384+
.index;
385+
vm.MoveManagementMenu(resetIndex);
386+
vm.ActivateManagementMenuItem();
387+
vm.MoveResetConfirmation(1);
388+
vm.ApplyResetConfirmation();
389+
}
390+
391+
private static int GetAdapterIndex(ChannelsConfigViewModel vm, ChannelType type)
392+
=> vm.Step.Adapters
393+
.Select((adapter, index) => (adapter.Type, index))
394+
.Single(entry => entry.Type == type)
395+
.index;
396+
348397
private static string[] ToStringArray(object? raw)
349398
=> Assert.IsType<object[]>(raw).Select(static value => value switch
350399
{

src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ private ILayoutNode BuildRotateCredentials()
254254
var field = fields[i];
255255
var input = EnsureCredentialInput(field);
256256
if (i == ViewModel.CredentialFieldIndex)
257-
input.OnFocused();
257+
Focus.SetFocus(input);
258258

259259
layout = layout
260260
.WithChild(new TextNode($" {field.Label}:").WithForeground(i == ViewModel.CredentialFieldIndex ? Color.Cyan : Color.White))
@@ -273,7 +273,7 @@ private ILayoutNode BuildResetConfirmation()
273273
var layout = Layouts.Vertical()
274274
.WithChild(Header($" Reset {ViewModel.ActiveAdapterName} connection?"))
275275
.WithChild(Hint($" This removes {ViewModel.ActiveAdapterName} credentials, allowed channels, allowed users,"))
276-
.WithChild(Hint(" DM settings, and channel permission mappings after you save."))
276+
.WithChild(Hint(" DM settings, and channel permission mappings immediately."))
277277
.WithChild(Layouts.Empty().Height(1));
278278

279279
for (var i = 0; i < options.Length; i++)
@@ -303,7 +303,7 @@ private LayoutNode BuildHelpText()
303303
ChannelsConfigScreen.AllowedUsers => " Use comma-separated user IDs. Blank means unrestricted users in allowed channels.",
304304
ChannelsConfigScreen.DirectMessages => " Space toggles DMs. Left/right changes the DM audience.",
305305
ChannelsConfigScreen.RotateCredentials => " Blank secret fields preserve existing secrets. Tab switches fields.",
306-
ChannelsConfigScreen.ResetConfirm => " Reset is staged until you save channel settings.",
306+
ChannelsConfigScreen.ResetConfirm => " Reset writes immediately when confirmed.",
307307
_ => string.Empty
308308
};
309309
return (ILayoutNode)new TextNode(help).WithForeground(Color.Gray);
@@ -623,11 +623,9 @@ private void HandleRotateCredentialsKey(ConsoleKeyInfo keyInfo)
623623
}
624624

625625
var field = fields[ViewModel.CredentialFieldIndex];
626-
if (_credentialInputs.TryGetValue(field.Key, out var input))
627-
{
628-
input.HandleInput(keyInfo);
629-
StageCredentialInput(field);
630-
}
626+
var input = EnsureCredentialInput(field);
627+
input.HandleInput(keyInfo);
628+
StageCredentialInput(field);
631629
}
632630

633631
private void HandleResetConfirmKey(ConsoleKeyInfo keyInfo)

0 commit comments

Comments
 (0)