124 lines
4.9 KiB
Go
124 lines
4.9 KiB
Go
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")
|
|
}
|
|
}
|