package handler import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "time" "vctp/internal/auth" "vctp/internal/settings" "vctp/server/models" ) type stubLDAPAuthenticator struct { identity auth.LDAPIdentity err error } func (s *stubLDAPAuthenticator) AuthenticateAndFetchGroups(_ context.Context, _ string, _ string) (auth.LDAPIdentity, error) { return s.identity, s.err } type stubJWTService struct { token string claims auth.Claims err error } func (s *stubJWTService) IssueToken(_ string, _ []string, _ []string) (string, auth.Claims, error) { return s.token, s.claims, s.err } func TestAuthLoginAuthDisabled(t *testing.T) { h := &Handler{ Logger: newTestLogger(), Settings: &settings.Settings{Values: &settings.SettingsYML{}}, } req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"username":"alice","password":"pw"}`)) rr := httptest.NewRecorder() h.AuthLogin(rr, req) if rr.Code != http.StatusServiceUnavailable { t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rr.Code) } } func TestAuthLoginInvalidCredentials(t *testing.T) { restoreFactories := swapAuthFactoriesForTest( func(_ auth.LDAPConfig) (ldapAuthenticator, error) { return &stubLDAPAuthenticator{err: auth.ErrLDAPInvalidCredentials}, nil }, func(_ auth.JWTConfig) (jwtService, error) { return &stubJWTService{}, nil }, ) defer restoreFactories() h := &Handler{ Logger: newTestLogger(), Settings: testAuthEnabledSettings(), } req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"username":"alice","password":"pw"}`)) rr := httptest.NewRecorder() h.AuthLogin(rr, req) if rr.Code != http.StatusUnauthorized { t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) } var payload models.ErrorResponse if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil { t.Fatalf("failed to decode response: %v", err) } if payload.Message != authLoginFailureMessage { t.Fatalf("unexpected error message: %q", payload.Message) } } func TestAuthLoginRejectsUnmappedRoles(t *testing.T) { restoreFactories := swapAuthFactoriesForTest( func(_ auth.LDAPConfig) (ldapAuthenticator, error) { return &stubLDAPAuthenticator{ identity: auth.LDAPIdentity{ Username: "alice", Groups: []string{"cn=other-group,ou=groups,dc=example,dc=com"}, }, }, nil }, func(_ auth.JWTConfig) (jwtService, error) { return &stubJWTService{}, nil }, ) defer restoreFactories() h := &Handler{ Logger: newTestLogger(), Settings: testAuthEnabledSettings(), } req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"username":"alice","password":"pw"}`)) rr := httptest.NewRecorder() h.AuthLogin(rr, req) if rr.Code != http.StatusUnauthorized { t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) } } func TestAuthLoginSuccess(t *testing.T) { restoreFactories := swapAuthFactoriesForTest( func(_ auth.LDAPConfig) (ldapAuthenticator, error) { return &stubLDAPAuthenticator{ identity: auth.LDAPIdentity{ Username: "alice", UserDN: "cn=alice,ou=users,dc=example,dc=com", Groups: []string{"cn=vctp-admins,ou=groups,dc=example,dc=com"}, }, }, nil }, func(_ auth.JWTConfig) (jwtService, error) { return &stubJWTService{ token: "issued-token", claims: auth.Claims{ ExpiresAt: time.Unix(1_700_000_000, 0).Unix(), }, }, nil }, ) defer restoreFactories() h := &Handler{ Logger: newTestLogger(), Settings: testAuthEnabledSettings(), } req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"username":"alice","password":"pw"}`)) rr := httptest.NewRecorder() h.AuthLogin(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) } var payload models.AuthLoginResponse if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil { t.Fatalf("failed to decode response: %v", err) } if payload.AccessToken != "issued-token" { t.Fatalf("unexpected token: %q", payload.AccessToken) } if payload.TokenType != "Bearer" { t.Fatalf("unexpected token type: %q", payload.TokenType) } } func TestAuthLoginJWTFactoryFailure(t *testing.T) { restoreFactories := swapAuthFactoriesForTest( func(_ auth.LDAPConfig) (ldapAuthenticator, error) { return &stubLDAPAuthenticator{ identity: auth.LDAPIdentity{ Username: "alice", Groups: []string{"cn=vctp-admins,ou=groups,dc=example,dc=com"}, }, }, nil }, func(_ auth.JWTConfig) (jwtService, error) { return nil, errors.New("jwt init failed") }, ) defer restoreFactories() h := &Handler{ Logger: newTestLogger(), Settings: testAuthEnabledSettings(), } req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"username":"alice","password":"pw"}`)) rr := httptest.NewRecorder() h.AuthLogin(rr, req) if rr.Code != http.StatusInternalServerError { t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, rr.Code) } } func testAuthEnabledSettings() *settings.Settings { cfg := &settings.Settings{Values: &settings.SettingsYML{}} cfg.Values.Settings.AuthEnabled = true cfg.Values.Settings.AuthMode = "required" cfg.Values.Settings.AuthJWTSigningKey = base64.StdEncoding.EncodeToString([]byte("test-signing-key")) cfg.Values.Settings.AuthTokenLifespanMinutes = 120 cfg.Values.Settings.AuthJWTIssuer = "vctp" cfg.Values.Settings.AuthJWTAudience = "vctp-api" cfg.Values.Settings.AuthClockSkewSeconds = 60 cfg.Values.Settings.LDAPBindAddress = "ldaps://ldap.example.com:636" cfg.Values.Settings.LDAPBaseDN = "dc=example,dc=com" cfg.Values.Settings.AuthGroupRoleMappings = map[string]string{ "cn=vctp-admins,ou=groups,dc=example,dc=com": "admin", } return cfg } func swapAuthFactoriesForTest( ldapFactory func(auth.LDAPConfig) (ldapAuthenticator, error), jwtFactory func(auth.JWTConfig) (jwtService, error), ) func() { origLDAPFactory := newLDAPAuthenticator origJWTFactory := newJWTService newLDAPAuthenticator = ldapFactory newJWTService = jwtFactory return func() { newLDAPAuthenticator = origLDAPFactory newJWTService = origJWTFactory } }