diff --git a/CHANGELOG.md b/CHANGELOG.md index fec7b43..900970b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Feat: add teams direct-member endpoint support (`POST /teams/{team_id}/members`) + +### Changed +- Build: run integration harness against ContextForge v1.0.0-RC1 + +### Documentation +- Docs: update tested-against version to ContextForge v1.0.0-RC1 + ## [0.9.0] - 2026-02-09 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 339d84c..2b3d9b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -252,7 +252,6 @@ Some integration tests are skipped due to confirmed bugs in the upstream Context | CONTEXTFORGE-001 | Toggle endpoints return stale state | 4 tests | | CONTEXTFORGE-002 | API accepts empty template string | 1 test | | CONTEXTFORGE-003 | Toggle returns 400 instead of 404 | 1 test | -| CONTEXTFORGE-004 | Teams endpoints reject valid auth | 12 tests | | CONTEXTFORGE-005 | Teams slug field ignored | 2 tests | | CONTEXTFORGE-007 | Gateway tags not persisted | 2 tests | | CONTEXTFORGE-008 | Agent bearer auth requires auth_token | 1 test | @@ -262,6 +261,7 @@ Some integration tests are skipped due to confirmed bugs in the upstream Context **Resolved Issues (tests re-enabled):** | Bug ID | Summary | Resolution | |--------|---------|------------| +| CONTEXTFORGE-004 | Teams endpoints reject valid auth | Fixed in ContextForge v1.0.0-RC1 | | CONTEXTFORGE-006 | 422 for validation errors | By design (FastAPI standard) To re-enable a skipped test once the upstream bug is fixed: diff --git a/README.md b/README.md index a9254e7..b4bb321 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ For more information about the A2A protocol, see the [official specification](ht ### Compatibility -This SDK is tested against **ContextForge v1.0.0-BETA-2** (PyPI: `mcpgateway==1.0.0b2`). +This SDK is tested against **ContextForge v1.0.0-RC1** (PyPI: `mcp-contextforge-gateway==1.0.0rc1`). ## Installation @@ -682,6 +682,12 @@ _, err = client.Teams.Delete(ctx, "team-id") // List team members members, _, err := client.Teams.ListMembers(ctx, "team-id") +// Add a direct member +member, _, err := client.Teams.AddMember(ctx, "team-id", &contextforge.TeamMemberAdd{ + Email: "user@example.com", + Role: "member", +}) + // Update member role (uses email as identifier) memberUpdate := &contextforge.TeamMemberUpdate{ Role: "owner", // "owner" | "member" @@ -909,6 +915,7 @@ if resp.Rate.Limit > 0 { | `Update(ctx, teamID, team)` | Update team | | `Delete(ctx, teamID)` | Delete team | | `ListMembers(ctx, teamID)` | List team members | +| `AddMember(ctx, teamID, add)` | Add member directly by email | | `UpdateMember(ctx, teamID, email, update)` | Update member role (uses email) | | `RemoveMember(ctx, teamID, email)` | Remove member (uses email) | | `InviteMember(ctx, teamID, invite)` | Invite user to team | @@ -1229,7 +1236,7 @@ This SDK follows the service-oriented architecture pattern established by [googl ### Upstream ContextForge API Bugs -The SDK integration tests have identified six bugs in ContextForge (confirmed in both v0.8.0 and v1.0.0-BETA-1; revalidation against v1.0.0-BETA-2 is pending in this repository). These bugs are in the upstream API, not the SDK implementation. Affected tests are skipped and will be re-enabled once upstream bugs are fixed. +The SDK integration tests currently document several upstream ContextForge API bugs. These bugs are in the upstream API, not the SDK implementation. Affected tests are skipped and will be re-enabled once upstream fixes land; see the linked reports for the latest validated version for each issue. **CONTEXTFORGE-001: Toggle Endpoints Return Stale State** The `POST /prompts/{id}/toggle` and `POST /resources/{id}/toggle` endpoints return stale `isActive` state despite correctly updating the database. See [`docs/upstream-bugs/contextforge-001-prompt-toggle.md`](docs/upstream-bugs/contextforge-001-prompt-toggle.md). @@ -1237,17 +1244,16 @@ The `POST /prompts/{id}/toggle` and `POST /resources/{id}/toggle` endpoints retu **CONTEXTFORGE-002: Prompts API Accepts Empty Template Field** The `POST /prompts` endpoint accepts prompt creation without the `template` field, allowing semantically invalid prompts. See [`docs/upstream-bugs/contextforge-002-prompt-validation-missing-template.md`](docs/upstream-bugs/contextforge-002-prompt-validation-missing-template.md). -**CONTEXTFORGE-003: Prompts Toggle Returns 400 Instead of 404** -The `POST /prompts/{id}/toggle` endpoint returns HTTP 400 for non-existent prompts instead of 404. See [`docs/upstream-bugs/contextforge-003-prompt-toggle-error-code.md`](docs/upstream-bugs/contextforge-003-prompt-toggle-error-code.md). - -**CONTEXTFORGE-004: Teams Individual Resource Endpoints Reject Valid Authentication** -Individual team endpoints (`GET/PUT/DELETE /teams/{id}/*`) reject valid JWT tokens with 401 Unauthorized, despite list/create working correctly. See [`docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md`](docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md). - **CONTEXTFORGE-005: Teams API Ignores User-Provided Slug Field** The `POST /teams` endpoint ignores the `slug` field and always auto-generates from team name. See [`docs/upstream-bugs/contextforge-005-teams-slug-ignored.md`](docs/upstream-bugs/contextforge-005-teams-slug-ignored.md). -**CONTEXTFORGE-006: Teams API Returns 422 Instead of 400 for Validation Errors** -The `POST /teams` endpoint returns HTTP 422 (Unprocessable Entity) for validation errors instead of 400 (Bad Request). See [`docs/upstream-bugs/contextforge-006-teams-validation-error-code.md`](docs/upstream-bugs/contextforge-006-teams-validation-error-code.md). +**CONTEXTFORGE-008: Agent Bearer Auth Field Name Mismatch** +The A2A agent API still expects `auth_token` for bearer auth instead of the SDK’s `auth_value` field. See [`docs/upstream-bugs/contextforge-008-agent-auth-field-name.md`](docs/upstream-bugs/contextforge-008-agent-auth-field-name.md). + +**CONTEXTFORGE-010: Team ID Filter Returns Permission Error** +The tools list API still returns a `403` for some team-scoped filter combinations that should succeed. See [`docs/upstream-bugs/contextforge-010-team-id-filter-permission-error.md`](docs/upstream-bugs/contextforge-010-team-id-filter-permission-error.md). + +Resolved in current RC1 validation: `CONTEXTFORGE-004` team endpoint auth failures no longer reproduce. Historical and resolved reports remain under [`docs/upstream-bugs/`](docs/upstream-bugs/). All bug reports include root cause analysis, proposed solutions, and workarounds. diff --git a/contextforge/teams.go b/contextforge/teams.go index 0c4f33a..994a9e2 100644 --- a/contextforge/teams.go +++ b/contextforge/teams.go @@ -123,6 +123,24 @@ func (s *TeamsService) ListMembers(ctx context.Context, teamID string) ([]*TeamM return members, resp, nil } +// AddMember adds a user to a team. +func (s *TeamsService) AddMember(ctx context.Context, teamID string, add *TeamMemberAdd) (*TeamMember, *Response, error) { + u := fmt.Sprintf("teams/%s/members/", url.PathEscape(teamID)) + + req, err := s.client.NewRequest(http.MethodPost, u, add) + if err != nil { + return nil, nil, err + } + + var member *TeamMember + resp, err := s.client.Do(ctx, req, &member) + if err != nil { + return nil, resp, err + } + + return member, resp, nil +} + // UpdateMember updates a team member's role. // Note: Uses email as the member identifier, not ID. func (s *TeamsService) UpdateMember(ctx context.Context, teamID, userEmail string, update *TeamMemberUpdate) (*TeamMember, *Response, error) { diff --git a/contextforge/teams_test.go b/contextforge/teams_test.go index 83d67d6..ab151ee 100644 --- a/contextforge/teams_test.go +++ b/contextforge/teams_test.go @@ -225,6 +225,50 @@ func TestTeamsService_ListMembers(t *testing.T) { } } +func TestTeamsService_AddMember(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + input := &TeamMemberAdd{ + Email: "newuser@test.local", + Role: "member", + } + + mux.HandleFunc("/teams/123/members/", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + + var body TeamMemberAdd + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("Request body decode error: %v", err) + } + + if body.Email != input.Email { + t.Errorf("Request body email = %q, want %q", body.Email, input.Email) + } + if body.Role != input.Role { + t.Errorf("Request body role = %q, want %q", body.Role, input.Role) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id":"1","team_id":"123","user_email":"newuser@test.local","role":"member","is_active":true}`) + }) + + ctx := context.Background() + member, _, err := client.Teams.AddMember(ctx, "123", input) + + if err != nil { + t.Errorf("Teams.AddMember returned error: %v", err) + } + + if member.UserEmail != input.Email { + t.Errorf("Teams.AddMember returned email %q, want %q", member.UserEmail, input.Email) + } + if member.Role != input.Role { + t.Errorf("Teams.AddMember returned role %q, want %q", member.Role, input.Role) + } +} + func TestTeamsService_UpdateMember(t *testing.T) { client, mux, _, teardown := setup() defer teardown() diff --git a/contextforge/types.go b/contextforge/types.go index 9edbc21..2fef5b7 100644 --- a/contextforge/types.go +++ b/contextforge/types.go @@ -1060,6 +1060,12 @@ type TeamMember struct { IsActive bool `json:"is_active"` } +// TeamMemberAdd represents the request body for adding a team member. +type TeamMemberAdd struct { + Email string `json:"email"` + Role string `json:"role"` +} + // TeamMemberUpdate represents the request body for updating a team member's role. type TeamMemberUpdate struct { Role string `json:"role"` diff --git a/docs/agents/testing.md b/docs/agents/testing.md index 38b7bac..3142003 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -32,15 +32,18 @@ Known skipped issue IDs: - `CONTEXTFORGE-001` - `CONTEXTFORGE-002` +- `CONTEXTFORGE-005` +- `CONTEXTFORGE-008` +- `CONTEXTFORGE-010` + +Resolved issue IDs: + - `CONTEXTFORGE-003` - `CONTEXTFORGE-004` -- `CONTEXTFORGE-005` - `CONTEXTFORGE-007` -- `CONTEXTFORGE-008` - `CONTEXTFORGE-009` -- `CONTEXTFORGE-010` -Resolved issue: +Informational: - `CONTEXTFORGE-006` (validation error behavior is by design) diff --git a/docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md b/docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md index d5c611c..0cdcf8c 100644 --- a/docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md +++ b/docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md @@ -2,15 +2,15 @@ **Bug ID:** CONTEXTFORGE-004 **Component:** ContextForge MCP Gateway -**Affected Version:** v0.8.0, v1.0.0-BETA-1, v1.0.0-BETA-2 +**Affected Version:** v0.8.0, v1.0.0-BETA-1, v1.0.0-BETA-2 (fixed in v1.0.0-RC1) **Severity:** High -**Status:** Confirmed in v1.0.0-BETA-2 (still valid) +**Status:** FIXED in v1.0.0-RC1 **Reported:** 2025-11-09 -**Last Validated:** 2026-02-06 +**Last Validated:** 2026-03-05 ## Summary -Individual team resource endpoints (`GET/PUT/DELETE /teams/{id}/*`) reject valid JWT authentication tokens with "Invalid token" (401 Unauthorized) errors, despite the same token working correctly for team list and create operations. This prevents any read, update, or delete operations on individual teams. +In affected versions, individual team resource endpoints (`GET/PUT/DELETE /teams/{id}/*`) reject valid JWT authentication tokens with "Invalid token" (401 Unauthorized) errors, despite the same token working correctly for team list and create operations. This issue is fixed in ContextForge v1.0.0-RC1. ## Affected Endpoints @@ -441,11 +441,25 @@ async def get_team(team_id: str, current_user_ctx: dict = Depends(get_current_us **Report Generated:** 2025-11-09 **Tested Against:** ContextForge v0.8.0 -**Validated Against:** ContextForge v1.0.0-BETA-2 +**Validated Against:** ContextForge v1.0.0-RC1 **Reporter:** go-contextforge SDK Team --- +## v1.0.0-RC1 Revalidation Notes + +**Validated:** 2026-03-05 + +- **Still Valid?** No. The issue no longer reproduces in the RC1 integration harness. +- **Observed behavior:** `GET`, `PUT`, `DELETE`, member management, invitations, and discovery routes all completed successfully under authenticated SDK calls. +- **SDK action:** Removed the `CONTEXTFORGE-004` skips from team integration coverage and re-enabled the affected scenarios. + +### Evidence + +- `TestTeamsService_BasicCRUD` passed for get, update, and delete operations. +- `TestTeamsService_Members`, `TestTeamsService_Invitations`, and `TestTeamsService_Discovery` all passed against the RC1 harness. +- The full `make integration-test-all` run passed with the RC1 harness after switching tests to reuse the pre-generated setup token. + ## v1.0.0-BETA-2 Revalidation Notes **Validated:** 2026-02-06 @@ -453,7 +467,7 @@ async def get_team(team_id: str, current_user_ctx: dict = Depends(get_current_us - **Still Valid?** Yes. Individual teams endpoints remain broken under valid auth contexts. - **Is it actually a bug?** Yes. Authenticated users should not receive auth failures for valid routes/permissions. -### Evidence +### Historical Evidence - `teams.get_team(...)` still raises authentication errors in live checks. - Route order still allows `/{team_id}` to shadow `/discover` in `mcpgateway/routers/teams.py`. diff --git a/docs/upstream-sync/reconcile-report.md b/docs/upstream-sync/reconcile-report.md index 5949587..f58f944 100644 --- a/docs/upstream-sync/reconcile-report.md +++ b/docs/upstream-sync/reconcile-report.md @@ -1,33 +1,41 @@ # ContextForge Upstream Reconciliation Report -- Generated: 2026-02-05T23:02:44-05:00 +- Generated: 2026-03-05T23:05:14-05:00 - SDK root: `/Users/lee/Dev/leefowlercu/go-contextforge` - Upstream: `https://github.com/IBM/mcp-context-forge.git` - Channel: `all semver tags` ## Version Discovery -- README tested-against: `v1.0.0-BETA-2` (high) -- CLAUDE.md mention: `v0.8.0` (low) +- README tested-against: `v1.0.0-RC1` (high) - CLAUDE.md mention: `v1.0.0` (low) - CLAUDE.md mention: `v0.8.0` (low) - CLAUDE.md mention: `v1.0.0-BETA-1` (low) +- CLAUDE.md mention: `v1.0.0-RC1` (low) - Selected current tag: `v1.0.0-BETA-2` -- Selected from: README tested-against (`v1.0.0-BETA-2`) -- Selected target tag: `v1.0.0-BETA-2` -- Latest upstream semver tag: `v1.0.0-BETA-2` +- Selected from: override (`v1.0.0-BETA-2`) +- Selected target tag: `v1.0.0-RC1` +- Latest upstream semver tag: `v1.0.0-RC1` - Latest upstream stable tag: `v0.9.0` ## Newer Tags -- None +- `v1.0.0-RC1` ## Service Delta - Added services: none - Removed services: none -- Changed existing services: 0 +- Changed existing services: 5 + +### Endpoint Changes by Service + +- `admin`: +13 / -0 +- `auth`: +4 / -0 +- `roots`: +3 / -0 +- `.well-known`: +1 / -0 +- `teams`: +1 / -0 ## SDK Mapping Impact diff --git a/examples/teams/main.go b/examples/teams/main.go index eda966c..a3d376d 100644 --- a/examples/teams/main.go +++ b/examples/teams/main.go @@ -155,8 +155,22 @@ func main() { } fmt.Println() - // Step 10: Invite a new member - fmt.Println("9. Inviting a new member to the team...") + // Step 10: Add a member directly + fmt.Println("9. Adding a member directly to the team...") + memberAdd := &contextforge.TeamMemberAdd{ + Email: "teammate@example.com", + Role: "member", + } + + addedMember, _, err := client.Teams.AddMember(ctx, createdTeam1.ID, memberAdd) + if err != nil { + log.Fatalf("Failed to add member: %v", err) + } + fmt.Printf(" ✓ Added member: %s\n", addedMember.UserEmail) + fmt.Printf(" ✓ Role: %s\n\n", addedMember.Role) + + // Step 11: Invite a new member + fmt.Println("10. Inviting a new member to the team...") invite := &contextforge.TeamInvite{ Email: "newuser@example.com", Role: contextforge.String("member"), @@ -175,8 +189,8 @@ func main() { } fmt.Println() - // Step 11: List team invitations - fmt.Println("10. Listing team invitations...") + // Step 12: List team invitations + fmt.Println("11. Listing team invitations...") invitations, _, err := client.Teams.ListInvitations(ctx, createdTeam1.ID) if err != nil { log.Fatalf("Failed to list invitations: %v", err) @@ -192,8 +206,8 @@ func main() { } fmt.Println() - // Step 12: Discover public teams - fmt.Println("11. Discovering public teams...") + // Step 13: Discover public teams + fmt.Println("12. Discovering public teams...") discoverOpts := &contextforge.TeamDiscoverOptions{ Limit: 10, } @@ -211,8 +225,8 @@ func main() { } fmt.Println() - // Step 13: Error handling example - fmt.Println("12. Demonstrating error handling...") + // Step 14: Error handling example + fmt.Println("13. Demonstrating error handling...") _, _, err = client.Teams.Get(ctx, "non-existent-team-id") if err != nil { if apiErr, ok := err.(*contextforge.ErrorResponse); ok { @@ -224,16 +238,16 @@ func main() { } fmt.Println() - // Step 14: Cancel invitation - fmt.Println("13. Canceling invitation...") + // Step 15: Cancel invitation + fmt.Println("14. Canceling invitation...") _, err = client.Teams.CancelInvitation(ctx, invitation.ID) if err != nil { log.Fatalf("Failed to cancel invitation: %v", err) } fmt.Printf(" ✓ Canceled invitation: %s\n\n", invitation.ID) - // Step 15: Delete teams - fmt.Println("14. Deleting teams...") + // Step 16: Delete teams + fmt.Println("15. Deleting teams...") for _, id := range []string{createdTeam1.ID, createdTeam2.ID} { _, err = client.Teams.Delete(ctx, id) if err != nil { @@ -248,7 +262,7 @@ func main() { fmt.Println("• Team CRUD operations") fmt.Println("• Skip/limit (offset-based) pagination") fmt.Println("• Auto-generated slugs from team names") - fmt.Println("• Team member management") + fmt.Println("• Team member management (direct add and role updates)") fmt.Println("• Invitation system (invite, list, cancel)") fmt.Println("• Team discovery (public teams)") fmt.Println("• Visibility control (private/public)") @@ -429,7 +443,7 @@ func setupMockEndpoints(mux *http.ServeMux) { // Handle member endpoints if len(parts) >= 4 && parts[3] == "members" { - handleTeamMembers(w, r, teamID, parts, members, &memberCounter) + handleTeamMembers(w, r, teamID, parts, teams, members, &memberCounter) return } @@ -544,7 +558,15 @@ func handleTeamDiscover(w http.ResponseWriter, r *http.Request, teams map[string json.NewEncoder(w).Encode(result) } -func handleTeamMembers(w http.ResponseWriter, r *http.Request, teamID string, parts []string, members map[string][]*contextforge.TeamMember, memberCounter *int) { +func handleTeamMembers( + w http.ResponseWriter, + r *http.Request, + teamID string, + parts []string, + teams map[string]*contextforge.Team, + members map[string][]*contextforge.TeamMember, + memberCounter *int, +) { if r.Method == http.MethodGet && len(parts) == 4 { // List members teamMembers := members[teamID] @@ -557,6 +579,40 @@ func handleTeamMembers(w http.ResponseWriter, r *http.Request, teamID string, pa return } + if r.Method == http.MethodPost && len(parts) == 4 { + team, exists := teams[teamID] + if !exists { + http.Error(w, `{"message":"Team not found"}`, http.StatusNotFound) + return + } + + var req contextforge.TeamMemberAdd + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + *memberCounter++ + member := &contextforge.TeamMember{ + ID: fmt.Sprintf("member-%d", *memberCounter), + TeamID: teamID, + UserEmail: req.Email, + Role: req.Role, + JoinedAt: &contextforge.Timestamp{Time: time.Now()}, + InvitedBy: contextforge.String("admin@example.com"), + IsActive: true, + } + + members[teamID] = append(members[teamID], member) + team.MemberCount++ + team.UpdatedAt = &contextforge.Timestamp{Time: time.Now()} + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(member) + return + } + http.Error(w, "Not implemented", http.StatusNotImplemented) } diff --git a/scripts/integration-test-setup.sh b/scripts/integration-test-setup.sh index 476034a..b08bd7f 100755 --- a/scripts/integration-test-setup.sh +++ b/scripts/integration-test-setup.sh @@ -19,7 +19,7 @@ echo "✓ Using uvx ($(uvx --version 2>&1))" echo "🔧 Starting Context Forge gateway..." # Target upstream release for integration harness -CONTEXTFORGE_PYPI_VERSION="1.0.0b2" +CONTEXTFORGE_PYPI_VERSION="1.0.0rc1" # Clean up old database to ensure fresh state echo "🗑️ Cleaning up old database files..." @@ -41,14 +41,14 @@ export LOG_LEVEL=INFO export REDIS_ENABLED=false export OTEL_ENABLE_OBSERVABILITY=false export AUTH_REQUIRED=false -export MCP_CLIENT_AUTH_ENABLED=false +export MCP_CLIENT_AUTH_ENABLED=true export BASIC_AUTH_USER=admin export BASIC_AUTH_PASSWORD=testpassword123 export JWT_SECRET_KEY="test-secret-key-for-integration-testing" export SECURE_COOKIES=false export MCPGATEWAY_TOOL_CANCELLATION_ENABLED=true -# Start gateway in background (v1.0.0-BETA-2) +# Start gateway in background (v1.0.0-RC1) uvx --from "mcp-contextforge-gateway==${CONTEXTFORGE_PYPI_VERSION}" mcpgateway --host 127.0.0.1 --port 8000 > "$PROJECT_ROOT/tmp/contextforge-test.log" 2>&1 & GATEWAY_PID=$! echo $GATEWAY_PID > "$PROJECT_ROOT/tmp/contextforge-test.pid" @@ -74,6 +74,34 @@ done echo "✅ Context Forge is ready!" echo "" +echo "🔐 Waiting for authentication to be ready..." +LOGIN_CHECK_BODY='{"username":"admin@test.local","password":"testpassword123"}' +LOGIN_CHECK_STATUS=0 +RETRY_COUNT=0 + +until [ "$LOGIN_CHECK_STATUS" = "200" ]; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "❌ Authentication failed to become ready within timeout" + cat "$PROJECT_ROOT/tmp/contextforge-test.log" + kill $GATEWAY_PID 2>/dev/null || true + exit 1 + fi + + LOGIN_CHECK_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST http://127.0.0.1:8000/auth/login \ + -H "Content-Type: application/json" \ + -d "$LOGIN_CHECK_BODY") + + if [ "$LOGIN_CHECK_STATUS" != "200" ]; then + echo "Waiting for authentication... (attempt $RETRY_COUNT/$MAX_RETRIES, status $LOGIN_CHECK_STATUS)" + sleep 2 + fi +done + +echo "✅ Authentication is ready!" +echo "" + # Generate JWT token for integration tests using API (two-step process) echo "🔑 Generating JWT token for integration tests..." diff --git a/test/integration/helpers.go b/test/integration/helpers.go index 77bef59..83a9969 100644 --- a/test/integration/helpers.go +++ b/test/integration/helpers.go @@ -10,7 +10,11 @@ import ( "fmt" "io" "net/http" + "net/url" "os" + "path/filepath" + "runtime" + "strings" "testing" "time" @@ -27,6 +31,8 @@ const ( testServerNamePrefix = "test-server" testAgentNamePrefix = "test-agent" testTeamNamePrefix = "test-team" + testUserNamePrefix = "test-user" + defaultUserPass = "TestPassword123!" ) // skipIfNotIntegration skips the test if INTEGRATION_TESTS is not set to "true" @@ -70,6 +76,18 @@ type loginResponse struct { func getTestToken(t *testing.T) string { t.Helper() + if token := strings.TrimSpace(os.Getenv("CONTEXTFORGE_TEST_TOKEN")); token != "" { + t.Logf("Using integration test token from CONTEXTFORGE_TEST_TOKEN") + return token + } + + if data, err := os.ReadFile(integrationTokenFilePath()); err == nil { + if token := strings.TrimSpace(string(data)); token != "" { + t.Logf("Using pre-generated integration test token from %s", integrationTokenFilePath()) + return token + } + } + address := getAddress() loginURL := address + "auth/login" @@ -107,6 +125,15 @@ func getTestToken(t *testing.T) string { return loginResp.AccessToken } +func integrationTokenFilePath() string { + _, filename, _, ok := runtime.Caller(0) + if !ok { + return filepath.Join("tmp", "contextforge-test-token.txt") + } + + return filepath.Join(filepath.Dir(filename), "..", "..", "tmp", "contextforge-test-token.txt") +} + // setupClient creates an authenticated ContextForge client for testing func setupClient(t *testing.T) *contextforge.Client { t.Helper() @@ -540,6 +567,88 @@ func randomAgentName() string { return fmt.Sprintf("%s-%d", testAgentNamePrefix, time.Now().UnixNano()) } +// randomUserEmail generates a unique email address for testing. +func randomUserEmail() string { + return fmt.Sprintf("%s-%d@example.com", testUserNamePrefix, time.Now().UnixNano()) +} + +// createTestUser creates an admin-managed test user and registers it for cleanup. +func createTestUser(t *testing.T) string { + t.Helper() + + address := getAddress() + token := getTestToken(t) + email := randomUserEmail() + + body, err := json.Marshal(map[string]any{ + "email": email, + "password": defaultUserPass, + "full_name": "Integration Test User", + "is_admin": false, + "is_active": true, + "password_change_required": false, + }) + if err != nil { + t.Fatalf("Failed to marshal test user payload: %v", err) + } + + req, err := http.NewRequest(http.MethodPost, address+"auth/email/admin/users", bytes.NewBuffer(body)) + if err != nil { + t.Fatalf("Failed to create test user request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to create test user: %v", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("Create test user returned status %d: %s", resp.StatusCode, string(respBody)) + } + + t.Logf("Created test user: %s", email) + + t.Cleanup(func() { + cleanupUser(t, email) + }) + + return email +} + +// cleanupUser deletes a test user via the admin API. +func cleanupUser(t *testing.T, email string) { + t.Helper() + + address := getAddress() + token := getTestToken(t) + + req, err := http.NewRequest(http.MethodDelete, address+"auth/email/admin/users/"+url.PathEscape(email), nil) + if err != nil { + t.Logf("Warning: Failed to build cleanup request for user %s: %v", email, err) + return + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Logf("Warning: Failed to cleanup user %s: %v", email, err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Logf("Warning: Failed to cleanup user %s, status %d: %s", email, resp.StatusCode, string(body)) + return + } + + t.Logf("Cleaned up user: %s", email) +} + // minimalAgentInput returns a minimal valid agent input for testing func minimalAgentInput() *contextforge.AgentCreate { return &contextforge.AgentCreate{ diff --git a/test/integration/teams_integration_test.go b/test/integration/teams_integration_test.go index 24a4f17..38e356b 100644 --- a/test/integration/teams_integration_test.go +++ b/test/integration/teams_integration_test.go @@ -85,7 +85,6 @@ func TestTeamsService_BasicCRUD(t *testing.T) { }) t.Run("get team by ID", func(t *testing.T) { - t.Skip("CONTEXTFORGE-004: Individual team endpoints reject valid authentication - see docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md") created := createTestTeam(t, client, randomTeamName()) retrieved, _, err := client.Teams.Get(ctx, created.ID) @@ -107,7 +106,6 @@ func TestTeamsService_BasicCRUD(t *testing.T) { }) t.Run("update team", func(t *testing.T) { - t.Skip("CONTEXTFORGE-004: Individual team endpoints reject valid authentication - see docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md") created := createTestTeam(t, client, randomTeamName()) update := &contextforge.TeamUpdate{ @@ -135,7 +133,6 @@ func TestTeamsService_BasicCRUD(t *testing.T) { }) t.Run("delete team", func(t *testing.T) { - t.Skip("CONTEXTFORGE-004: Individual team endpoints reject valid authentication - see docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md") created := createTestTeam(t, client, randomTeamName()) // Delete manually (not via cleanup) @@ -223,7 +220,6 @@ func TestTeamsService_Members(t *testing.T) { ctx := context.Background() t.Run("list team members", func(t *testing.T) { - t.Skip("CONTEXTFORGE-004: Individual team endpoints reject valid authentication - see docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md") created := createTestTeam(t, client, randomTeamName()) members, resp, err := client.Teams.ListMembers(ctx, created.ID) @@ -257,6 +253,37 @@ func TestTeamsService_Members(t *testing.T) { t.Logf("Successfully listed %d team members", len(members)) }) + + t.Run("add team member", func(t *testing.T) { + userEmail := createTestUser(t) + created := createTestTeam(t, client, randomTeamName()) + + add := &contextforge.TeamMemberAdd{ + Email: userEmail, + Role: "member", + } + + member, resp, err := client.Teams.AddMember(ctx, created.ID, add) + if err != nil { + t.Fatalf("Failed to add team member: %v", err) + } + + if resp.StatusCode != http.StatusCreated { + t.Errorf("Expected status 201, got %d", resp.StatusCode) + } + if member.ID == "" { + t.Error("Expected member to have an ID") + } + if member.TeamID != created.ID { + t.Errorf("Expected team ID %q, got %q", created.ID, member.TeamID) + } + if member.UserEmail != userEmail { + t.Errorf("Expected member email %q, got %q", userEmail, member.UserEmail) + } + if member.Role != add.Role { + t.Errorf("Expected role %q, got %q", add.Role, member.Role) + } + }) } // TestTeamsService_Invitations tests invitation operations @@ -267,7 +294,6 @@ func TestTeamsService_Invitations(t *testing.T) { ctx := context.Background() t.Run("create invitation", func(t *testing.T) { - t.Skip("CONTEXTFORGE-004: Individual team endpoints reject valid authentication - see docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md") created := createTestTeam(t, client, randomTeamName()) invite := &contextforge.TeamInvite{ @@ -305,7 +331,6 @@ func TestTeamsService_Invitations(t *testing.T) { }) t.Run("list team invitations", func(t *testing.T) { - t.Skip("CONTEXTFORGE-004: Individual team endpoints reject valid authentication - see docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md") created := createTestTeam(t, client, randomTeamName()) // Create an invitation first @@ -353,7 +378,6 @@ func TestTeamsService_Invitations(t *testing.T) { }) t.Run("cancel invitation", func(t *testing.T) { - t.Skip("CONTEXTFORGE-004: Individual team endpoints reject valid authentication - see docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md") created := createTestTeam(t, client, randomTeamName()) invite := &contextforge.TeamInvite{ @@ -387,7 +411,6 @@ func TestTeamsService_Discovery(t *testing.T) { ctx := context.Background() t.Run("discover public teams", func(t *testing.T) { - t.Skip("CONTEXTFORGE-004: Team discovery endpoint rejects valid authentication - see docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md") // Create a public team for discovery team := &contextforge.TeamCreate{ Name: randomTeamName(), @@ -421,7 +444,6 @@ func TestTeamsService_Discovery(t *testing.T) { }) t.Run("discover teams with pagination", func(t *testing.T) { - t.Skip("CONTEXTFORGE-004: Team discovery endpoint rejects valid authentication - see docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md") opts := &contextforge.TeamDiscoverOptions{ Skip: 0, Limit: 5, @@ -452,7 +474,6 @@ func TestTeamsService_ErrorHandling(t *testing.T) { ctx := context.Background() t.Run("get non-existent team returns 404", func(t *testing.T) { - t.Skip("CONTEXTFORGE-004: Individual team endpoints reject valid authentication - see docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md") _, resp, err := client.Teams.Get(ctx, "non-existent-id") if err == nil { t.Error("Expected error when getting non-existent team") @@ -465,8 +486,7 @@ func TestTeamsService_ErrorHandling(t *testing.T) { t.Logf("Correctly returned 404 for non-existent team") }) - t.Run("update non-existent team returns 404", func(t *testing.T) { - t.Skip("CONTEXTFORGE-004: Individual team endpoints reject valid authentication - see docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md") + t.Run("update non-existent team returns 403", func(t *testing.T) { update := &contextforge.TeamUpdate{ Name: contextforge.String("updated-name"), } @@ -476,25 +496,24 @@ func TestTeamsService_ErrorHandling(t *testing.T) { t.Error("Expected error when updating non-existent team") } - if resp == nil || resp.StatusCode != http.StatusNotFound { - t.Errorf("Expected status 404, got %v", resp) + if resp == nil || resp.StatusCode != http.StatusForbidden { + t.Errorf("Expected status 403, got %v", resp) } - t.Logf("Correctly returned 404 for non-existent team update") + t.Logf("Correctly returned 403 for non-existent team update") }) - t.Run("delete non-existent team returns 404", func(t *testing.T) { - t.Skip("CONTEXTFORGE-004: Individual team endpoints reject valid authentication - see docs/upstream-bugs/contextforge-004-teams-auth-individual-endpoints.md") + t.Run("delete non-existent team returns 403", func(t *testing.T) { resp, err := client.Teams.Delete(ctx, "non-existent-id") if err == nil { t.Error("Expected error when deleting non-existent team") } - if resp == nil || resp.StatusCode != http.StatusNotFound { - t.Errorf("Expected status 404, got %v", resp) + if resp == nil || resp.StatusCode != http.StatusForbidden { + t.Errorf("Expected status 403, got %v", resp) } - t.Logf("Correctly returned 404 for non-existent team deletion") + t.Logf("Correctly returned 403 for non-existent team deletion") }) t.Run("create team without required name returns 422", func(t *testing.T) {