package externaladmin import ( "errors" "net/http" "net/http/httptest" "os" "strings" "testing" adminmiddleware "hyapp-admin-server/internal/middleware" "github.com/DATA-DOG/go-sqlmock" "github.com/gin-gonic/gin" "gorm.io/driver/mysql" "gorm.io/gorm" ) func TestCreateAccountRejectsUsernameAlreadyUsedByAnotherApp(t *testing.T) { adminSQL, adminMock, err := sqlmock.New() if err != nil { t.Fatalf("create admin sql mock: %v", err) } defer adminSQL.Close() adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{}) if err != nil { t.Fatalf("create admin gorm: %v", err) } userDB, userMock, err := sqlmock.New() if err != nil { t.Fatalf("create user sql mock: %v", err) } defer userDB.Close() // The check intentionally has no app_code predicate. It avoids owner-DB and // bcrypt work for an obvious conflict; migration 100's unique index closes the // concurrent-create race after this advisory check. adminMock.ExpectQuery("SELECT `id` FROM `external_admin_accounts` WHERE username = \\? LIMIT \\?"). WithArgs("shared-operator", 1). WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(9)) service := NewService(adminDB, userDB, Config{}) _, err = service.CreateAccount(t.Context(), CreateInput{ AppCode: "lalu", TargetUserID: "8888", Username: "Shared-Operator", Password: "temporary-password", CreatedByAdminID: 1, }) if !errors.Is(err, ErrAccountConflict) { t.Fatalf("create error = %v, want global username conflict", err) } if err := adminMock.ExpectationsWereMet(); err != nil { t.Fatalf("admin sql expectations: %v", err) } if err := userMock.ExpectationsWereMet(); err != nil { t.Fatalf("user DB must not be queried after global conflict: %v", err) } } func TestLoginIgnoresLegacyRequestAppAndAuditsUnknownAccountWithoutTenant(t *testing.T) { adminSQL, adminMock, err := sqlmock.New() if err != nil { t.Fatalf("create admin sql mock: %v", err) } defer adminSQL.Close() adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{}) if err != nil { t.Fatalf("create admin gorm: %v", err) } adminMock.ExpectQuery("SELECT `id`,`app_code` FROM `external_admin_accounts` WHERE username = \\? LIMIT \\?"). WithArgs("missing-operator", 1). WillReturnRows(sqlmock.NewRows([]string{"id", "app_code"})) // Unknown names use an empty app_code sentinel. The legacy request value "lalu" // must not be copied into security audit data or used to select a tenant. adminMock.ExpectBegin() adminMock.ExpectExec("INSERT INTO `external_admin_login_logs`"). WithArgs(nil, "", "missing-operator", "192.0.2.10", "", "failed", "invalid_credentials", sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(1, 1)) adminMock.ExpectCommit() gin.SetMode(gin.TestMode) engine := gin.New() // Production installs the global App middleware before external routes. Login // must remove its fallback "lalu" response header until credentials resolve. engine.Use(adminmiddleware.AppCode()) handler := New(adminDB, nil, Config{}, nil, WithLoginRateLimiter(allowFixedWindowLimiter{})) RegisterExternalRoutes(engine.Group("/api/v1"), handler, BusinessHandlers{}) request := httptest.NewRequest(http.MethodPost, "/api/v1/external/auth/login", strings.NewReader(`{"appCode":"lalu","account":"missing-operator","password":"wrong-password"}`)) request.Header.Set("Content-Type", "application/json") request.RemoteAddr = "192.0.2.10:12345" responseRecorder := httptest.NewRecorder() engine.ServeHTTP(responseRecorder, request) if responseRecorder.Code != http.StatusUnauthorized { t.Fatalf("status=%d body=%s", responseRecorder.Code, responseRecorder.Body.String()) } if got := responseRecorder.Header().Get("X-App-Code"); got != "" { t.Fatalf("failed tenant-less login exposed default App header %q", got) } if !strings.Contains(responseRecorder.Body.String(), `"code":40100`) || strings.Contains(responseRecorder.Body.String(), "App") { t.Fatalf("login response must expose only stable auth code and generic text: %s", responseRecorder.Body.String()) } if err := adminMock.ExpectationsWereMet(); err != nil { t.Fatalf("admin sql expectations: %v", err) } } func TestGlobalUsernameMigrationIsOnlineAndFailClosed(t *testing.T) { body, err := os.ReadFile("../../../migrations/100_external_admin_global_username.sql") if err != nil { t.Fatalf("read migration 100: %v", err) } sqlText := string(body) for _, token := range []string{ "ADD UNIQUE KEY uk_external_admin_accounts_username (username)", "DROP INDEX uk_external_admin_accounts_app_username", "ALGORITHM=INPLACE", "LOCK=NONE", } { if !strings.Contains(sqlText, token) { t.Fatalf("migration 100 missing %q", token) } } if strings.Contains(strings.ToUpper(sqlText), "UPDATE EXTERNAL_ADMIN_ACCOUNTS") || strings.Contains(strings.ToUpper(sqlText), "DELETE FROM EXTERNAL_ADMIN_ACCOUNTS") { t.Fatal("migration must not silently rename or delete duplicate credentials") } }