fix(gitea-client): use POST /api/v1/orgs not /admin/orgs for org create (TBD-A43, Closes #1906) (#1910)
Gitea 1.22+ no longer routes POST /api/v1/admin/orgs — that path is GET-only (admin list) and returns 405 with `Allow: GET`. The supported create endpoint is POST /api/v1/orgs (org-create-as-self): the authenticated principal owns the new Org. Because the organization-controller authenticates with the Gitea admin token (catalyst-gitea-token, owner=gitea_admin), the admin user owns each tenant Org — same semantic as the legacy admin path. Symptom on t31: catalyst-organization-controller loops on "gitea.EnsureOrg: create: gitea: POST .../api/v1/admin/orgs: HTTP 405", blocking D29 Step 7 (tenant Gitea Org provisioning). Real Gitea API proof (t31, Gitea 1.22.3): - BEFORE: POST /api/v1/admin/orgs → 405 Method Not Allowed (Allow: GET) - AFTER: POST /api/v1/orgs → 201 Created - 422 on duplicate username → unchanged (still mapped to errAlreadyExists) Closes #1906 Refs TBD-A43 Co-authored-by: hatiyildiz <hatice.yildiz@openova.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8b5cab3aae
commit
f442c28174
@ -174,8 +174,8 @@ func (g *giteaServer) handle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/orgs
|
||||
if r.Method == http.MethodPost && p == "/api/v1/admin/orgs" {
|
||||
// POST /api/v1/orgs
|
||||
if r.Method == http.MethodPost && p == "/api/v1/orgs" {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
FullName string `json:"full_name"`
|
||||
|
||||
@ -27,7 +27,9 @@
|
||||
// Endpoints (Gitea Admin REST API, version 1.22):
|
||||
//
|
||||
// GET /api/v1/orgs/{org}
|
||||
// POST /api/v1/admin/orgs
|
||||
// POST /api/v1/orgs (org-create-as-self;
|
||||
// admin-owned token →
|
||||
// admin owns the new org)
|
||||
// GET /api/v1/repos/{owner}/{repo}
|
||||
// POST /api/v1/orgs/{org}/repos
|
||||
// GET /api/v1/repos/{owner}/{repo}/branches/{branch}
|
||||
@ -245,8 +247,12 @@ type Org struct {
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
}
|
||||
|
||||
// adminOrgCreate is the payload for POST /admin/orgs.
|
||||
type adminOrgCreate struct {
|
||||
// orgCreate is the payload for POST /orgs. The authenticated user
|
||||
// (the bearer of the admin access-token) becomes the new Org's owner.
|
||||
// In Gitea 1.22+, the legacy POST /admin/orgs/{user} endpoint is no
|
||||
// longer routed (returns 405 with `Allow: GET`); /orgs is the only
|
||||
// supported create path for both admin- and user-owned tokens.
|
||||
type orgCreate struct {
|
||||
Username string `json:"username"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
@ -288,21 +294,31 @@ func (c *Client) GetOrg(ctx context.Context, slug string) (Org, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateOrg creates a Gitea Org via the admin endpoint. Returns
|
||||
// errAlreadyExists (internal sentinel) on 422/409 so EnsureOrg can
|
||||
// re-find idempotently.
|
||||
// CreateOrg creates a Gitea Org via POST /orgs (the org-create-as-self
|
||||
// endpoint). The authenticated principal owns the new Org. Because the
|
||||
// controller authenticates with a Gitea admin token, the admin user
|
||||
// owns each created tenant Org — same semantic as the legacy
|
||||
// /admin/orgs path. Returns errAlreadyExists (internal sentinel) on
|
||||
// 422/409 so EnsureOrg can re-find idempotently.
|
||||
//
|
||||
// NOTE: Gitea 1.22+ no longer routes POST /api/v1/admin/orgs (returns
|
||||
// HTTP 405 `Allow: GET`); the admin-namespaced create path is
|
||||
// /api/v1/admin/users/{user}/orgs but is order-of-magnitude clunkier
|
||||
// (requires knowing the admin username). /orgs covers every realistic
|
||||
// production deployment because the controller's token is always
|
||||
// owned by a sufficiently-privileged user.
|
||||
func (c *Client) CreateOrg(ctx context.Context, slug, fullName, description, visibility string) (Org, error) {
|
||||
if visibility == "" {
|
||||
visibility = "private"
|
||||
}
|
||||
body := adminOrgCreate{
|
||||
body := orgCreate{
|
||||
Username: slug,
|
||||
FullName: fullName,
|
||||
Description: description,
|
||||
Visibility: visibility,
|
||||
}
|
||||
var out Org
|
||||
status, _, err := c.do(ctx, http.MethodPost, "/admin/orgs", body, &out)
|
||||
status, _, err := c.do(ctx, http.MethodPost, "/orgs", body, &out)
|
||||
if err != nil {
|
||||
if status == http.StatusUnprocessableEntity || status == http.StatusConflict {
|
||||
return Org{}, errAlreadyExists
|
||||
|
||||
@ -84,9 +84,9 @@ func (f *fakeGitea) handler() http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/orgs
|
||||
if r.Method == http.MethodPost && p == "/api/v1/admin/orgs" {
|
||||
var body adminOrgCreate
|
||||
// POST /api/v1/orgs
|
||||
if r.Method == http.MethodPost && p == "/api/v1/orgs" {
|
||||
var body orgCreate
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@ -472,7 +472,7 @@ func TestEnsureOrg_FindHits(t *testing.T) {
|
||||
if got := fake.callCount(http.MethodGet, "/api/v1/orgs/acme"); got != 1 {
|
||||
t.Errorf("expected 1 GET, got %d", got)
|
||||
}
|
||||
if got := fake.callCount(http.MethodPost, "/api/v1/admin/orgs"); got != 0 {
|
||||
if got := fake.callCount(http.MethodPost, "/api/v1/orgs"); got != 0 {
|
||||
t.Errorf("expected 0 POST when org pre-exists, got %d", got)
|
||||
}
|
||||
}
|
||||
@ -489,7 +489,7 @@ func TestEnsureOrg_CreatesWhenMissing(t *testing.T) {
|
||||
if o.Username != "newone" || o.ID == 0 {
|
||||
t.Errorf("expected created org, got %+v", o)
|
||||
}
|
||||
if got := fake.callCount(http.MethodPost, "/api/v1/admin/orgs"); got != 1 {
|
||||
if got := fake.callCount(http.MethodPost, "/api/v1/orgs"); got != 1 {
|
||||
t.Errorf("expected 1 POST, got %d", got)
|
||||
}
|
||||
}
|
||||
@ -506,7 +506,7 @@ func TestEnsureOrg_422Race(t *testing.T) {
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(Org{ID: 99, Username: "raced"})
|
||||
case "POST /api/v1/admin/orgs":
|
||||
case "POST /api/v1/orgs":
|
||||
http.Error(w, "duplicate", http.StatusUnprocessableEntity)
|
||||
default:
|
||||
http.Error(w, "unhandled", http.StatusInternalServerError)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user