From 79c7b2f90c88a93a49aaee3e2e9867dacce4daf6 Mon Sep 17 00:00:00 2001 From: yusufaytas Date: Wed, 17 Dec 2025 06:48:12 +0300 Subject: [PATCH 1/2] Added team capability. - Adds end to end team capability support: schema structs, provider interface, env-driven handler, /teams/query, / teams/:id, /teams/:id/members, plugin loader, and registry wiring so adapters and plugins can respond like other capabilities. - Extends provider listings/capability normalization so teams appear in /providers/team and accept pluralized routes. - Adds comprehensive registry and HTTP handler tests plus README guidance showing how to configure/query the team capability. --- README.md | 21 +- api/capability.go | 2 + api/plugin_providers.go | 25 + api/providers.go | 3 + api/server.go | 9 + api/team_handler.go | 87 ++++ api/team_handler_test.go | 1005 ++++++++++++++++++++++++++++++++++++++ schema/team.go | 65 +++ team/provider.go | 41 ++ team/provider_test.go | 53 ++ 10 files changed, 1310 insertions(+), 1 deletion(-) create mode 100644 api/team_handler.go create mode 100644 api/team_handler_test.go create mode 100644 schema/team.go create mode 100644 team/provider.go create mode 100644 team/provider_test.go diff --git a/README.md b/README.md index 72af076..0331d67 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Adapters live in separate repos such as: OpsOrch Core never links vendor logic directly. Each capability is resolved at runtime by either importing an **in-process provider** (Go package that registers itself) or by launching a **local plugin binary** that speaks OpsOrch's stdio RPC protocol. At startup OpsOrch checks for environment overrides first, then falls back to any persisted configuration stored via the secret provider. -Environment variables for any capability (`incident`, `alert`, `log`, `metric`, `ticket`, `messaging`, `service`, `deployment`, `secret`): +Environment variables for any capability (`incident`, `alert`, `log`, `metric`, `ticket`, `messaging`, `service`, `deployment`, `team`, `secret`): - `OPSORCH__PROVIDER=` – name passed to the corresponding registry - `OPSORCH__CONFIG=` – decrypted config map forwarded to the constructor - `OPSORCH__PLUGIN=/path/to/binary` – optional local plugin that overrides `OPSORCH__PROVIDER` @@ -118,6 +118,23 @@ curl -s -X POST http://localhost:8080/deployments/query \ # Get a specific deployment (requires deployment provider) curl -s http://localhost:8080/deployments/deploy-123 + +# Query Teams (requires team provider) +curl -s -X POST http://localhost:8080/teams/query \ + -H "Content-Type: application/json" \ + -d '{ + "name": "backend", + "tags": {"type": "team"}, + "scope": { + "service": "api-service" + } + }' + +# Get a specific team (requires team provider) +curl -s http://localhost:8080/teams/engineering + +# Get team members (requires team provider) +curl -s http://localhost:8080/teams/engineering/members ``` Add `-H "Authorization: Bearer "` to each curl when `OPSORCH_BEARER_TOKEN` is set. @@ -145,6 +162,7 @@ If only one is provided the server will refuse to start. Pre-built multi-platform Docker images (linux/amd64, linux/arm64) are automatically published to GitHub Container Registry (GHCR) on every release. **Pull and run the latest version:** + ```bash docker pull ghcr.io/opsorch/opsorch-core:latest docker run --rm -p 8080:8080 ghcr.io/opsorch/opsorch-core:latest @@ -219,6 +237,7 @@ OpsOrch exposes API endpoints for: - Messaging - Services - Deployments +- Teams Schemas live under `schema/` and evolve as the system matures. diff --git a/api/capability.go b/api/capability.go index 169f75d..2bdcad6 100644 --- a/api/capability.go +++ b/api/capability.go @@ -21,6 +21,8 @@ func normalizeCapability(name string) (string, bool) { return "service", true case "deployment", "deployments": return "deployment", true + case "team", "teams": + return "team", true default: return "", false } diff --git a/api/plugin_providers.go b/api/plugin_providers.go index f76fb9f..7780fd9 100644 --- a/api/plugin_providers.go +++ b/api/plugin_providers.go @@ -207,3 +207,28 @@ func (p deploymentPluginProvider) Get(ctx context.Context, id string) (schema.De var res schema.Deployment return res, p.runner.call(ctx, "deployment.get", map[string]any{"id": id}, &res) } + +// Team plugin provider ------------------------------------------------------- + +type teamPluginProvider struct { + runner *pluginRunner +} + +func newTeamPluginProvider(path string, cfg map[string]any) teamPluginProvider { + return teamPluginProvider{runner: newPluginRunner(path, cfg)} +} + +func (p teamPluginProvider) Query(ctx context.Context, query schema.TeamQuery) ([]schema.Team, error) { + var res []schema.Team + return res, p.runner.call(ctx, "team.query", query, &res) +} + +func (p teamPluginProvider) Get(ctx context.Context, id string) (schema.Team, error) { + var res schema.Team + return res, p.runner.call(ctx, "team.get", map[string]any{"id": id}, &res) +} + +func (p teamPluginProvider) Members(ctx context.Context, teamID string) ([]schema.TeamMember, error) { + var res []schema.TeamMember + return res, p.runner.call(ctx, "team.members", map[string]any{"teamID": teamID}, &res) +} diff --git a/api/providers.go b/api/providers.go index deafee0..4465df2 100644 --- a/api/providers.go +++ b/api/providers.go @@ -15,6 +15,7 @@ import ( "github.com/opsorch/opsorch-core/metric" "github.com/opsorch/opsorch-core/orcherr" "github.com/opsorch/opsorch-core/service" + "github.com/opsorch/opsorch-core/team" "github.com/opsorch/opsorch-core/ticket" ) @@ -47,6 +48,8 @@ func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) bool { providers = service.Providers() case "deployment": providers = deployment.Providers() + case "team": + providers = team.Providers() } writeJSON(w, http.StatusOK, map[string]any{"providers": providers}) return true diff --git a/api/server.go b/api/server.go index 7a2a991..e7705d0 100644 --- a/api/server.go +++ b/api/server.go @@ -25,6 +25,7 @@ type Server struct { messaging MessagingHandler service ServiceHandler deployment DeploymentHandler + team TeamHandler secret SecretProvider } @@ -81,6 +82,12 @@ func NewServerFromEnv(ctx context.Context) (*Server, error) { log.Printf("Failed to initialize deployment provider: %v", err) dep = DeploymentHandler{} // Empty handler with nil provider } + tm, err := newTeamHandlerFromEnv(sec) + if err != nil { + // Log the error but continue startup with team capability disabled + log.Printf("Failed to initialize team provider: %v", err) + tm = TeamHandler{} // Empty handler with nil provider + } _ = ctx // reserved for future use @@ -97,6 +104,7 @@ func NewServerFromEnv(ctx context.Context) (*Server, error) { messaging: msg, service: svc, deployment: dep, + team: tm, secret: sec, }, nil } @@ -139,6 +147,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { case s.handleMessaging(w, r): case s.handleService(w, r): case s.handleDeployment(w, r): + case s.handleTeam(w, r): default: http.NotFound(w, r) } diff --git a/api/team_handler.go b/api/team_handler.go new file mode 100644 index 0000000..cc48e84 --- /dev/null +++ b/api/team_handler.go @@ -0,0 +1,87 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "github.com/opsorch/opsorch-core/orcherr" + "github.com/opsorch/opsorch-core/schema" + "github.com/opsorch/opsorch-core/team" +) + +// TeamHandler wraps provider wiring for teams. +type TeamHandler struct { + provider team.Provider +} + +func newTeamHandlerFromEnv(sec SecretProvider) (TeamHandler, error) { + name, cfg, pluginPath, err := loadProviderConfig(sec, "team", "OPSORCH_TEAM_PROVIDER", "OPSORCH_TEAM_CONFIG", "OPSORCH_TEAM_PLUGIN") + if err != nil || (name == "" && pluginPath == "") { + return TeamHandler{}, err + } + if pluginPath != "" { + return TeamHandler{provider: newTeamPluginProvider(pluginPath, cfg)}, nil + } + constructor, ok := team.LookupProvider(name) + if !ok { + return TeamHandler{}, fmt.Errorf("team provider %s not registered", name) + } + provider, err := constructor(cfg) + if err != nil { + return TeamHandler{}, err + } + return TeamHandler{provider: provider}, nil +} + +func (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) bool { + if !strings.HasPrefix(r.URL.Path, "/teams") { + return false + } + if s.team.provider == nil { + writeError(w, http.StatusNotImplemented, orcherr.OpsOrchError{Code: "team_provider_missing", Message: "team provider not configured"}) + return true + } + + path := strings.TrimSuffix(r.URL.Path, "/") + segments := strings.Split(strings.Trim(path, "/"), "/") + + switch { + case len(segments) == 2 && segments[1] == "query" && r.Method == http.MethodPost: + var query schema.TeamQuery + if err := decodeJSON(r, &query); err != nil { + writeError(w, http.StatusBadRequest, orcherr.OpsOrchError{Code: "bad_request", Message: err.Error()}) + return true + } + teams, err := s.team.provider.Query(r.Context(), query) + if err != nil { + writeProviderError(w, err) + return true + } + logAudit(r, "team.query") + writeJSON(w, http.StatusOK, teams) + return true + case len(segments) == 2 && r.Method == http.MethodGet: + id := segments[1] + team, err := s.team.provider.Get(r.Context(), id) + if err != nil { + writeProviderError(w, err) + return true + } + logAudit(r, "team.get") + writeJSON(w, http.StatusOK, team) + return true + case len(segments) == 3 && segments[2] == "members" && r.Method == http.MethodGet: + teamID := segments[1] + members, err := s.team.provider.Members(r.Context(), teamID) + if err != nil { + writeProviderError(w, err) + return true + } + logAudit(r, "team.members") + writeJSON(w, http.StatusOK, members) + return true + default: + return false + } +} \ No newline at end of file diff --git a/api/team_handler_test.go b/api/team_handler_test.go new file mode 100644 index 0000000..cd88dc9 --- /dev/null +++ b/api/team_handler_test.go @@ -0,0 +1,1005 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/opsorch/opsorch-core/orcherr" + "github.com/opsorch/opsorch-core/schema" + "github.com/opsorch/opsorch-core/team" +) + +// Mock team provider for testing +type mockTeamProvider struct { + queryFunc func(ctx context.Context, query schema.TeamQuery) ([]schema.Team, error) + getFunc func(ctx context.Context, id string) (schema.Team, error) + membersFunc func(ctx context.Context, teamID string) ([]schema.TeamMember, error) +} + +func (m *mockTeamProvider) Query(ctx context.Context, query schema.TeamQuery) ([]schema.Team, error) { + if m.queryFunc != nil { + return m.queryFunc(ctx, query) + } + return []schema.Team{}, nil +} + +func (m *mockTeamProvider) Get(ctx context.Context, id string) (schema.Team, error) { + if m.getFunc != nil { + return m.getFunc(ctx, id) + } + return schema.Team{}, nil +} + +func (m *mockTeamProvider) Members(ctx context.Context, teamID string) ([]schema.TeamMember, error) { + if m.membersFunc != nil { + return m.membersFunc(ctx, teamID) + } + return []schema.TeamMember{}, nil +} + +// Register mock provider for testing +func init() { + team.RegisterProvider("mock", func(config map[string]any) (team.Provider, error) { + return &mockTeamProvider{}, nil + }) +} + +// handleTeamRequest is a helper to test team handler directly +func (h *TeamHandler) handleTeamRequest(w http.ResponseWriter, r *http.Request) bool { + if !strings.HasPrefix(r.URL.Path, "/teams") { + return false + } + if h.provider == nil { + writeError(w, http.StatusNotImplemented, orcherr.OpsOrchError{Code: "team_provider_missing", Message: "team provider not configured"}) + return true + } + + path := strings.TrimSuffix(r.URL.Path, "/") + segments := strings.Split(strings.Trim(path, "/"), "/") + + switch { + case len(segments) == 2 && segments[1] == "query" && r.Method == http.MethodPost: + var query schema.TeamQuery + if err := decodeJSON(r, &query); err != nil { + writeError(w, http.StatusBadRequest, orcherr.OpsOrchError{Code: "bad_request", Message: err.Error()}) + return true + } + teams, err := h.provider.Query(r.Context(), query) + if err != nil { + writeProviderError(w, err) + return true + } + logAudit(r, "team.query") + writeJSON(w, http.StatusOK, teams) + return true + case len(segments) == 2 && r.Method == http.MethodGet: + id := segments[1] + team, err := h.provider.Get(r.Context(), id) + if err != nil { + writeProviderError(w, err) + return true + } + logAudit(r, "team.get") + writeJSON(w, http.StatusOK, team) + return true + case len(segments) == 3 && segments[2] == "members" && r.Method == http.MethodGet: + teamID := segments[1] + members, err := h.provider.Members(r.Context(), teamID) + if err != nil { + writeProviderError(w, err) + return true + } + logAudit(r, "team.members") + writeJSON(w, http.StatusOK, members) + return true + default: + return false + } +} + +// **Feature: team-capability, Property: Environment configuration processing** +func TestTeamProperty_EnvironmentConfigurationProcessing(t *testing.T) { + testCases := []struct { + name string + provider string + config string + plugin string + expectError bool + expectNilProvider bool + }{ + { + name: "valid provider configuration", + provider: "mock", + config: `{"test": "value"}`, + expectError: false, + expectNilProvider: false, + }, + { + name: "no configuration", + provider: "", + config: "", + plugin: "", + expectError: false, + expectNilProvider: true, + }, + { + name: "invalid provider name", + provider: "nonexistent", + config: `{"test": "value"}`, + expectError: true, + expectNilProvider: true, + }, + { + name: "plugin path specified", + provider: "", + config: "", + plugin: "/path/to/plugin", + expectError: false, + expectNilProvider: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + oldProvider := os.Getenv("OPSORCH_TEAM_PROVIDER") + oldConfig := os.Getenv("OPSORCH_TEAM_CONFIG") + oldPlugin := os.Getenv("OPSORCH_TEAM_PLUGIN") + + os.Setenv("OPSORCH_TEAM_PROVIDER", tc.provider) + os.Setenv("OPSORCH_TEAM_CONFIG", tc.config) + os.Setenv("OPSORCH_TEAM_PLUGIN", tc.plugin) + + defer func() { + os.Setenv("OPSORCH_TEAM_PROVIDER", oldProvider) + os.Setenv("OPSORCH_TEAM_CONFIG", oldConfig) + os.Setenv("OPSORCH_TEAM_PLUGIN", oldPlugin) + }() + + mockSec := &mockSecretProvider{} + handler, err := newTeamHandlerFromEnv(mockSec) + + if tc.expectError && err == nil { + t.Errorf("expected error but got none") + } + if !tc.expectError && err != nil { + t.Errorf("unexpected error: %v", err) + } + if tc.expectNilProvider && handler.provider != nil { + t.Errorf("expected nil provider but got non-nil") + } + if !tc.expectNilProvider && !tc.expectError && handler.provider == nil { + t.Errorf("expected non-nil provider but got nil") + } + }) + } +} + +// **Feature: team-capability, Property: Team query body processing** +func TestTeamProperty_TeamQueryBodyProcessing(t *testing.T) { + mockProvider := &mockTeamProvider{ + queryFunc: func(ctx context.Context, query schema.TeamQuery) ([]schema.Team, error) { + teams := []schema.Team{ + { + ID: "team-1", + Name: query.Name, + Tags: query.Tags, + }, + } + return teams, nil + }, + } + + handler := &TeamHandler{provider: mockProvider} + + testCases := []struct { + name string + query schema.TeamQuery + }{ + { + name: "query with name filter", + query: schema.TeamQuery{ + Name: "backend", + }, + }, + { + name: "query with tags filter", + query: schema.TeamQuery{ + Tags: map[string]string{ + "department": "engineering", + "region": "us-west", + }, + }, + }, + { + name: "query with scope", + query: schema.TeamQuery{ + Scope: schema.QueryScope{ + Service: "api-service", + Environment: "production", + }, + }, + }, + { + name: "query with metadata", + query: schema.TeamQuery{ + Metadata: map[string]any{ + "custom_field": "value", + }, + }, + }, + { + name: "complex query with all fields", + query: schema.TeamQuery{ + Name: "platform", + Tags: map[string]string{ + "team_type": "core", + }, + Scope: schema.QueryScope{ + Service: "payment-service", + Environment: "staging", + Team: "backend", + }, + Metadata: map[string]any{ + "oncall": true, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + bodyBytes, _ := json.Marshal(tc.query) + req := httptest.NewRequest("POST", "/teams/query", strings.NewReader(string(bodyBytes))) + req.Header.Set("Content-Type", "application/json") + + recorder := httptest.NewRecorder() + handled := handler.handleTeamRequest(recorder, req) + + if !handled { + t.Errorf("expected request to be handled") + } + + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", recorder.Code) + } + + var teams []schema.Team + if err := json.Unmarshal(recorder.Body.Bytes(), &teams); err != nil { + t.Errorf("failed to unmarshal response: %v", err) + } + + if len(teams) == 0 { + t.Errorf("expected at least one team in response") + } + + // Verify that name filter was passed correctly + if tc.query.Name != "" && teams[0].Name != tc.query.Name { + t.Errorf("expected name %s, got %s", tc.query.Name, teams[0].Name) + } + }) + } +} + +// **Feature: team-capability, Property: Team ID retrieval** +func TestTeamProperty_TeamIDRetrieval(t *testing.T) { + testCases := []struct { + name string + teamID string + mockTeam schema.Team + expectError bool + }{ + { + name: "valid team ID", + teamID: "team-123", + mockTeam: schema.Team{ + ID: "team-123", + Name: "Backend Team", + Parent: "engineering", + Tags: map[string]string{"department": "engineering"}, + }, + expectError: false, + }, + { + name: "team ID with special characters", + teamID: "team-abc-123_def", + mockTeam: schema.Team{ + ID: "team-abc-123_def", + Name: "Platform Team", + }, + expectError: false, + }, + { + name: "long team ID", + teamID: "very-long-team-id-with-many-characters-12345678901234567890", + mockTeam: schema.Team{ + ID: "very-long-team-id-with-many-characters-12345678901234567890", + Name: "Data Science Team", + }, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockProvider := &mockTeamProvider{ + getFunc: func(ctx context.Context, id string) (schema.Team, error) { + if id == tc.teamID { + return tc.mockTeam, nil + } + return schema.Team{}, fmt.Errorf("team not found") + }, + } + + handler := &TeamHandler{provider: mockProvider} + + req := httptest.NewRequest("GET", fmt.Sprintf("/teams/%s", tc.teamID), nil) + recorder := httptest.NewRecorder() + + handled := handler.handleTeamRequest(recorder, req) + + if !handled { + t.Errorf("expected request to be handled") + } + + if tc.expectError { + if recorder.Code == http.StatusOK { + t.Errorf("expected error response, got status 200") + } + } else { + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", recorder.Code) + } + + var team schema.Team + if err := json.Unmarshal(recorder.Body.Bytes(), &team); err != nil { + t.Errorf("failed to unmarshal response: %v", err) + } + + if team.ID != tc.teamID { + t.Errorf("expected team ID %s, got %s", tc.teamID, team.ID) + } + if team.Name != tc.mockTeam.Name { + t.Errorf("expected name %s, got %s", tc.mockTeam.Name, team.Name) + } + if team.Parent != tc.mockTeam.Parent { + t.Errorf("expected parent %s, got %s", tc.mockTeam.Parent, team.Parent) + } + } + }) + } +} + +// **Feature: team-capability, Property: Team members retrieval** +func TestTeamProperty_TeamMembersRetrieval(t *testing.T) { + testCases := []struct { + name string + teamID string + mockMembers []schema.TeamMember + expectError bool + }{ + { + name: "team with multiple members", + teamID: "team-123", + mockMembers: []schema.TeamMember{ + {ID: "user-1", Name: "Alice", Email: "alice@example.com", Role: "owner"}, + {ID: "user-2", Name: "Bob", Email: "bob@example.com", Role: "member"}, + {ID: "user-3", Name: "Charlie", Email: "charlie@example.com", Role: "oncall"}, + }, + expectError: false, + }, + { + name: "team with no members", + teamID: "empty-team", + mockMembers: []schema.TeamMember{}, + expectError: false, + }, + { + name: "team with member handles", + teamID: "team-456", + mockMembers: []schema.TeamMember{ + { + ID: "user-4", + Name: "Diana", + Email: "diana@example.com", + Handle: "@diana-slack", + Role: "manager", + Metadata: map[string]any{ + "slack_id": "U1234567890", + }, + }, + }, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockProvider := &mockTeamProvider{ + membersFunc: func(ctx context.Context, teamID string) ([]schema.TeamMember, error) { + if teamID == tc.teamID { + return tc.mockMembers, nil + } + return nil, fmt.Errorf("team not found") + }, + } + + handler := &TeamHandler{provider: mockProvider} + + req := httptest.NewRequest("GET", fmt.Sprintf("/teams/%s/members", tc.teamID), nil) + recorder := httptest.NewRecorder() + + handled := handler.handleTeamRequest(recorder, req) + + if !handled { + t.Errorf("expected request to be handled") + } + + if tc.expectError { + if recorder.Code == http.StatusOK { + t.Errorf("expected error response, got status 200") + } + } else { + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", recorder.Code) + } + + var members []schema.TeamMember + if err := json.Unmarshal(recorder.Body.Bytes(), &members); err != nil { + t.Errorf("failed to unmarshal response: %v", err) + } + + if len(members) != len(tc.mockMembers) { + t.Errorf("expected %d members, got %d", len(tc.mockMembers), len(members)) + } + + for i, member := range members { + if member.ID != tc.mockMembers[i].ID { + t.Errorf("expected member ID %s, got %s", tc.mockMembers[i].ID, member.ID) + } + if member.Name != tc.mockMembers[i].Name { + t.Errorf("expected name %s, got %s", tc.mockMembers[i].Name, member.Name) + } + if member.Email != tc.mockMembers[i].Email { + t.Errorf("expected email %s, got %s", tc.mockMembers[i].Email, member.Email) + } + if member.Role != tc.mockMembers[i].Role { + t.Errorf("expected role %s, got %s", tc.mockMembers[i].Role, member.Role) + } + } + } + }) + } +} + +// **Feature: team-capability, Property: Provider error handling** +func TestTeamProperty_ProviderErrorHandling(t *testing.T) { + testCases := []struct { + name string + providerError error + expectedStatus int + expectedCode string + }{ + { + name: "provider not found error", + providerError: &orcherr.OpsOrchError{Code: "not_found", Message: "team not found"}, + expectedStatus: http.StatusNotFound, + expectedCode: "not_found", + }, + { + name: "provider bad request error", + providerError: &orcherr.OpsOrchError{Code: "bad_request", Message: "invalid query parameters"}, + expectedStatus: http.StatusBadRequest, + expectedCode: "bad_request", + }, + { + name: "provider generic error", + providerError: &orcherr.OpsOrchError{Code: "provider_error", Message: "upstream service unavailable"}, + expectedStatus: http.StatusBadGateway, + expectedCode: "provider_error", + }, + { + name: "non-OpsOrch error", + providerError: fmt.Errorf("generic error from provider"), + expectedStatus: http.StatusBadGateway, + expectedCode: "provider_error", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Test error handling for Query method + t.Run("query_error", func(t *testing.T) { + mockProvider := &mockTeamProvider{ + queryFunc: func(ctx context.Context, query schema.TeamQuery) ([]schema.Team, error) { + return nil, tc.providerError + }, + } + + handler := &TeamHandler{provider: mockProvider} + + body := `{"name": "test"}` + req := httptest.NewRequest("POST", "/teams/query", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + handled := handler.handleTeamRequest(recorder, req) + + if !handled { + t.Errorf("expected request to be handled") + } + + if recorder.Code != tc.expectedStatus { + t.Errorf("expected status %d, got %d", tc.expectedStatus, recorder.Code) + } + + var errorResponse map[string]string + if err := json.Unmarshal(recorder.Body.Bytes(), &errorResponse); err != nil { + t.Errorf("failed to unmarshal error response: %v", err) + } + + if errorResponse["code"] != tc.expectedCode { + t.Errorf("expected error code %s, got %s", tc.expectedCode, errorResponse["code"]) + } + }) + + // Test error handling for Get method + t.Run("get_error", func(t *testing.T) { + mockProvider := &mockTeamProvider{ + getFunc: func(ctx context.Context, id string) (schema.Team, error) { + return schema.Team{}, tc.providerError + }, + } + + handler := &TeamHandler{provider: mockProvider} + + req := httptest.NewRequest("GET", "/teams/test-id", nil) + recorder := httptest.NewRecorder() + + handled := handler.handleTeamRequest(recorder, req) + + if !handled { + t.Errorf("expected request to be handled") + } + + if recorder.Code != tc.expectedStatus { + t.Errorf("expected status %d, got %d", tc.expectedStatus, recorder.Code) + } + + var errorResponse map[string]string + if err := json.Unmarshal(recorder.Body.Bytes(), &errorResponse); err != nil { + t.Errorf("failed to unmarshal error response: %v", err) + } + + if errorResponse["code"] != tc.expectedCode { + t.Errorf("expected error code %s, got %s", tc.expectedCode, errorResponse["code"]) + } + }) + + // Test error handling for Members method + t.Run("members_error", func(t *testing.T) { + mockProvider := &mockTeamProvider{ + membersFunc: func(ctx context.Context, teamID string) ([]schema.TeamMember, error) { + return nil, tc.providerError + }, + } + + handler := &TeamHandler{provider: mockProvider} + + req := httptest.NewRequest("GET", "/teams/test-id/members", nil) + recorder := httptest.NewRecorder() + + handled := handler.handleTeamRequest(recorder, req) + + if !handled { + t.Errorf("expected request to be handled") + } + + if recorder.Code != tc.expectedStatus { + t.Errorf("expected status %d, got %d", tc.expectedStatus, recorder.Code) + } + + var errorResponse map[string]string + if err := json.Unmarshal(recorder.Body.Bytes(), &errorResponse); err != nil { + t.Errorf("failed to unmarshal error response: %v", err) + } + + if errorResponse["code"] != tc.expectedCode { + t.Errorf("expected error code %s, got %s", tc.expectedCode, errorResponse["code"]) + } + }) + }) + } +} + +// **Feature: team-capability, Property: Error response consistency** +func TestTeamProperty_ErrorResponseConsistency(t *testing.T) { + testCases := []struct { + name string + setupHandler func() *TeamHandler + setupRequest func() *http.Request + expectedStatus int + expectedFields []string + }{ + { + name: "no provider configured", + setupHandler: func() *TeamHandler { + return &TeamHandler{provider: nil} + }, + setupRequest: func() *http.Request { + return httptest.NewRequest("POST", "/teams/query", strings.NewReader(`{"name": "test"}`)) + }, + expectedStatus: http.StatusNotImplemented, + expectedFields: []string{"code", "message"}, + }, + { + name: "invalid JSON body", + setupHandler: func() *TeamHandler { + return &TeamHandler{provider: &mockTeamProvider{}} + }, + setupRequest: func() *http.Request { + return httptest.NewRequest("POST", "/teams/query", strings.NewReader(`{invalid json`)) + }, + expectedStatus: http.StatusBadRequest, + expectedFields: []string{"code", "message"}, + }, + { + name: "provider error", + setupHandler: func() *TeamHandler { + return &TeamHandler{ + provider: &mockTeamProvider{ + queryFunc: func(ctx context.Context, query schema.TeamQuery) ([]schema.Team, error) { + return nil, fmt.Errorf("provider connection failed") + }, + }, + } + }, + setupRequest: func() *http.Request { + return httptest.NewRequest("POST", "/teams/query", strings.NewReader(`{"name": "test"}`)) + }, + expectedStatus: http.StatusBadGateway, + expectedFields: []string{"code", "message"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + handler := tc.setupHandler() + req := tc.setupRequest() + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + handled := handler.handleTeamRequest(recorder, req) + + if !handled { + t.Errorf("expected request to be handled") + } + + if recorder.Code != tc.expectedStatus { + t.Errorf("expected status %d, got %d", tc.expectedStatus, recorder.Code) + } + + contentType := recorder.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("expected Content-Type application/json, got %s", contentType) + } + + var response map[string]interface{} + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Errorf("response is not valid JSON: %v", err) + } + + for _, field := range tc.expectedFields { + if _, exists := response[field]; !exists { + t.Errorf("expected field %s not found in response", field) + } + } + + if code, exists := response["code"]; exists { + if _, ok := code.(string); !ok { + t.Errorf("expected code field to be string, got %T", code) + } + } + + if message, exists := response["message"]; exists { + if _, ok := message.(string); !ok { + t.Errorf("expected message field to be string, got %T", message) + } + } + }) + } +} + +// **Feature: team-capability, Property: Startup resilience** +func TestTeamProperty_StartupResilience(t *testing.T) { + testCases := []struct { + name string + provider string + config string + plugin string + expectServerStartup bool + expectTeamEnabled bool + }{ + { + name: "valid team provider", + provider: "mock", + config: `{"test": "value"}`, + expectServerStartup: true, + expectTeamEnabled: true, + }, + { + name: "invalid team provider", + provider: "nonexistent", + config: `{"test": "value"}`, + expectServerStartup: true, // Server should still start + expectTeamEnabled: false, // But team should be disabled + }, + { + name: "no team provider", + provider: "", + config: "", + plugin: "", + expectServerStartup: true, + expectTeamEnabled: false, + }, + { + name: "invalid JSON config", + provider: "mock", + config: `{invalid json}`, + expectServerStartup: true, // Server should still start + expectTeamEnabled: false, // But team should be disabled + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + oldProvider := os.Getenv("OPSORCH_TEAM_PROVIDER") + oldConfig := os.Getenv("OPSORCH_TEAM_CONFIG") + oldPlugin := os.Getenv("OPSORCH_TEAM_PLUGIN") + + os.Setenv("OPSORCH_TEAM_PROVIDER", tc.provider) + os.Setenv("OPSORCH_TEAM_CONFIG", tc.config) + os.Setenv("OPSORCH_TEAM_PLUGIN", tc.plugin) + + defer func() { + os.Setenv("OPSORCH_TEAM_PROVIDER", oldProvider) + os.Setenv("OPSORCH_TEAM_CONFIG", oldConfig) + os.Setenv("OPSORCH_TEAM_PLUGIN", oldPlugin) + }() + + server, err := NewServerFromEnv(context.Background()) + + if tc.expectServerStartup { + if err != nil { + t.Errorf("expected server to start successfully, got error: %v", err) + } + if server == nil { + t.Errorf("expected server to be created") + } + + if server != nil { + hasProvider := server.team.provider != nil + if tc.expectTeamEnabled && !hasProvider { + t.Errorf("expected team to be enabled but provider is nil") + } + if !tc.expectTeamEnabled && hasProvider { + t.Errorf("expected team to be disabled but provider is not nil") + } + } + } else { + if err == nil { + t.Errorf("expected server startup to fail") + } + } + }) + } +} + +// **Feature: team-capability, Property: Endpoint routing** +func TestTeamProperty_EndpointRouting(t *testing.T) { + mockProvider := &mockTeamProvider{ + queryFunc: func(ctx context.Context, query schema.TeamQuery) ([]schema.Team, error) { + return []schema.Team{{ID: "team-1", Name: "Test Team"}}, nil + }, + getFunc: func(ctx context.Context, id string) (schema.Team, error) { + return schema.Team{ID: id, Name: "Test Team"}, nil + }, + membersFunc: func(ctx context.Context, teamID string) ([]schema.TeamMember, error) { + return []schema.TeamMember{{ID: "user-1", Name: "Test User"}}, nil + }, + } + + server := &Server{ + corsOrigin: "*", + team: TeamHandler{provider: mockProvider}, + } + + testCases := []struct { + name string + method string + path string + body string + expectedStatus int + expectHandled bool + }{ + { + name: "POST /teams/query", + method: "POST", + path: "/teams/query", + body: `{"name": "test"}`, + expectedStatus: http.StatusOK, + expectHandled: true, + }, + { + name: "GET /teams/{id}", + method: "GET", + path: "/teams/team-123", + expectedStatus: http.StatusOK, + expectHandled: true, + }, + { + name: "GET /teams/{id}/members", + method: "GET", + path: "/teams/team-123/members", + expectedStatus: http.StatusOK, + expectHandled: true, + }, + { + name: "invalid method for query", + method: "GET", + path: "/teams/query", + expectedStatus: http.StatusOK, // Will be handled as GET /teams/{id} where id="query" + expectHandled: true, + }, + { + name: "non-team path returns false", + method: "GET", + path: "/incidents/123", + expectedStatus: 0, + expectHandled: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var req *http.Request + if tc.body != "" { + req = httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(tc.method, tc.path, nil) + } + recorder := httptest.NewRecorder() + + handled := server.handleTeam(recorder, req) + + if handled != tc.expectHandled { + t.Errorf("expected handled=%v, got %v", tc.expectHandled, handled) + } + + if tc.expectHandled && recorder.Code != tc.expectedStatus { + t.Errorf("expected status %d, got %d", tc.expectedStatus, recorder.Code) + } + }) + } +} + +// **Feature: team-capability, Property: Full server integration** +func TestTeamProperty_FullServerIntegration(t *testing.T) { + mockProvider := &mockTeamProvider{ + queryFunc: func(ctx context.Context, query schema.TeamQuery) ([]schema.Team, error) { + return []schema.Team{ + { + ID: "team-backend", + Name: "Backend Team", + Parent: "engineering", + Tags: map[string]string{"department": "engineering"}, + }, + }, nil + }, + getFunc: func(ctx context.Context, id string) (schema.Team, error) { + return schema.Team{ + ID: id, + Name: "Backend Team", + Parent: "engineering", + }, nil + }, + membersFunc: func(ctx context.Context, teamID string) ([]schema.TeamMember, error) { + return []schema.TeamMember{ + {ID: "user-1", Name: "Alice", Email: "alice@example.com", Role: "owner"}, + {ID: "user-2", Name: "Bob", Email: "bob@example.com", Role: "member"}, + }, nil + }, + } + + server := &Server{ + corsOrigin: "*", + team: TeamHandler{provider: mockProvider}, + } + + // Test query endpoint through full server + t.Run("query through server", func(t *testing.T) { + req := httptest.NewRequest("POST", "/teams/query", strings.NewReader(`{"name": "backend"}`)) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + server.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", recorder.Code) + } + + var teams []schema.Team + if err := json.Unmarshal(recorder.Body.Bytes(), &teams); err != nil { + t.Errorf("failed to unmarshal response: %v", err) + } + + if len(teams) != 1 { + t.Errorf("expected 1 team, got %d", len(teams)) + } + }) + + // Test get endpoint through full server + t.Run("get through server", func(t *testing.T) { + req := httptest.NewRequest("GET", "/teams/team-backend", nil) + recorder := httptest.NewRecorder() + + server.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", recorder.Code) + } + + var team schema.Team + if err := json.Unmarshal(recorder.Body.Bytes(), &team); err != nil { + t.Errorf("failed to unmarshal response: %v", err) + } + + if team.ID != "team-backend" { + t.Errorf("expected team ID team-backend, got %s", team.ID) + } + }) + + // Test members endpoint through full server + t.Run("members through server", func(t *testing.T) { + req := httptest.NewRequest("GET", "/teams/team-backend/members", nil) + recorder := httptest.NewRecorder() + + server.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", recorder.Code) + } + + var members []schema.TeamMember + if err := json.Unmarshal(recorder.Body.Bytes(), &members); err != nil { + t.Errorf("failed to unmarshal response: %v", err) + } + + if len(members) != 2 { + t.Errorf("expected 2 members, got %d", len(members)) + } + }) + + // Test CORS headers + t.Run("CORS headers", func(t *testing.T) { + req := httptest.NewRequest("OPTIONS", "/teams/query", nil) + recorder := httptest.NewRecorder() + + server.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200 for OPTIONS, got %d", recorder.Code) + } + + corsOrigin := recorder.Header().Get("Access-Control-Allow-Origin") + if corsOrigin != "*" { + t.Errorf("expected CORS origin *, got %s", corsOrigin) + } + }) +} diff --git a/schema/team.go b/schema/team.go new file mode 100644 index 0000000..b370e22 --- /dev/null +++ b/schema/team.go @@ -0,0 +1,65 @@ +package schema + +// TeamQuery defines filters passed to the active team provider. +// Providers decide how to apply these hints (server-side or client-side). +type TeamQuery struct { + // Name is a substring / fuzzy match filter for team names. + // Used for discovery (autocomplete, UIs, MCP listing, etc.). + Name string `json:"name,omitempty"` + + // Tags filters teams by key/value tag pairs. + // Providers may map these to labels, attributes, or custom metadata. + Tags map[string]string `json:"tags,omitempty"` + + // Scope provides shared service/team/environment hints. + // Providers can ignore fields they do not support. + Scope QueryScope `json:"scope,omitempty"` + + // Metadata carries provider-specific query hints. + Metadata map[string]any `json:"metadata,omitempty"` +} + +// Team represents a normalized team or group from the active team provider. +// OpsOrch does not store or mutate teams; providers own the source of truth. +type Team struct { + // ID is the canonical OpsOrch handle for this team. + // This is what QueryScope.Team should carry. + ID string `json:"id"` + + // Name is the human-readable display name of the team. + Name string `json:"name"` + + // Parent is the canonical ID of this team's parent, if any. + // Root-level teams leave this empty. Call the team provider's Get with this + // value if you need the parent details. + Parent string `json:"parent,omitempty"` + + // Tags contains normalized key/value tags for filtering and correlation. + Tags map[string]string `json:"tags,omitempty"` + + // Metadata stores provider-specific fields not covered by the normalized schema. + Metadata map[string]any `json:"metadata,omitempty"` +} + +// TeamMember is a lightweight, provider-backed representation of a team member. +// OpsOrch does not manage identities; this is a best-effort snapshot. +type TeamMember struct { + // ID is the canonical handle for this person within OpsOrch. + // Providers can map this to a user ID, email, or any stable upstream identifier. + ID string `json:"id"` + + // Name is the display name for this member. + Name string `json:"name,omitempty"` + + // Email is often the most stable handle for routing notifications, etc. + Email string `json:"email,omitempty"` + + // Handle can be a chat/username (Slack handle, GitHub login, etc.). + Handle string `json:"handle,omitempty"` + + // Role is a free-form normalized label like "owner", "manager", "member", "oncall". + Role string `json:"role,omitempty"` + + // Metadata is provider-specific: raw user object, links, extra attributes, etc. + Metadata map[string]any `json:"metadata,omitempty"` +} diff --git a/team/provider.go b/team/provider.go new file mode 100644 index 0000000..9033bdd --- /dev/null +++ b/team/provider.go @@ -0,0 +1,41 @@ +package team + +import ( + "context" + + "github.com/opsorch/opsorch-core/registry" + "github.com/opsorch/opsorch-core/schema" +) + +// Provider defines the capability surface a team adapter must satisfy. +type Provider interface { + // Query discovers teams by name/tags/scope. + Query(ctx context.Context, query schema.TeamQuery) ([]schema.Team, error) + + // Get returns a single team by its canonical ID. + Get(ctx context.Context, id string) (schema.Team, error) + + // Members returns a best-effort snapshot of members for a given team. + // Callers pass the canonical team ID (schema.Team.ID). + Members(ctx context.Context, teamID string) ([]schema.TeamMember, error) +} + +// ProviderConstructor builds a team provider from decrypted configuration. +type ProviderConstructor func(config map[string]any) (Provider, error) + +var providers = registry.New[ProviderConstructor]() + +// RegisterProvider adds a team provider constructor. +func RegisterProvider(name string, constructor ProviderConstructor) error { + return providers.Register(name, constructor) +} + +// LookupProvider returns a named provider constructor if registered. +func LookupProvider(name string) (ProviderConstructor, bool) { + return providers.Get(name) +} + +// Providers lists all registered team provider names. +func Providers() []string { + return providers.Names() +} diff --git a/team/provider_test.go b/team/provider_test.go new file mode 100644 index 0000000..6655993 --- /dev/null +++ b/team/provider_test.go @@ -0,0 +1,53 @@ +package team + +import ( + "context" + "testing" + + "github.com/opsorch/opsorch-core/schema" +) + +type stubTeamProvider struct{} + +func (stubTeamProvider) Query(ctx context.Context, query schema.TeamQuery) ([]schema.Team, error) { + return []schema.Team{{ID: "team1", Name: "Test Team"}}, nil +} + +func (stubTeamProvider) Get(ctx context.Context, id string) (schema.Team, error) { + return schema.Team{ID: id, Name: "Test Team"}, nil +} + +func (stubTeamProvider) Members(ctx context.Context, teamID string) ([]schema.TeamMember, error) { + return []schema.TeamMember{{ID: "user1", Name: "Test User", Email: "test@example.com"}}, nil +} + +func TestTeamRegisterLookup(t *testing.T) { + name := "test-team" + ctor := func(cfg map[string]any) (Provider, error) { return stubTeamProvider{}, nil } + if err := RegisterProvider(name, ctor); err != nil && err.Error() != "registry: provider test-team already registered" { + t.Fatalf("register: %v", err) + } + _, ok := LookupProvider(name) + if !ok { + t.Fatalf("expected provider lookup success") + } + names := Providers() + found := false + for _, n := range names { + if n == name { + found = true + } + } + if !found { + t.Fatalf("expected provider name in list: %v", names) + } +} + +func TestTeamDuplicateFails(t *testing.T) { + name := "dup-team" + ctor := func(cfg map[string]any) (Provider, error) { return stubTeamProvider{}, nil } + _ = RegisterProvider(name, ctor) + if err := RegisterProvider(name, ctor); err == nil { + t.Fatalf("expected duplicate registration to fail") + } +} \ No newline at end of file From f512b5a410d62a7cf31eda03d53a8ec09523b9f3 Mon Sep 17 00:00:00 2001 From: yusufaytas Date: Wed, 17 Dec 2025 22:29:03 +0300 Subject: [PATCH 2/2] Fixed lint errors. --- api/team_handler.go | 2 +- api/team_handler_test.go | 44 ++++++++++++++++++++-------------------- team/provider_test.go | 2 +- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/api/team_handler.go b/api/team_handler.go index cc48e84..e0914c0 100644 --- a/api/team_handler.go +++ b/api/team_handler.go @@ -84,4 +84,4 @@ func (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) bool { default: return false } -} \ No newline at end of file +} diff --git a/api/team_handler_test.go b/api/team_handler_test.go index cd88dc9..7c279b6 100644 --- a/api/team_handler_test.go +++ b/api/team_handler_test.go @@ -718,41 +718,41 @@ func TestTeamProperty_ErrorResponseConsistency(t *testing.T) { // **Feature: team-capability, Property: Startup resilience** func TestTeamProperty_StartupResilience(t *testing.T) { testCases := []struct { - name string - provider string - config string - plugin string + name string + provider string + config string + plugin string expectServerStartup bool - expectTeamEnabled bool + expectTeamEnabled bool }{ { - name: "valid team provider", - provider: "mock", - config: `{"test": "value"}`, + name: "valid team provider", + provider: "mock", + config: `{"test": "value"}`, expectServerStartup: true, - expectTeamEnabled: true, + expectTeamEnabled: true, }, { - name: "invalid team provider", - provider: "nonexistent", - config: `{"test": "value"}`, + name: "invalid team provider", + provider: "nonexistent", + config: `{"test": "value"}`, expectServerStartup: true, // Server should still start - expectTeamEnabled: false, // But team should be disabled + expectTeamEnabled: false, // But team should be disabled }, { - name: "no team provider", - provider: "", - config: "", - plugin: "", + name: "no team provider", + provider: "", + config: "", + plugin: "", expectServerStartup: true, - expectTeamEnabled: false, + expectTeamEnabled: false, }, { - name: "invalid JSON config", - provider: "mock", - config: `{invalid json}`, + name: "invalid JSON config", + provider: "mock", + config: `{invalid json}`, expectServerStartup: true, // Server should still start - expectTeamEnabled: false, // But team should be disabled + expectTeamEnabled: false, // But team should be disabled }, } diff --git a/team/provider_test.go b/team/provider_test.go index 6655993..b96bd72 100644 --- a/team/provider_test.go +++ b/team/provider_test.go @@ -50,4 +50,4 @@ func TestTeamDuplicateFails(t *testing.T) { if err := RegisterProvider(name, ctor); err == nil { t.Fatalf("expected duplicate registration to fail") } -} \ No newline at end of file +}