package app import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "lingniu/vehicle-data-platform/apps/api/internal/config" "lingniu/vehicle-data-platform/apps/api/internal/platform" ) const ( viewerToken = "viewer-token-at-least-16" operatorToken = "operator-token-at-least-16" adminToken = "admin-token-at-least-16" ) func testAuthConfig() config.Config { return config.Config{ AuthMode: "enforce", AuthTokensJSON: `[ {"token":"` + viewerToken + `","name":"viewer-a","role":"viewer"}, {"token":"` + operatorToken + `","name":"operator-a","role":"operator"}, {"token":"` + adminToken + `","name":"admin-a","role":"admin"} ]`, } } func authRequest(t *testing.T, cfg config.Config, method, path, token string) *httptest.ResponseRecorder { t.Helper() next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { principal, ok := platform.PrincipalFromContext(r.Context()) if !ok { t.Fatal("authenticated request reached handler without principal") } w.Header().Set("X-Principal", principal.Name+":"+principal.Role) w.WriteHeader(http.StatusNoContent) }) req := httptest.NewRequest(method, path, nil) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rec := httptest.NewRecorder() withAPIAuth(next, cfg).ServeHTTP(rec, req) return rec } func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) { cfg := testAuthConfig() missing := authRequest(t, cfg, http.MethodGet, "/api/v2/alerts/rules", "") if missing.Code != http.StatusUnauthorized || !strings.HasPrefix(missing.Header().Get("WWW-Authenticate"), "Bearer") { t.Fatalf("missing token status=%d headers=%v body=%s", missing.Code, missing.Header(), missing.Body.String()) } invalid := authRequest(t, cfg, http.MethodGet, "/api/v2/alerts/rules", "wrong-token-at-least-16") if invalid.Code != http.StatusUnauthorized { t.Fatalf("invalid token status=%d body=%s", invalid.Code, invalid.Body.String()) } viewerQuery := authRequest(t, cfg, http.MethodPost, "/api/v2/alerts/events", viewerToken) if viewerQuery.Code != http.StatusNoContent || viewerQuery.Header().Get("X-Principal") != "viewer-a:viewer" { t.Fatalf("viewer query status=%d principal=%s", viewerQuery.Code, viewerQuery.Header().Get("X-Principal")) } viewerAction := authRequest(t, cfg, http.MethodPost, "/api/v2/alerts/events/a/actions", viewerToken) if viewerAction.Code != http.StatusForbidden { t.Fatalf("viewer mutation should be forbidden, status=%d", viewerAction.Code) } viewerExports := authRequest(t, cfg, http.MethodGet, "/api/v2/exports", viewerToken) if viewerExports.Code != http.StatusNoContent { t.Fatalf("viewer export listing should reach owner/scope enforcement, status=%d", viewerExports.Code) } viewerExportCreate := authRequest(t, cfg, http.MethodPost, "/api/v2/exports", viewerToken) if viewerExportCreate.Code != http.StatusNoContent { t.Fatalf("viewer export creation should reach owner/scope enforcement, status=%d", viewerExportCreate.Code) } operatorExports := authRequest(t, cfg, http.MethodGet, "/api/v2/exports/exp_1/download", operatorToken) if operatorExports.Code != http.StatusNoContent { t.Fatalf("operator export download status=%d", operatorExports.Code) } operatorAction := authRequest(t, cfg, http.MethodPost, "/api/v2/alerts/events/a/actions", operatorToken) if operatorAction.Code != http.StatusNoContent { t.Fatalf("operator action status=%d body=%s", operatorAction.Code, operatorAction.Body.String()) } operatorRule := authRequest(t, cfg, http.MethodPost, "/api/v2/alerts/rules", operatorToken) if operatorRule.Code != http.StatusForbidden { t.Fatalf("operator rule mutation should be forbidden, status=%d", operatorRule.Code) } operatorRollback := authRequest(t, cfg, http.MethodPost, "/api/v2/alerts/rules/rule-1/rollback", operatorToken) if operatorRollback.Code != http.StatusForbidden { t.Fatalf("operator rule rollback should be forbidden, status=%d", operatorRollback.Code) } operatorProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", operatorToken) if operatorProfile.Code != http.StatusForbidden { t.Fatalf("operator profile mutation should be forbidden, status=%d", operatorProfile.Code) } operatorProfileSync := authRequest(t, cfg, http.MethodPost, "/api/v2/vehicle-profiles/sync", operatorToken) if operatorProfileSync.Code != http.StatusForbidden { t.Fatalf("operator profile sync should be forbidden, status=%d", operatorProfileSync.Code) } operatorIdentityClaim := authRequest(t, cfg, http.MethodPost, "/api/v2/access/unresolved-identities/identity-1/claim", operatorToken) if operatorIdentityClaim.Code != http.StatusForbidden { t.Fatalf("operator identity claim should be forbidden, status=%d", operatorIdentityClaim.Code) } viewerSourceDiagnostic := authRequest(t, cfg, http.MethodGet, "/api/v2/operations/vehicles/VIN001/sources", viewerToken) if viewerSourceDiagnostic.Code != http.StatusForbidden { t.Fatalf("viewer source diagnostic should be forbidden, status=%d", viewerSourceDiagnostic.Code) } operatorSourceDiagnostic := authRequest(t, cfg, http.MethodGet, "/api/v2/operations/vehicles/VIN001/sources", operatorToken) if operatorSourceDiagnostic.Code != http.StatusNoContent { t.Fatalf("operator source diagnostic status=%d", operatorSourceDiagnostic.Code) } operatorSourcePolicy := authRequest(t, cfg, http.MethodPut, "/api/v2/operations/vehicles/VIN001/sources/ref", operatorToken) if operatorSourcePolicy.Code != http.StatusForbidden { t.Fatalf("operator source policy mutation should be forbidden, status=%d", operatorSourcePolicy.Code) } viewerReconciliation := authRequest(t, cfg, http.MethodGet, "/api/v2/reconciliation/summary", viewerToken) if viewerReconciliation.Code != http.StatusForbidden { t.Fatalf("viewer reconciliation summary should be forbidden, status=%d", viewerReconciliation.Code) } operatorReconciliation := authRequest(t, cfg, http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-1/actions", operatorToken) if operatorReconciliation.Code != http.StatusNoContent { t.Fatalf("operator reconciliation action status=%d body=%s", operatorReconciliation.Code, operatorReconciliation.Body.String()) } operatorReconciliationArchive := authRequest(t, cfg, http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-1/archive", operatorToken) if operatorReconciliationArchive.Code != http.StatusForbidden { t.Fatalf("operator reconciliation archive should be forbidden, status=%d", operatorReconciliationArchive.Code) } adminReconciliationArchive := authRequest(t, cfg, http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-1/archive", adminToken) if adminReconciliationArchive.Code != http.StatusNoContent { t.Fatalf("admin reconciliation archive status=%d body=%s", adminReconciliationArchive.Code, adminReconciliationArchive.Body.String()) } operatorOpenPlatform := authRequest(t, cfg, http.MethodGet, "/api/v2/open-platform/apps", operatorToken) if operatorOpenPlatform.Code != http.StatusForbidden { t.Fatalf("operator open-platform management should be forbidden, status=%d", operatorOpenPlatform.Code) } adminProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", adminToken) if adminProfile.Code != http.StatusNoContent || adminProfile.Header().Get("X-Principal") != "admin-a:admin" { t.Fatalf("admin profile mutation status=%d principal=%s", adminProfile.Code, adminProfile.Header().Get("X-Principal")) } adminProfileSync := authRequest(t, cfg, http.MethodPost, "/api/v2/vehicle-profiles/sync", adminToken) if adminProfileSync.Code != http.StatusNoContent || adminProfileSync.Header().Get("X-Principal") != "admin-a:admin" { t.Fatalf("admin profile sync status=%d principal=%s", adminProfileSync.Code, adminProfileSync.Header().Get("X-Principal")) } adminThreshold := authRequest(t, cfg, http.MethodPut, "/api/v2/access/thresholds", adminToken) if adminThreshold.Code != http.StatusNoContent { t.Fatalf("admin threshold status=%d body=%s", adminThreshold.Code, adminThreshold.Body.String()) } adminIdentityClaim := authRequest(t, cfg, http.MethodPost, "/api/v2/access/unresolved-identities/identity-1/claim", adminToken) if adminIdentityClaim.Code != http.StatusNoContent { t.Fatalf("admin identity claim status=%d body=%s", adminIdentityClaim.Code, adminIdentityClaim.Body.String()) } adminSourcePolicy := authRequest(t, cfg, http.MethodPut, "/api/v2/operations/vehicles/VIN001/sources/ref", adminToken) if adminSourcePolicy.Code != http.StatusNoContent { t.Fatalf("admin source policy status=%d body=%s", adminSourcePolicy.Code, adminSourcePolicy.Body.String()) } adminOpenPlatform := authRequest(t, cfg, http.MethodGet, "/api/v2/open-platform/apps", adminToken) if adminOpenPlatform.Code != http.StatusNoContent { t.Fatalf("admin open-platform management status=%d body=%s", adminOpenPlatform.Code, adminOpenPlatform.Body.String()) } } func TestDisabledMockModeProvidesOperableIdentityDirectory(t *testing.T) { cfg := config.Config{AuthMode: "disabled", DataMode: "mock"} handler := withAPIAuth(http.NotFoundHandler(), cfg) listResponse := httptest.NewRecorder() handler.ServeHTTP(listResponse, httptest.NewRequest(http.MethodGet, "/api/v2/admin/users", nil)) if listResponse.Code != http.StatusOK { t.Fatalf("mock directory status=%d body=%s", listResponse.Code, listResponse.Body.String()) } var listEnvelope struct { Data []authUser `json:"data"` } if err := json.Unmarshal(listResponse.Body.Bytes(), &listEnvelope); err != nil { t.Fatal(err) } if len(listEnvelope.Data) < 4 || listEnvelope.Data[1].AuthProvider != "OneOS" || listEnvelope.Data[1].ExternalSubject == "" || listEnvelope.Data[2].ExternalSubject != "" { t.Fatalf("mock identity examples are incomplete: %+v", listEnvelope.Data) } updateBody := `{"displayName":"华东数据客户","password":"ChangeMe2026!","status":"enabled","customerRef":"CUS-EAST","tenantRef":"tenant-east","menuKeys":["monitor"],"vehicleVins":["LMRKH9AC2R1004087"]}` updateResponse := httptest.NewRecorder() handler.ServeHTTP(updateResponse, httptest.NewRequest(http.MethodPut, "/api/v2/admin/users/102", strings.NewReader(updateBody))) if updateResponse.Code != http.StatusBadRequest || !strings.Contains(updateResponse.Body.String(), "EXTERNAL_IDENTITY_PASSWORD_READ_ONLY") { t.Fatalf("mock external credential ownership was not enforced: status=%d body=%s", updateResponse.Code, updateResponse.Body.String()) } } func TestAPIAuthSessionAndDisabledMode(t *testing.T) { rec := authRequest(t, config.Config{AuthMode: "disabled"}, http.MethodGet, "/api/v2/alerts/rules", "") if rec.Code != http.StatusNoContent || rec.Header().Get("X-Principal") != "local-developer:admin" { t.Fatalf("disabled mode status=%d principal=%s", rec.Code, rec.Header().Get("X-Principal")) } req := httptest.NewRequest(http.MethodGet, "/api/v2/session", nil) req.Header.Set("Authorization", "Bearer "+operatorToken) session := httptest.NewRecorder() withAPIAuth(http.NotFoundHandler(), testAuthConfig()).ServeHTTP(session, req) if session.Code != http.StatusOK || !strings.Contains(session.Body.String(), `"name":"operator-a"`) || !strings.Contains(session.Body.String(), `"role":"operator"`) { t.Fatalf("session status=%d body=%s", session.Code, session.Body.String()) } } func TestAuthSelfServiceEndpointsAllowCustomerRole(t *testing.T) { if role := requiredRole(httptest.NewRequest(http.MethodPost, "/api/v2/auth/logout", nil)); role != "viewer" { t.Fatalf("logout should allow authenticated customers, required role=%s", role) } if role := requiredRole(httptest.NewRequest(http.MethodPut, "/api/v2/auth/password", nil)); role != "viewer" { t.Fatalf("password change should allow authenticated customers, required role=%s", role) } } func TestMileagePostQueriesAllowCustomerRole(t *testing.T) { for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} { req := httptest.NewRequest(http.MethodPost, path, nil) if role := requiredRole(req); role != "viewer" { t.Fatalf("%s should allow authenticated customers, required role=%s", path, role) } if menu := requiredMenu(req); menu != "statistics" { t.Fatalf("%s should use statistics menu scope, required menu=%s", path, menu) } } } func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) { cases := []config.Config{ {AuthMode: "enforce"}, {AuthMode: "unknown"}, {AuthMode: "enforce", AuthTokensJSON: `not-json`}, {AuthMode: "enforce", AuthToken: "short"}, } for _, cfg := range cases { rec := authRequest(t, cfg, http.MethodGet, "/api/v2/alerts/rules", "") if rec.Code != http.StatusServiceUnavailable { t.Fatalf("misconfigured auth should fail closed: cfg=%+v status=%d", cfg, rec.Code) } } }