/* Copyright 2026 linux.do Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package auth_source import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/linux-do/credit/internal/apps/oauth" "github.com/linux-do/credit/internal/model" "github.com/linux-do/credit/internal/testhelper" "github.com/linux-do/credit/internal/util" ) func setupTestRouter(authUser *model.User) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() adminGroup := r.Group("/api/v1/admin") // Mock authentication middleware adminGroup.Use(func(c *gin.Context) { if authUser != nil { util.SetToContext(c, oauth.UserObjKey, authUser) } c.Next() }) adminGroup.GET("/auth-sources", ListAuthSources) adminGroup.POST("/auth-sources", CreateAuthSource) adminGroup.PUT("/auth-sources/:id", UpdateAuthSource) adminGroup.PUT("/auth-sources/:id/toggle", ToggleAuthSource) adminGroup.DELETE("/auth-sources/:id", DeleteAuthSource) return r } func TestListAuthSources(t *testing.T) { dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() // Seed source source := model.AuthSource{ ID: 1, Name: "google", Type: "oidc", DisplayName: "Google Auth", IsActive: true, ClientID: "client_id_123", ClientSecret: "client_secret_456", OpenIDDiscoveryURL: "https://accounts.google.com", } dbConn.Create(&source) adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} router := setupTestRouter(adminUser) req, _ := http.NewRequest("GET", "/api/v1/admin/auth-sources", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("expected 200 OK, got %d", w.Code) } var resp util.ResponseAny json.Unmarshal(w.Body.Bytes(), &resp) dataBytes, _ := json.Marshal(resp.Data) var sources []model.AuthSource json.Unmarshal(dataBytes, &sources) if len(sources) != 1 { t.Errorf("expected 1 auth source, got %d", len(sources)) } if sources[0].Name != "google" { t.Errorf("expected name 'google', got '%s'", sources[0].Name) } // Verify sanitize removed the secret if sources[0].ClientSecret != "" { t.Error("client secret should be sanitized") } if !sources[0].ClientSecretConfigured { t.Error("client secret configured flag should be true") } } func TestCreateAuthSource(t *testing.T) { dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} router := setupTestRouter(adminUser) t.Run("create successfully", func(t *testing.T) { reqPayload := AuthSourceRequest{ Name: "github", Type: "oidc", DisplayName: "GitHub OIDC", IsActive: true, ClientID: "client_id_gh", ClientSecret: "client_secret_gh", OpenIDDiscoveryURL: "https://github.com", } body, _ := json.Marshal(reqPayload) req, _ := http.NewRequest("POST", "/api/v1/admin/auth-sources", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) } // Verify database var src model.AuthSource dbConn.Where("name = ?", "github").First(&src) if src.ClientID != "client_id_gh" { t.Errorf("expected client_id_gh, got '%s'", src.ClientID) } }) t.Run("create invalid validation failure", func(t *testing.T) { reqPayload := AuthSourceRequest{ Name: "invalid name!", Type: "oidc", DisplayName: "Invalid", IsActive: true, ClientID: "client_id_val", ClientSecret: "client_secret_val", OpenIDDiscoveryURL: "https://discovery.url", } body, _ := json.Marshal(reqPayload) req, _ := http.NewRequest("POST", "/api/v1/admin/auth-sources", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 Bad Request, got %d", w.Code) } }) } func TestUpdateAuthSource(t *testing.T) { dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() // Seed source source := model.AuthSource{ ID: 1, Name: "microsoft", Type: "oidc", DisplayName: "Microsoft", IsActive: true, ClientID: "old_client_id", ClientSecret: "old_secret", OpenIDDiscoveryURL: "https://login.microsoftonline.com", } dbConn.Create(&source) adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} router := setupTestRouter(adminUser) t.Run("update keep client secret", func(t *testing.T) { reqPayload := AuthSourceRequest{ Name: "microsoft", Type: "oidc", DisplayName: "Microsoft Updated", IsActive: true, ClientID: "new_client_id", ClientSecret: "", // empty implies keeping existing secret OpenIDDiscoveryURL: "https://login.microsoftonline.com", } body, _ := json.Marshal(reqPayload) req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/1", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) } var src model.AuthSource dbConn.First(&src, 1) if src.DisplayName != "Microsoft Updated" { t.Errorf("expected display name update, got '%s'", src.DisplayName) } if src.ClientSecret != "old_secret" { t.Errorf("expected old secret to be preserved, got '%s'", src.ClientSecret) } }) t.Run("update new client secret", func(t *testing.T) { reqPayload := AuthSourceRequest{ Name: "microsoft", Type: "oidc", DisplayName: "Microsoft Updated Again", IsActive: true, ClientID: "new_client_id", ClientSecret: "brand_new_secret", OpenIDDiscoveryURL: "https://login.microsoftonline.com", } body, _ := json.Marshal(reqPayload) req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/microsoft", bytes.NewBuffer(body)) // Using Name instead of ID req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) } var src model.AuthSource dbConn.First(&src, 1) if src.ClientSecret != "brand_new_secret" { t.Errorf("expected secret update, got '%s'", src.ClientSecret) } }) } func TestToggleAuthSource(t *testing.T) { dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() source := model.AuthSource{ ID: 1, Name: "test_source", Type: "oidc", DisplayName: "Test Source", IsActive: false, ClientID: "", ClientSecret: "", OpenIDDiscoveryURL: "https://test.discovery.url", } dbConn.Create(&source) adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} router := setupTestRouter(adminUser) t.Run("cannot activate without credentials", func(t *testing.T) { payload := ToggleAuthSourceRequest{IsActive: true} body, _ := json.Marshal(payload) req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/1/toggle", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 Bad Request when activating without client_id/secret, got %d", w.Code) } }) t.Run("toggle success after setting credentials", func(t *testing.T) { // Set credentials first dbConn.Model(&model.AuthSource{}).Where("id = ?", 1).Updates(map[string]interface{}{ "client_id": "id", "client_secret": "secret", }) payload := ToggleAuthSourceRequest{IsActive: true} body, _ := json.Marshal(payload) req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/1/toggle", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) } var src model.AuthSource dbConn.First(&src, 1) if !src.IsActive { t.Error("auth source should be activated") } }) } func TestDeleteAuthSource(t *testing.T) { dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() source := model.AuthSource{ ID: 1, Name: "delete_me", Type: "oidc", DisplayName: "Delete Me", IsActive: true, ClientID: "id", ClientSecret: "secret", OpenIDDiscoveryURL: "https://delete.me", } dbConn.Create(&source) externalAccount := model.ExternalAccount{ ID: 10, AuthSourceID: 1, UserID: 50, ExternalID: "ext_50", } dbConn.Create(&externalAccount) adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} router := setupTestRouter(adminUser) req, _ := http.NewRequest("DELETE", "/api/v1/admin/auth-sources/1", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("expected 200 OK, got %d", w.Code) } // Verify AuthSource is deleted var srcCount int64 dbConn.Model(&model.AuthSource{}).Where("id = ?", 1).Count(&srcCount) if srcCount != 0 { t.Error("AuthSource should be deleted from the database") } // Verify ExternalAccount bindings are also deleted var extCount int64 dbConn.Model(&model.ExternalAccount{}).Where("auth_source_id = ?", 1).Count(&extCount) if extCount != 0 { t.Error("related ExternalAccount bindings should be deleted") } }