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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/v1/admin/orgs
|
// POST /api/v1/orgs
|
||||||
if r.Method == http.MethodPost && p == "/api/v1/admin/orgs" {
|
if r.Method == http.MethodPost && p == "/api/v1/orgs" {
|
||||||
var body struct {
|
var body struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
|
|||||||
@ -27,7 +27,9 @@
|
|||||||
// Endpoints (Gitea Admin REST API, version 1.22):
|
// Endpoints (Gitea Admin REST API, version 1.22):
|
||||||
//
|
//
|
||||||
// GET /api/v1/orgs/{org}
|
// 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}
|
// GET /api/v1/repos/{owner}/{repo}
|
||||||
// POST /api/v1/orgs/{org}/repos
|
// POST /api/v1/orgs/{org}/repos
|
||||||
// GET /api/v1/repos/{owner}/{repo}/branches/{branch}
|
// GET /api/v1/repos/{owner}/{repo}/branches/{branch}
|
||||||
@ -245,8 +247,12 @@ type Org struct {
|
|||||||
Visibility string `json:"visibility,omitempty"`
|
Visibility string `json:"visibility,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminOrgCreate is the payload for POST /admin/orgs.
|
// orgCreate is the payload for POST /orgs. The authenticated user
|
||||||
type adminOrgCreate struct {
|
// (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"`
|
Username string `json:"username"`
|
||||||
FullName string `json:"full_name,omitempty"`
|
FullName string `json:"full_name,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
@ -288,21 +294,31 @@ func (c *Client) GetOrg(ctx context.Context, slug string) (Org, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateOrg creates a Gitea Org via the admin endpoint. Returns
|
// CreateOrg creates a Gitea Org via POST /orgs (the org-create-as-self
|
||||||
// errAlreadyExists (internal sentinel) on 422/409 so EnsureOrg can
|
// endpoint). The authenticated principal owns the new Org. Because the
|
||||||
// re-find idempotently.
|
// 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) {
|
func (c *Client) CreateOrg(ctx context.Context, slug, fullName, description, visibility string) (Org, error) {
|
||||||
if visibility == "" {
|
if visibility == "" {
|
||||||
visibility = "private"
|
visibility = "private"
|
||||||
}
|
}
|
||||||
body := adminOrgCreate{
|
body := orgCreate{
|
||||||
Username: slug,
|
Username: slug,
|
||||||
FullName: fullName,
|
FullName: fullName,
|
||||||
Description: description,
|
Description: description,
|
||||||
Visibility: visibility,
|
Visibility: visibility,
|
||||||
}
|
}
|
||||||
var out Org
|
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 err != nil {
|
||||||
if status == http.StatusUnprocessableEntity || status == http.StatusConflict {
|
if status == http.StatusUnprocessableEntity || status == http.StatusConflict {
|
||||||
return Org{}, errAlreadyExists
|
return Org{}, errAlreadyExists
|
||||||
|
|||||||
@ -84,9 +84,9 @@ func (f *fakeGitea) handler() http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/v1/admin/orgs
|
// POST /api/v1/orgs
|
||||||
if r.Method == http.MethodPost && p == "/api/v1/admin/orgs" {
|
if r.Method == http.MethodPost && p == "/api/v1/orgs" {
|
||||||
var body adminOrgCreate
|
var body orgCreate
|
||||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
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 {
|
if got := fake.callCount(http.MethodGet, "/api/v1/orgs/acme"); got != 1 {
|
||||||
t.Errorf("expected 1 GET, got %d", got)
|
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)
|
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 {
|
if o.Username != "newone" || o.ID == 0 {
|
||||||
t.Errorf("expected created org, got %+v", o)
|
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)
|
t.Errorf("expected 1 POST, got %d", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -506,7 +506,7 @@ func TestEnsureOrg_422Race(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(Org{ID: 99, Username: "raced"})
|
_ = 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)
|
http.Error(w, "duplicate", http.StatusUnprocessableEntity)
|
||||||
default:
|
default:
|
||||||
http.Error(w, "unhandled", http.StatusInternalServerError)
|
http.Error(w, "unhandled", http.StatusInternalServerError)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user