Skip to content

Commit 66abb40

Browse files
committed
refine(config): unify channels validation
1 parent af9380b commit 66abb40

12 files changed

Lines changed: 654 additions & 69 deletions

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

Lines changed: 259 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,22 @@ public ChannelsConfigNavigationTests()
3232
"""
3333
{
3434
"configVersion": 1,
35-
"Slack": { "Enabled": true, "AllowedChannelIds": ["C01"] }
35+
"Slack": { "Enabled": true, "AllowedChannelIds": ["C01"] },
36+
"Discord": { "Enabled": true, "AllowedChannelIds": ["123456789"] },
37+
"Mattermost": {
38+
"Enabled": true,
39+
"ServerUrl": "https://mattermost.example.com",
40+
"AllowedChannelIds": ["town-square"]
41+
}
42+
}
43+
""");
44+
File.WriteAllText(_paths.SecretsPath,
45+
"""
46+
{
47+
"configVersion": 1,
48+
"Slack": { "BotToken": "xoxb-existing", "AppToken": "xapp-existing" },
49+
"Discord": { "BotToken": "discord-existing" },
50+
"Mattermost": { "BotToken": "mattermost-existing" }
3651
}
3752
""");
3853
}
@@ -59,23 +74,88 @@ public async Task Channels_Escape_ReturnsToDashboardUsingTerminaHistory()
5974
Assert.Equal("/config", app.CurrentPath);
6075
}
6176

77+
[Theory]
78+
[InlineData(ChannelType.Slack)]
79+
[InlineData(ChannelType.Discord)]
80+
[InlineData(ChannelType.Mattermost)]
81+
public async Task Channels_RotateCredentials_AcceptsTypedCredentialInput(ChannelType channelType)
82+
{
83+
var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm);
84+
OpenChannels(dashboardVm);
85+
MoveToAdapter(input, channelType);
86+
87+
input.EnqueueKey(ConsoleKey.Enter); // Open configured adapter management.
88+
MoveToRotateCredentials(input);
89+
input.EnqueueKey(ConsoleKey.Enter); // Rotate credentials.
90+
TypeCredentials(input, channelType);
91+
input.EnqueueKey(ConsoleKey.Enter);
92+
input.EnqueueKey(ConsoleKey.Q, false, false, true);
93+
94+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
95+
await app.RunAsync(cts.Token);
96+
97+
var channelsVm = Assert.IsType<ChannelsConfigViewModel>(getChannelsVm());
98+
AssertTypedCredentials(channelsVm, channelType);
99+
Assert.Equal("Credential changes staged. Press d to save.", channelsVm.Status.Value.Text);
100+
}
101+
102+
[Theory]
103+
[InlineData(ChannelType.Slack)]
104+
[InlineData(ChannelType.Discord)]
105+
[InlineData(ChannelType.Mattermost)]
106+
public async Task Channels_FirstTimeAdapterSetup_AcceptsTypedCredentialInput(ChannelType channelType)
107+
{
108+
WriteEmptyChannelFiles();
109+
var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm);
110+
OpenChannels(dashboardVm);
111+
MoveToAdapter(input, channelType);
112+
113+
input.EnqueueKey(ConsoleKey.Enter); // Enable selected adapter and enter first-time setup.
114+
TypeFirstTimeSetup(input, channelType);
115+
input.EnqueueKey(ConsoleKey.Q, false, false, true);
116+
117+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
118+
await app.RunAsync(cts.Token);
119+
120+
var channelsVm = Assert.IsType<ChannelsConfigViewModel>(getChannelsVm());
121+
Assert.Equal(ChannelsConfigScreen.ChannelPermissions, channelsVm.Screen.Value);
122+
Assert.Equal(channelType, channelsVm.ActiveAdapterType);
123+
AssertFirstTimeSetup(channelsVm, channelType);
124+
}
125+
62126
[Fact]
63-
public async Task Channels_RotateCredentials_AcceptsTypedSecretInput()
127+
public async Task Channels_FirstTimeSlackBotToken_ShowsValidationError()
64128
{
129+
WriteEmptyChannelFiles();
65130
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();
131+
OpenChannels(dashboardVm);
132+
133+
input.EnqueueKey(ConsoleKey.Enter); // Enable Slack and enter first-time setup.
134+
input.EnqueueString("not-a-slack-token");
135+
input.EnqueueKey(ConsoleKey.Enter);
136+
input.EnqueueKey(ConsoleKey.Q, false, false, true);
137+
138+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
139+
await app.RunAsync(cts.Token);
140+
141+
var channelsVm = Assert.IsType<ChannelsConfigViewModel>(getChannelsVm());
142+
var slack = channelsVm.Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack);
143+
Assert.Equal(ChannelsEditorValidationMessages.SlackBotTokenPrefix, channelsVm.Status.Value.Text);
144+
Assert.Equal(ConfigStatusTone.Error, channelsVm.Status.Value.Tone);
145+
Assert.Equal(1, slack.CurrentSubStep);
146+
Assert.Null(slack.BotToken);
147+
}
148+
149+
[Fact]
150+
public async Task Channels_RotateCredentials_InvalidSlackBotToken_ShowsValidationError()
151+
{
152+
var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm);
153+
OpenChannels(dashboardVm);
71154

