@@ -267,6 +267,201 @@ func TestCreateIssueAllowsWhenGitlabGroupEmpty(t *testing.T) {
267267 assert .Equal (t , http .StatusOK , result .StatusCode )
268268}
269269
270+ func TestCompleteConnectUserToGitlab_StateValidation (t * testing.T ) {
271+ validUserID := "abcdefghijklmnopqrstuvwxyz"
272+
273+ setupPlugin := func (t * testing.T ) * Plugin {
274+ t .Helper ()
275+
276+ siteURL := "https://mattermost.example.com"
277+ mmConfig := & model.Config {}
278+ mmConfig .ServiceSettings .SiteURL = & siteURL
279+
280+ config := configuration {
281+ GitlabURL : "https://gitlab.example.com" ,
282+ GitlabOAuthClientID : "client_id" ,
283+ GitlabOAuthClientSecret : "client_secret" ,
284+ EncryptionKey : "aaaaaaaaaaaaaaaa" ,
285+ }
286+
287+ p := & Plugin {configuration : & config }
288+ p .initializeAPI ()
289+
290+ api := & plugintest.API {}
291+ api .On ("GetConfig" ).Return (mmConfig )
292+ api .On ("KVGet" , mock .Anything ).Return (nil , nil ).Maybe ()
293+ api .On ("LogWarn" , mock .Anything , mock .Anything , mock .Anything ).Return (nil ).Maybe ()
294+ api .On ("LogDebug" , mock .Anything , mock .Anything , mock .Anything ).Return (nil ).Maybe ()
295+ p .SetAPI (api )
296+ p .client = pluginapi .NewClient (api , p .Driver )
297+ p .oauthBroker = NewOAuthBroker (func (_ OAuthCompleteEvent ) {})
298+ return p
299+ }
300+
301+ t .Run ("rejects state with arbitrary KV key name" , func (t * testing.T ) {
302+ p := setupPlugin (t )
303+
304+ w := httptest .NewRecorder ()
305+ r := httptest .NewRequest (http .MethodGet , "/oauth/complete?code=test&state=Gitlab_Instance_Configuration_Map" , nil )
306+ r .Header .Set ("Mattermost-User-ID" , validUserID )
307+
308+ p .ServeHTTP (nil , w , r )
309+
310+ result := w .Result ()
311+ defer func () { _ = result .Body .Close () }()
312+ assert .Equal (t , http .StatusBadRequest , result .StatusCode )
313+ data , _ := io .ReadAll (result .Body )
314+ assert .Contains (t , string (data ), "Invalid OAuth state" )
315+ })
316+
317+ t .Run ("rejects state targeting user token key" , func (t * testing.T ) {
318+ p := setupPlugin (t )
319+
320+ w := httptest .NewRecorder ()
321+ r := httptest .NewRequest (http .MethodGet , "/oauth/complete?code=test&state=" + validUserID + "_usertoken" , nil )
322+ r .Header .Set ("Mattermost-User-ID" , validUserID )
323+
324+ p .ServeHTTP (nil , w , r )
325+
326+ result := w .Result ()
327+ defer func () { _ = result .Body .Close () }()
328+ assert .Equal (t , http .StatusBadRequest , result .StatusCode )
329+ data , _ := io .ReadAll (result .Body )
330+ assert .Contains (t , string (data ), "Invalid OAuth state" )
331+ })
332+
333+ t .Run ("rejects empty state" , func (t * testing.T ) {
334+ p := setupPlugin (t )
335+
336+ w := httptest .NewRecorder ()
337+ r := httptest .NewRequest (http .MethodGet , "/oauth/complete?code=test&state=" , nil )
338+ r .Header .Set ("Mattermost-User-ID" , validUserID )
339+
340+ p .ServeHTTP (nil , w , r )
341+
342+ result := w .Result ()
343+ defer func () { _ = result .Body .Close () }()
344+ assert .Equal (t , http .StatusBadRequest , result .StatusCode )
345+ })
346+
347+ t .Run ("passes validation with correctly formatted state and matching user" , func (t * testing.T ) {
348+ state := "abcdefghijklmno_" + validUserID
349+
350+ siteURL := "https://mattermost.example.com"
351+ mmConfig := & model.Config {}
352+ mmConfig .ServiceSettings .SiteURL = & siteURL
353+
354+ config := configuration {
355+ GitlabURL : "https://gitlab.example.com" ,
356+ GitlabOAuthClientID : "client_id" ,
357+ GitlabOAuthClientSecret : "client_secret" ,
358+ EncryptionKey : "aaaaaaaaaaaaaaaa" ,
359+ }
360+
361+ p := & Plugin {configuration : & config }
362+ p .initializeAPI ()
363+
364+ api := & plugintest.API {}
365+ api .On ("GetConfig" ).Return (mmConfig )
366+ api .On ("KVGet" , state ).Return ([]byte (state ), nil )
367+ api .On ("KVSetWithOptions" , state , []byte (nil ), mock .Anything ).Return (true , nil )
368+ api .On ("KVGet" , instanceConfigNameListKey ).Return (nil , nil )
369+ api .On ("LogDebug" , mock .Anything , mock .Anything , mock .Anything ).Return (nil ).Maybe ()
370+ api .On ("LogWarn" , mock .Anything , mock .Anything , mock .Anything ).Return (nil ).Maybe ()
371+ api .On ("LogWarn" , mock .Anything , mock .Anything , mock .Anything , mock .Anything , mock .Anything , mock .Anything , mock .Anything ).Return (nil ).Maybe ()
372+ p .SetAPI (api )
373+ p .client = pluginapi .NewClient (api , p .Driver )
374+ p .oauthBroker = NewOAuthBroker (func (_ OAuthCompleteEvent ) {})
375+
376+ w := httptest .NewRecorder ()
377+ r := httptest .NewRequest (http .MethodGet , "/oauth/complete?code=test&state=" + state , nil )
378+ r .Header .Set ("Mattermost-User-ID" , validUserID )
379+
380+ p .ServeHTTP (nil , w , r )
381+
382+ result := w .Result ()
383+ defer func () { _ = result .Body .Close () }()
384+
385+ // The request passes state validation and proceeds to the token exchange,
386+ // which fails because there's no real GitLab server — that's an Internal
387+ // Server Error, not a Bad Request or Unauthorized from the validation gates.
388+ assert .Equal (t , http .StatusInternalServerError , result .StatusCode )
389+ data , _ := io .ReadAll (result .Body )
390+ assert .NotContains (t , string (data ), "invalid state" )
391+ assert .NotContains (t , string (data ), "not authorized, incorrect user" )
392+
393+ api .AssertCalled (t , "KVGet" , state )
394+ api .AssertCalled (t , "KVSetWithOptions" , state , []byte (nil ), mock .Anything )
395+ })
396+
397+ t .Run ("returns error when KV delete of state token fails" , func (t * testing.T ) {
398+ state := "abcdefghijklmno_" + validUserID
399+
400+ siteURL := "https://mattermost.example.com"
401+ mmConfig := & model.Config {}
402+ mmConfig .ServiceSettings .SiteURL = & siteURL
403+
404+ config := configuration {
405+ GitlabURL : "https://gitlab.example.com" ,
406+ GitlabOAuthClientID : "client_id" ,
407+ GitlabOAuthClientSecret : "client_secret" ,
408+ EncryptionKey : "aaaaaaaaaaaaaaaa" ,
409+ }
410+
411+ p := & Plugin {configuration : & config }
412+ p .initializeAPI ()
413+
414+ kvDeleteErr := model .NewAppError ("KVDelete" , "plugin.kv_delete.error" , nil , "storage failure" , http .StatusInternalServerError )
415+
416+ api := & plugintest.API {}
417+ api .On ("GetConfig" ).Return (mmConfig )
418+ api .On ("KVGet" , state ).Return ([]byte (state ), nil )
419+ api .On ("KVSetWithOptions" , state , []byte (nil ), mock .Anything ).Return (false , kvDeleteErr )
420+ api .On ("KVGet" , instanceConfigNameListKey ).Return (nil , nil )
421+ api .On ("LogDebug" , mock .Anything , mock .Anything , mock .Anything ).Return (nil ).Maybe ()
422+ api .On ("LogWarn" , mock .Anything , mock .Anything , mock .Anything , mock .Anything , mock .Anything ).Return (nil ).Maybe ()
423+ api .On ("LogWarn" , mock .Anything , mock .Anything , mock .Anything , mock .Anything , mock .Anything , mock .Anything , mock .Anything ).Return (nil ).Maybe ()
424+ p .SetAPI (api )
425+ p .client = pluginapi .NewClient (api , p .Driver )
426+ p .oauthBroker = NewOAuthBroker (func (_ OAuthCompleteEvent ) {})
427+
428+ w := httptest .NewRecorder ()
429+ r := httptest .NewRequest (http .MethodGet , "/oauth/complete?code=test&state=" + state , nil )
430+ r .Header .Set ("Mattermost-User-ID" , validUserID )
431+
432+ p .ServeHTTP (nil , w , r )
433+
434+ result := w .Result ()
435+ defer func () { _ = result .Body .Close () }()
436+
437+ assert .Equal (t , http .StatusInternalServerError , result .StatusCode )
438+ data , _ := io .ReadAll (result .Body )
439+ assert .Contains (t , string (data ), "Error completing OAuth connection" )
440+
441+ api .AssertCalled (t , "KVSetWithOptions" , state , []byte (nil ), mock .Anything )
442+ api .AssertNotCalled (t , "KVGet" , "user_id_usertoken" )
443+ })
444+
445+ t .Run ("rejects state with wrong user ID" , func (t * testing.T ) {
446+ p := setupPlugin (t )
447+
448+ differentUserID := "zyxwvutsrqponmlkjihgfedcba"
449+ state := "abcdefghijklmno_" + differentUserID
450+
451+ w := httptest .NewRecorder ()
452+ r := httptest .NewRequest (http .MethodGet , "/oauth/complete?code=test&state=" + state , nil )
453+ r .Header .Set ("Mattermost-User-ID" , validUserID )
454+
455+ p .ServeHTTP (nil , w , r )
456+
457+ result := w .Result ()
458+ defer func () { _ = result .Body .Close () }()
459+ assert .Equal (t , http .StatusUnauthorized , result .StatusCode )
460+ data , _ := io .ReadAll (result .Body )
461+ assert .Contains (t , string (data ), "Not authorized" )
462+ })
463+ }
464+
270465func TestAttachCommentToIssueReturns403WhenNamespaceNotAllowed (t * testing.T ) {
271466 fakeGitLab := fakeGitLabServer (t , "othergroup/repo" )
272467 defer fakeGitLab .Close ()
0 commit comments