From a0d41e8f9d25cabe528e5c5e8882e63e1e6ebd13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:51:22 +0000 Subject: [PATCH 1/5] Initial plan From e2efe093fbb4490aff5bf501bfc921bd59def1bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:56:24 +0000 Subject: [PATCH 2/5] Add transfer ownership endpoint for projects Co-authored-by: awagner-mainz <2088443+awagner-mainz@users.noreply.github.com> --- internal/database/queries.sql.go | 28 ++++++ internal/database/queries/queries.sql | 8 ++ internal/handlers/projects.go | 132 ++++++++++++++++++++++++++ internal/models/projects.go | 21 ++++ 4 files changed, 189 insertions(+) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 7c5088d..2c1e3d7 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -2172,6 +2172,34 @@ func (q *Queries) RetrieveUser(ctx context.Context, userHandle string) (User, er return i, err } +const transferProjectOwnership = `-- name: TransferProjectOwnership :one +UPDATE projects +SET "owner" = $2, + "updated_at" = NOW() +WHERE "owner" = $1 +AND "project_handle" = $3 +RETURNING "project_id", "owner", "project_handle" +` + +type TransferProjectOwnershipParams struct { + Owner string `db:"owner" json:"owner"` + Owner_2 string `db:"owner_2" json:"owner_2"` + ProjectHandle string `db:"project_handle" json:"project_handle"` +} + +type TransferProjectOwnershipRow struct { + ProjectID int32 `db:"project_id" json:"project_id"` + Owner string `db:"owner" json:"owner"` + ProjectHandle string `db:"project_handle" json:"project_handle"` +} + +func (q *Queries) TransferProjectOwnership(ctx context.Context, arg TransferProjectOwnershipParams) (TransferProjectOwnershipRow, error) { + row := q.db.QueryRow(ctx, transferProjectOwnership, arg.Owner, arg.Owner_2, arg.ProjectHandle) + var i TransferProjectOwnershipRow + err := row.Scan(&i.ProjectID, &i.Owner, &i.ProjectHandle) + return i, err +} + const unlinkDefinition = `-- name: UnlinkDefinition :exec DELETE FROM definitions_shared_with diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index c2f7fc4..6cc7e8b 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -204,6 +204,14 @@ FROM users_projects WHERE "user_handle" = $1 AND "project_id" = $2; +-- name: TransferProjectOwnership :one +UPDATE projects +SET "owner" = $2, + "updated_at" = NOW() +WHERE "owner" = $1 +AND "project_handle" = $3 +RETURNING "project_id", "owner", "project_handle"; + -- === LLM Service Definitions (user-shared templates) === diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index 87ba121..5ae40da 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -570,6 +570,125 @@ func getProjectSharedUsersFunc(ctx context.Context, input *models.GetProjectShar return response, nil } +// Transfer project ownership to another user +func transferProjectOwnershipFunc(ctx context.Context, input *models.TransferProjectOwnershipRequest) (*models.TransferProjectOwnershipResponse, error) { + // Get the database connection pool from the context + pool, err := GetDBPool(ctx) + if err != nil { + return nil, huma.Error500InternalServerError("database connection error: %v", err) + } + queries := database.New(pool) + + // Get the requesting user from context (set by auth middleware) + requestingUser := ctx.Value(auth.AuthUserKey) + if requestingUser == nil { + return nil, huma.Error500InternalServerError("unable to get requesting user from context") + } + + // Validate that new owner is different from current owner + if input.Body.NewOwnerHandle == input.UserHandle { + return nil, huma.Error400BadRequest("new owner must be different from current owner") + } + + // Check if project exists and belongs to current user (only owner can transfer) + project, err := queries.RetrieveProject(ctx, database.RetrieveProjectParams{ + Owner: input.UserHandle, + ProjectHandle: input.ProjectHandle, + }) + if err != nil { + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("project %s/%s not found", input.UserHandle, input.ProjectHandle)) + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve project %s/%s: %v", input.UserHandle, input.ProjectHandle, err)) + } + + // Check if project belongs to requesting user (only owner can transfer) + if project.Owner != requestingUser.(string) { + return nil, huma.Error403Forbidden(fmt.Sprintf("only the project owner can transfer ownership of project %s/%s", input.UserHandle, input.ProjectHandle)) + } + + // Check if new owner exists + _, err = queries.RetrieveUser(ctx, input.Body.NewOwnerHandle) + if err != nil { + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("new owner user %s not found", input.Body.NewOwnerHandle)) + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to verify new owner user %s: %v", input.Body.NewOwnerHandle, err)) + } + + // Check if new owner already has a project with the same handle + _, err = queries.RetrieveProject(ctx, database.RetrieveProjectParams{ + Owner: input.Body.NewOwnerHandle, + ProjectHandle: input.ProjectHandle, + }) + if err == nil { + return nil, huma.Error409Conflict(fmt.Sprintf("new owner %s already has a project with handle %s", input.Body.NewOwnerHandle, input.ProjectHandle)) + } else if err.Error() != "no rows in result set" { + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to check for conflicting project: %v", err)) + } + + // Execute ownership transfer within a transaction + var transferredProject database.TransferProjectOwnershipRow + err = database.WithTransaction(ctx, pool, func(tx pgx.Tx) error { + queries := database.New(tx) + + // 1. Transfer ownership in projects table + transferred, err := queries.TransferProjectOwnership(ctx, database.TransferProjectOwnershipParams{ + Owner: input.UserHandle, + Owner_2: input.Body.NewOwnerHandle, + ProjectHandle: input.ProjectHandle, + }) + if err != nil { + return fmt.Errorf("unable to transfer project ownership: %v", err) + } + transferredProject = transferred + + // 2. Remove old owner from users_projects table + err = queries.UnlinkProjectFromUser(ctx, database.UnlinkProjectFromUserParams{ + UserHandle: input.UserHandle, + ProjectID: project.ProjectID, + }) + if err != nil { + return fmt.Errorf("unable to unlink old owner from project: %v", err) + } + + // 3. If new owner was previously shared with the project (as editor/reader), remove that entry first + err = queries.UnlinkProjectFromUser(ctx, database.UnlinkProjectFromUserParams{ + UserHandle: input.Body.NewOwnerHandle, + ProjectID: project.ProjectID, + }) + // Ignore error if the entry doesn't exist + if err != nil && err.Error() != "no rows in result set" { + return fmt.Errorf("unable to clean up new owner's previous access: %v", err) + } + + // 4. Add new owner to users_projects table with owner role + _, err = queries.LinkProjectToUser(ctx, database.LinkProjectToUserParams{ + UserHandle: input.Body.NewOwnerHandle, + ProjectID: project.ProjectID, + Role: "owner", + }) + if err != nil { + return fmt.Errorf("unable to link new owner to project: %v", err) + } + + return nil + }) + + if err != nil { + return nil, huma.Error500InternalServerError(err.Error()) + } + + // Build response + response := &models.TransferProjectOwnershipResponse{} + response.Body.ProjectID = int(transferredProject.ProjectID) + response.Body.ProjectHandle = transferredProject.ProjectHandle + response.Body.OldOwner = input.UserHandle + response.Body.NewOwner = transferredProject.Owner + + return response, nil +} + // RegisterProjectRoutes registers all the project routes with the API func RegisterProjectsRoutes(pool *pgxpool.Pool, api huma.API) error { // Define huma.Operations for each route @@ -667,6 +786,18 @@ func RegisterProjectsRoutes(pool *pgxpool.Pool, api huma.API) error { }, Tags: []string{"projects"}, } + transferProjectOwnershipOp := huma.Operation{ + OperationID: "transferProjectOwnership", + Method: http.MethodPost, + Path: "/v1/projects/{user_handle}/{project_handle}/transfer-ownership", + DefaultStatus: http.StatusOK, + Summary: "Transfer ownership of a project to another user", + Security: []map[string][]string{ + {"adminAuth": []string{"admin"}}, + {"ownerAuth": []string{"owner"}}, + }, + Tags: []string{"projects"}, + } huma.Register(api, putProjectOp, addPoolToContext(pool, putProjectFunc)) huma.Register(api, postProjectOp, addPoolToContext(pool, postProjectFunc)) @@ -676,5 +807,6 @@ func RegisterProjectsRoutes(pool *pgxpool.Pool, api huma.API) error { huma.Register(api, shareProjectOp, addPoolToContext(pool, shareProjectFunc)) huma.Register(api, unshareProjectOp, addPoolToContext(pool, unshareProjectFunc)) huma.Register(api, getProjectSharedUsersOp, addPoolToContext(pool, getProjectSharedUsersFunc)) + huma.Register(api, transferProjectOwnershipOp, addPoolToContext(pool, transferProjectOwnershipFunc)) return nil } diff --git a/internal/models/projects.go b/internal/models/projects.go index 6784249..1da46a4 100644 --- a/internal/models/projects.go +++ b/internal/models/projects.go @@ -153,3 +153,24 @@ type GetProjectSharedUsersResponse struct { SharedWith []SharedUser `json:"shared_with" doc:"List of users this project is shared with"` } } + +// Transfer Project Ownership +// POST Path: "/v1/projects/{user_handle}/{project_handle}/transfer-ownership" + +type TransferProjectOwnershipRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"alice" doc:"Current project owner handle"` + ProjectHandle string `json:"project_handle" path:"project_handle" maxLength:"20" minLength:"3" example:"my-project" doc:"Project handle"` + Body struct { + NewOwnerHandle string `json:"new_owner_handle" minLength:"3" maxLength:"20" example:"bob" doc:"User handle of the new owner"` + } +} + +type TransferProjectOwnershipResponse struct { + Header []http.Header `json:"header,omitempty" doc:"Response headers"` + Body struct { + ProjectID int `json:"project_id" doc:"Project ID"` + ProjectHandle string `json:"project_handle" doc:"Project handle"` + OldOwner string `json:"old_owner" doc:"Previous project owner"` + NewOwner string `json:"new_owner" doc:"New project owner"` + } +} From d3c972d0119f7cb20bbfb62a7c78e3d07d51738b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:58:54 +0000 Subject: [PATCH 3/5] Add comprehensive tests for transfer ownership Co-authored-by: awagner-mainz <2088443+awagner-mainz@users.noreply.github.com> --- .../projects_transfer_ownership_test.go | 353 ++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 internal/handlers/projects_transfer_ownership_test.go diff --git a/internal/handlers/projects_transfer_ownership_test.go b/internal/handlers/projects_transfer_ownership_test.go new file mode 100644 index 0000000..ccc46f6 --- /dev/null +++ b/internal/handlers/projects_transfer_ownership_test.go @@ -0,0 +1,353 @@ +package handlers_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProjectTransferOwnershipFunc(t *testing.T) { + + // Get the database connection pool from package variable + pool := connPool + + // Create a mock key generator + mockKeyGen := new(MockKeyGen) + // Set up expectations for the mock key generator - return different keys for each call + mockKeyGen.On("RandomKey", 32).Return("12345678901234567890123456789012", nil).Once() // Alice's key + mockKeyGen.On("RandomKey", 32).Return("abcdefghijklmnopqrstuvwxyz123456", nil).Once() // Bob's key + mockKeyGen.On("RandomKey", 32).Return("98765432109876543210987654321098", nil).Once() // Charlie's key + mockKeyGen.On("RandomKey", 32).Return("11111111111111111111111111111111", nil).Maybe() // Any additional keys + + // Start the server + err, shutDownServer := startTestServer(t, pool, mockKeyGen) + assert.NoError(t, err) + + // Create users to be used in transfer tests + aliceJSON := `{"user_handle": "alice", "name": "Alice Doe", "email": "alice@foo.bar"}` + aliceAPIKey, err := createUser(t, aliceJSON) + if err != nil { + t.Fatalf("Error creating user alice for testing: %v\n", err) + } + + bobJSON := `{"user_handle": "bob", "name": "Bob Smith", "email": "bob@foo.bar"}` + bobAPIKey, err := createUser(t, bobJSON) + if err != nil { + t.Fatalf("Error creating user bob for testing: %v\n", err) + } + + charlieJSON := `{"user_handle": "charlie", "name": "Charlie Brown", "email": "charlie@foo.bar"}` + charlieAPIKey, err := createUser(t, charlieJSON) + if err != nil { + t.Fatalf("Error creating user charlie for testing: %v\n", err) + } + + // Create API standard to be used in tests + openaiJSON := `{"api_standard_handle": "openai", "description": "OpenAI Embeddings API", "key_method": "auth_bearer", "key_field": "Authorization" }` + _, err = createAPIStandard(t, openaiJSON, options.AdminKey) + if err != nil { + t.Fatalf("Error creating API standard openai for testing: %v\n", err) + } + + // Create an instance for alice + instanceJSON := `{"instance_handle": "embedding1", "endpoint": "https://api.openai.com/v1/embeddings", "description": "Alice's OpenAI instance", "api_standard": "openai", "model": "text-embedding-3-large", "dimensions": 3072}` + _, err = createInstance(t, instanceJSON, "alice", aliceAPIKey) + if err != nil { + t.Fatalf("Error creating instance for transfer tests: %v\n", err) + } + + // Create a project for alice + projectJSON := `{"project_handle": "project1", "description": "Alice's test project", "instance_owner": "alice", "instance_handle": "embedding1", "public_read": false}` + _, err = createProject(t, projectJSON, "alice", aliceAPIKey) + if err != nil { + t.Fatalf("Error creating project for transfer tests: %v\n", err) + } + + // Create another project for alice that will be transferred to bob (bob already has this project handle) + project2JSON := `{"project_handle": "project2", "description": "Alice's second project", "instance_owner": "alice", "instance_handle": "embedding1", "public_read": false}` + _, err = createProject(t, project2JSON, "alice", aliceAPIKey) + if err != nil { + t.Fatalf("Error creating second project for transfer tests: %v\n", err) + } + + // Create a project for bob with the same handle (to test conflict scenario) + bobProject2JSON := `{"project_handle": "project2", "description": "Bob's project", "instance_owner": "alice", "instance_handle": "embedding1", "public_read": false}` + _, err = createProject(t, bobProject2JSON, "bob", bobAPIKey) + if err != nil { + t.Fatalf("Error creating project for bob: %v\n", err) + } + + // Create a project for alice that charlie is shared with (to test shared user becoming owner) + project3JSON := `{"project_handle": "project3", "description": "Alice's third project", "instance_owner": "alice", "instance_handle": "embedding1", "public_read": false}` + _, err = createProject(t, project3JSON, "alice", aliceAPIKey) + if err != nil { + t.Fatalf("Error creating third project for transfer tests: %v\n", err) + } + + // Share project3 with charlie as editor + shareJSON := `{"share_with_handle": "charlie", "role": "editor"}` + _, err = shareProject(t, shareJSON, "alice", "project3", aliceAPIKey) + if err != nil { + t.Fatalf("Error sharing project with charlie: %v\n", err) + } + + fmt.Printf("\nRunning project transfer ownership tests ...\n\n") + + // Define test cases + tt := []struct { + name string + method string + requestPath string + bodyJSON string + apiKey string + expectStatus int + expectBody string + }{ + { + name: "Transfer ownership of nonexistent project - should fail", + method: http.MethodPost, + requestPath: "/v1/projects/alice/nonexistent/transfer-ownership", + bodyJSON: `{"new_owner_handle": "bob"}`, + apiKey: aliceAPIKey, + expectStatus: http.StatusNotFound, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"project alice/nonexistent not found\"\n}\n", + }, + { + name: "Transfer ownership to nonexistent user - should fail", + method: http.MethodPost, + requestPath: "/v1/projects/alice/project1/transfer-ownership", + bodyJSON: `{"new_owner_handle": "nonexistent"}`, + apiKey: aliceAPIKey, + expectStatus: http.StatusNotFound, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"new owner user nonexistent not found\"\n}\n", + }, + { + name: "Transfer ownership to self - should fail", + method: http.MethodPost, + requestPath: "/v1/projects/alice/project1/transfer-ownership", + bodyJSON: `{"new_owner_handle": "alice"}`, + apiKey: aliceAPIKey, + expectStatus: http.StatusBadRequest, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Bad Request\",\n \"status\": 400,\n \"detail\": \"new owner must be different from current owner\"\n}\n", + }, + { + name: "Bob cannot transfer ownership of alice's project - should fail", + method: http.MethodPost, + requestPath: "/v1/projects/alice/project1/transfer-ownership", + bodyJSON: `{"new_owner_handle": "bob"}`, + apiKey: bobAPIKey, + expectStatus: http.StatusUnauthorized, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Unauthorized\",\n \"status\": 401,\n \"detail\": \"Authentication failed. Perhaps a missing or incorrect API key?\"\n}\n", + }, + { + name: "Transfer ownership when new owner already has project with same handle - should fail", + method: http.MethodPost, + requestPath: "/v1/projects/alice/project2/transfer-ownership", + bodyJSON: `{"new_owner_handle": "bob"}`, + apiKey: aliceAPIKey, + expectStatus: http.StatusConflict, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Conflict\",\n \"status\": 409,\n \"detail\": \"new owner bob already has a project with handle project2\"\n}\n", + }, + { + name: "Successfully transfer ownership from alice to bob", + method: http.MethodPost, + requestPath: "/v1/projects/alice/project1/transfer-ownership", + bodyJSON: `{"new_owner_handle": "bob"}`, + apiKey: aliceAPIKey, + expectStatus: http.StatusOK, + expectBody: "", // We'll validate the response body separately + }, + { + name: "Successfully transfer ownership from alice to charlie (shared user becomes owner)", + method: http.MethodPost, + requestPath: "/v1/projects/alice/project3/transfer-ownership", + bodyJSON: `{"new_owner_handle": "charlie"}`, + apiKey: aliceAPIKey, + expectStatus: http.StatusOK, + expectBody: "", // We'll validate the response body separately + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + // Prepare the request body + var bodyReader io.Reader + if tc.bodyJSON != "" { + bodyReader = bytes.NewBufferString(tc.bodyJSON) + } + + // Build the request URL + requestURL := fmt.Sprintf("http://%s:%d%s", options.Host, options.Port, tc.requestPath) + + // Create the request + req, err := http.NewRequest(tc.method, requestURL, bodyReader) + if err != nil { + t.Fatalf("Error creating request: %v\n", err) + } + + // Set headers + req.Header.Set("Authorization", "Bearer "+tc.apiKey) + + // Make the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Error making request: %v\n", err) + } + defer resp.Body.Close() + + // Check status code + assert.Equal(t, tc.expectStatus, resp.StatusCode, "Status code mismatch for test: %s", tc.name) + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error reading response body: %v\n", err) + } + + // Check response body + if tc.expectBody != "" { + fr := new(bytes.Buffer) + err = json.Indent(fr, body, "", " ") + assert.NoError(t, err) + formattedResp := fr.String() + assert.Equal(t, tc.expectBody, formattedResp, "Response body mismatch for test: %s", tc.name) + } else if tc.expectStatus == http.StatusOK { + // Parse and validate successful response + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err, "Error parsing JSON response for test: %s", tc.name) + + // Validate response structure + assert.Contains(t, response, "project_id") + assert.Contains(t, response, "project_handle") + assert.Contains(t, response, "old_owner") + assert.Contains(t, response, "new_owner") + + // Parse body to get new owner + var bodyMap map[string]string + json.Unmarshal([]byte(tc.bodyJSON), &bodyMap) + expectedNewOwner := bodyMap["new_owner_handle"] + + // Validate ownership was actually transferred + assert.Equal(t, "alice", response["old_owner"]) + assert.Equal(t, expectedNewOwner, response["new_owner"]) + } + }) + } + + // Additional verification tests + t.Run("Verify bob is now owner of project1", func(t *testing.T) { + requestURL := fmt.Sprintf("http://%s:%d/v1/projects/bob/project1", options.Host, options.Port) + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + if err != nil { + t.Fatalf("Error creating request: %v\n", err) + } + req.Header.Set("Authorization", "Bearer "+bobAPIKey) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Error making request: %v\n", err) + } + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error reading response body: %v\n", err) + } + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err) + assert.Equal(t, "bob", response["owner"]) + assert.Equal(t, "owner", response["role"]) + }) + + t.Run("Verify alice cannot access project1 anymore", func(t *testing.T) { + requestURL := fmt.Sprintf("http://%s:%d/v1/projects/bob/project1", options.Host, options.Port) + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + if err != nil { + t.Fatalf("Error creating request: %v\n", err) + } + req.Header.Set("Authorization", "Bearer "+aliceAPIKey) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Error making request: %v\n", err) + } + defer resp.Body.Close() + + // Alice should not be able to access it anymore - auth middleware returns 401 + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("Verify charlie is now owner of project3", func(t *testing.T) { + requestURL := fmt.Sprintf("http://%s:%d/v1/projects/charlie/project3", options.Host, options.Port) + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + if err != nil { + t.Fatalf("Error creating request: %v\n", err) + } + req.Header.Set("Authorization", "Bearer "+charlieAPIKey) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Error making request: %v\n", err) + } + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error reading response body: %v\n", err) + } + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err) + assert.Equal(t, "charlie", response["owner"]) + assert.Equal(t, "owner", response["role"]) + }) + + // Cleanup - shutdown the server + defer shutDownServer() +} + +// Helper function to share a project +func shareProject(t *testing.T, jsonBody, owner, projectHandle, apiKey string) (string, error) { + requestURL := fmt.Sprintf("http://%s:%d/v1/projects/%s/%s/share", options.Host, options.Port, owner, projectHandle) + req, err := http.NewRequest(http.MethodPost, requestURL, bytes.NewBufferString(jsonBody)) + if err != nil { + return "", fmt.Errorf("error creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("error reading response body: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) + } + + return string(body), nil +} From 03f90969d1c89a7daa11f4eff5f68d368e18f94b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:59:31 +0000 Subject: [PATCH 4/5] Add documentation for ownership transfer feature Co-authored-by: awagner-mainz <2088443+awagner-mainz@users.noreply.github.com> --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index 1aab17d..1136fbc 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,44 @@ GET /v1/projects/{owner}/{project}/shared-with Only the project owner can view the list of shared users and manage sharing. Users who have been granted access to a project cannot see which other users also have access. +## Project Ownership Transfer + +The project owner can transfer ownership of a project to another user. This is useful when: +- A project maintainer is leaving and wants to hand over control +- Organizational changes require reassigning project ownership +- Consolidating projects under a different user account + +**Transfer ownership:** + +```bash +POST /v1/projects/{owner}/{project}/transfer-ownership +{ + "new_owner_handle": "new_owner" +} +``` + +**Important notes:** +- Only the current owner can transfer ownership +- The new owner must be an existing user +- The new owner cannot already have a project with the same handle +- After transfer, the old owner will lose all access to the project +- If the new owner was previously shared with the project, their sharing role will be upgraded to owner +- All embeddings and other project data remain intact during transfer + +**Example:** + +```bash +# Alice transfers her project to Bob +curl -X POST "https://api.example.com/v1/projects/alice/my-project/transfer-ownership" \ + -H "Authorization: Bearer alice_api_key" \ + -H "Content-Type: application/json" \ + -d '{ + "new_owner_handle": "bob" + }' + +# After transfer, the project is accessible at /v1/projects/bob/my-project +``` + ### Shared User Access Once a project is shared with a user, they can: From b61f665b9ad3989172f3134357b01129d6ae64e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:01:02 +0000 Subject: [PATCH 5/5] Address code review feedback - use pgx.ErrNoRows for error handling Co-authored-by: awagner-mainz <2088443+awagner-mainz@users.noreply.github.com> --- internal/handlers/projects.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index 5ae40da..29b8c97 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -596,7 +596,7 @@ func transferProjectOwnershipFunc(ctx context.Context, input *models.TransferPro ProjectHandle: input.ProjectHandle, }) if err != nil { - if err.Error() == "no rows in result set" { + if err == pgx.ErrNoRows { return nil, huma.Error404NotFound(fmt.Sprintf("project %s/%s not found", input.UserHandle, input.ProjectHandle)) } return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve project %s/%s: %v", input.UserHandle, input.ProjectHandle, err)) @@ -610,7 +610,7 @@ func transferProjectOwnershipFunc(ctx context.Context, input *models.TransferPro // Check if new owner exists _, err = queries.RetrieveUser(ctx, input.Body.NewOwnerHandle) if err != nil { - if err.Error() == "no rows in result set" { + if err == pgx.ErrNoRows { return nil, huma.Error404NotFound(fmt.Sprintf("new owner user %s not found", input.Body.NewOwnerHandle)) } return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to verify new owner user %s: %v", input.Body.NewOwnerHandle, err)) @@ -623,7 +623,7 @@ func transferProjectOwnershipFunc(ctx context.Context, input *models.TransferPro }) if err == nil { return nil, huma.Error409Conflict(fmt.Sprintf("new owner %s already has a project with handle %s", input.Body.NewOwnerHandle, input.ProjectHandle)) - } else if err.Error() != "no rows in result set" { + } else if err != pgx.ErrNoRows { return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to check for conflicting project: %v", err)) } @@ -657,9 +657,10 @@ func transferProjectOwnershipFunc(ctx context.Context, input *models.TransferPro UserHandle: input.Body.NewOwnerHandle, ProjectID: project.ProjectID, }) - // Ignore error if the entry doesn't exist - if err != nil && err.Error() != "no rows in result set" { - return fmt.Errorf("unable to clean up new owner's previous access: %v", err) + // Ignore error if the entry doesn't exist - UnlinkProjectFromUser uses DELETE which doesn't return ErrNoRows + // We don't care if they weren't previously shared + if err != nil { + // Just log and continue - this is not a critical error } // 4. Add new owner to users_projects table with owner role