72155
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");
156+
MoveToRotateCredentials(input);
157+
input.EnqueueKey(ConsoleKey.Enter);
158+
input.EnqueueString("not-a-slack-token");
79159
input.EnqueueKey(ConsoleKey.Enter);
80160
input.EnqueueKey(ConsoleKey.Q, false, false, true);
81161

@@ -84,8 +164,172 @@ public async Task Channels_RotateCredentials_AcceptsTypedSecretInput()
84164

85165
var channelsVm = Assert.IsType<ChannelsConfigViewModel>(getChannelsVm());
86166
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);
167+
Assert.Equal(ChannelsConfigScreen.RotateCredentials, channelsVm.Screen.Value);
168+
Assert.Equal(ChannelsEditorValidationMessages.SlackBotTokenPrefix, channelsVm.Status.Value.Text);
169+
Assert.Equal(ConfigStatusTone.Error, channelsVm.Status.Value.Tone);
170+
Assert.Null(slack.BotToken);
171+
}
172+
173+
private static void OpenChannels(ConfigDashboardViewModel dashboardVm)
174+
{
175+
dashboardVm.SelectedIndex.Value = dashboardVm.Items
176+
.Select((item, index) => (item, index))
177+
.Single(entry => entry.item.Label == "Channels")
178+
.index;
179+
dashboardVm.ActivateSelected();
180+
}
181+
182+
private static void MoveToAdapter(VirtualInputSource input, ChannelType channelType)
183+
{
184+
var adapterIndex = channelType switch
185+
{
186+
ChannelType.Slack => 0,
187+
ChannelType.Discord => 1,
188+
ChannelType.Mattermost => 2,
189+
_ => throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null)
190+
};
191+
192+
for (var i = 0; i < adapterIndex; i++)
193+
input.EnqueueKey(ConsoleKey.DownArrow);
194+
}
195+
196+
private static void MoveToRotateCredentials(VirtualInputSource input)
197+
{
198+
for (var i = 0; i < 4; i++)
199+
input.EnqueueKey(ConsoleKey.DownArrow);
200+
}
201+
202+
private static void TypeCredentials(VirtualInputSource input, ChannelType channelType)
203+
{
204+
switch (channelType)
205+
{
206+
case ChannelType.Slack:
207+
input.EnqueueString("xoxb-typed-token");
208+
input.EnqueueKey(ConsoleKey.Tab);
209+
input.EnqueueString("xapp-typed-token");
210+
break;
211+
case ChannelType.Discord:
212+
input.EnqueueString("discord-typed-token");
213+
break;
214+
case ChannelType.Mattermost:
215+
input.EnqueueKey(ConsoleKey.A, false, false, true);
216+
input.EnqueueKey(ConsoleKey.Backspace);
217+
input.EnqueueString("https://typed-mattermost.example.com");
218+
input.EnqueueKey(ConsoleKey.Tab);
219+
input.EnqueueString("mattermost-typed-token");
220+
break;
221+
default:
222+
throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null);
223+
}
224+
}
225+
226+
private static void TypeFirstTimeSetup(VirtualInputSource input, ChannelType channelType)
227+
{
228+
switch (channelType)
229+
{
230+
case ChannelType.Slack:
231+
input.EnqueueString("xoxb-first-time-token");
232+
input.EnqueueKey(ConsoleKey.Enter);
233+
input.EnqueueString("xapp-first-time-token");
234+
input.EnqueueKey(ConsoleKey.Enter);
235+
input.EnqueueString("C-first-time");
236+
input.EnqueueKey(ConsoleKey.Enter);
237+
SelectSecondOption(input); // Disable DMs.
238+
SelectSecondOption(input); // Allow anyone in allowed channels.
239+
break;
240+
case ChannelType.Discord:
241+
input.EnqueueString("discord-first-time-token");
242+
input.EnqueueKey(ConsoleKey.Enter);
243+
input.EnqueueString("123456789012345678");
244+
input.EnqueueKey(ConsoleKey.Enter);
245+
SelectSecondOption(input); // Disable DMs.
246+
SelectSecondOption(input); // Allow anyone in allowed channels.
247+
break;
248+
case ChannelType.Mattermost:
249+
input.EnqueueString("https://first-time-mattermost.example.com");
250+
input.EnqueueKey(ConsoleKey.Enter);
251+
input.EnqueueString("mattermost-first-time-token");
252+
input.EnqueueKey(ConsoleKey.Enter);
253+
input.EnqueueString("town-square");
254+
input.EnqueueKey(ConsoleKey.Enter);
255+
SelectSecondOption(input); // Disable DMs.
256+
SelectSecondOption(input); // Allow anyone in allowed channels.
257+
input.EnqueueKey(ConsoleKey.Enter); // Skip optional callback URL.
258+
break;
259+
default:
260+
throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null);
261+
}
262+
}
263+
264+
private static void SelectSecondOption(VirtualInputSource input)
265+
{
266+
input.EnqueueKey(ConsoleKey.DownArrow);
267+
input.EnqueueKey(ConsoleKey.Enter);
268+
}
269+
270+
private static void AssertTypedCredentials(ChannelsConfigViewModel vm, ChannelType channelType)
271+
{
272+
switch (channelType)
273+
{
274+
case ChannelType.Slack:
275+
var slack = vm.Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack);
276+
Assert.Equal("xoxb-typed-token", slack.BotToken);
277+
Assert.Equal("xapp-typed-token", slack.AppToken);
278+
break;
279+
case ChannelType.Discord:
280+
var discord = vm.Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord);
281+
Assert.Equal("discord-typed-token", discord.BotToken);
282+
break;
283+
case ChannelType.Mattermost:
284+
var mattermost = vm.Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost);
285+
Assert.Equal("https://typed-mattermost.example.com", mattermost.ServerUrl);
286+
Assert.Equal("mattermost-typed-token", mattermost.BotToken);
287+
break;
288+
default:
289+
throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null);
290+
}
291+
}
292+
293+
private static void AssertFirstTimeSetup(ChannelsConfigViewModel vm, ChannelType channelType)
294+
{
295+
switch (channelType)
296+
{
297+
case ChannelType.Slack:
298+
var slack = vm.Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack);
299+
Assert.Equal("xoxb-first-time-token", slack.BotToken);
300+
Assert.Equal("xapp-first-time-token", slack.AppToken);
301+
Assert.Equal("C-first-time", slack.ChannelNamesInput);
302+
break;
303+
case ChannelType.Discord:
304+
var discord = vm.Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord);
305+
Assert.Equal("discord-first-time-token", discord.BotToken);
306+
Assert.Equal("123456789012345678", discord.ChannelIdsInput);
307+
break;
308+
case ChannelType.Mattermost:
309+
var mattermost = vm.Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost);
310+
Assert.Equal("https://first-time-mattermost.example.com", mattermost.ServerUrl);
311+
Assert.Equal("mattermost-first-time-token", mattermost.BotToken);
312+
Assert.Equal("town-square", mattermost.ChannelIdsInput);
313+
break;
314+
default:
315+
throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null);
316+
}
317+
}
318+
319+
private void WriteEmptyChannelFiles()
320+
{
321+
File.WriteAllText(_paths.NetclawConfigPath,
322+
"""
323+
{
324+
"configVersion": 1
325+
}
326+
""");
327+
File.WriteAllText(_paths.SecretsPath,
328+
"""
329+
{
330+
"configVersion": 1
331+
}
332+
""");
89333
}
90334

