@@ -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 (
0 commit comments