91335
private TerminaApplication CreateHeadlessApp(

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,26 @@ public void Channels_editor_hosts_original_channel_picker_adapters()
5252
Assert.Equal(["Slack", "Discord", "Mattermost"], labels);
5353
}
5454

55+
[Fact]
56+
public void Channels_editor_validator_maps_static_errors_to_fields()
57+
{
58+
var model = new ChannelsEditorModel
59+
{
60+
Slack =
61+
{
62+
Enabled = true,
63+
BotTokenDraft = "not-a-slack-token",
64+
HasPersistedAppToken = true,
65+
}
66+
};
67+
var validator = new ChannelsEditorValidationAdapter();
68+
69+
var result = validator.Validate(model);
70+
71+
var issue = Assert.Single(result.IssuesFor(ChannelsEditorFieldPaths.SlackBotToken));
72+
Assert.Equal(ChannelsEditorValidationMessages.SlackBotTokenPrefix, issue.Message);
73+
}
74+
5575
[Fact]
5676
public void Existing_config_prefills_picker_and_adapter_drafts()
5777
{

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -389,9 +389,7 @@ private bool HandleKeyInfo(ConsoleKeyInfo keyInfo)
389389
if (TryOpenConfiguredAdapter(keyInfo))
390390
return true;
391391

392-
if (!ViewModel.IsSaved.Value
393-
&& ViewModel.StepView.CapturesInput
394-
&& ViewModel.StepView.HandleKeyPress(new KeyPressed(keyInfo)))
392+
if (!ViewModel.IsSaved.Value && ViewModel.StepView.HandleKeyPress(new KeyPressed(keyInfo)))
395393
{
396394
ViewModel.RequestRedraw();
397395
return true;
@@ -652,6 +650,7 @@ private StepViewCallbacks CreateCallbacks()
652650
InvalidateHelp = () => _helpTextNode?.Invalidate(),
653651
AdvanceStep = ViewModel.GoNext,
654652
RequestRedraw = ViewModel.RequestRedraw,
653+
SetStatusMessage = message => ViewModel.Status.Value = new ConfigStatusMessage(message, ConfigStatusTone.Error),
655654
};
656655

657656
private void InvalidateAll()

0 commit comments

Comments
 (0)