feat: Forgejo API mock server for CI smoke tests #123

Closed
opened 2026-04-01 18:20:06 +00:00 by dev-bot · 0 comments
Collaborator

Problem

The smoke-init CI pipeline was removed because it spun up a real Forgejo instance inside the Woodpecker CI container, which never started within the 5-minute timeout (Forgejo takes >60s in Docker-in-LXD, often hangs entirely).

The test itself (tests/smoke-init.sh) is valuable — it validates the full disinto init flow. We need to restore it with a mock Forgejo API instead of a real instance.

What to build

A Python HTTP mock server (tests/mock-forgejo.py) that implements the 15 Forgejo API endpoints that disinto init calls. The mock stores state in-memory (dicts), responds instantly, and validates that init sends correct requests.

Endpoints to implement

Based on the Forgejo v11.0 Swagger spec (Gitea 1.22.0 compatible):

1. GET /api/v1/version

  • No auth required
  • Response: {"version": "11.0.0-mock"}

2. POST /api/v1/admin/users

  • Auth: Token (admin)
  • Request body:
    {
      "username": "string (required)",
      "email": "string (required)",
      "password": "string",
      "must_change_password": false,
      "login_name": "string",
      "source_id": 0,
      "full_name": "string",
      "send_notify": false,
      "visibility": "string"
    }
    
  • Response 201: User object {"id": N, "login": "username", "email": "...", "is_admin": false, ...}
  • Store in users dict keyed by username

3. PATCH /api/v1/admin/users/{username}

  • Auth: Token (admin)
  • Request body (all optional):
    {
      "admin": true,
      "must_change_password": false,
      "password": "string",
      "login_name": "string",
      "source_id": 0,
      "email": "string"
    }
    
  • Response 200: Updated user object
  • Update users[username] in-memory

4. GET /api/v1/users/{username}

  • Auth: None (public) or Token
  • Response 200: User object
  • Response 404: {"message": "user does not exist"} if not in users dict

5. POST /api/v1/users/{username}/tokens

  • Auth: Basic (username:password)
  • Request body:
    {
      "name": "string (required)",
      "scopes": ["all"]
    }
    
  • Response 201: {"id": N, "name": "token-name", "sha1": "<generated-hex-40>", "scopes": ["all"]}
  • Generate a deterministic fake token: sha256(username + name)[:40]
  • Store in tokens dict

6. GET /api/v1/repos/{owner}/{repo}

  • Auth: Token
  • Response 200: Repo object {"id": N, "full_name": "owner/repo", "name": "repo", "owner": {"login": "owner"}, "empty": false, "default_branch": "main", ...}
  • Response 404: if not in repos dict

7. POST /api/v1/orgs

  • Auth: Token
  • Request body:
    {
      "username": "string (required)",
      "visibility": "public"
    }
    
  • Response 201: Org object {"id": N, "username": "org-name", ...}
  • Store in orgs dict

8. POST /api/v1/orgs/{org}/repos

  • Auth: Token
  • Request body:
    {
      "name": "string (required)",
      "auto_init": false,
      "default_branch": "main",
      "description": "string",
      "private": false
    }
    
  • Response 201: Repo object
  • Store in repos dict keyed by "{org}/{name}"

9. POST /api/v1/user/repos

  • Auth: Token
  • Same request body as org/repos
  • Response 201: Repo object
  • Store in repos dict keyed by "{authenticated_user}/{name}"

10. PUT /api/v1/repos/{owner}/{repo}/collaborators/{collaborator}

  • Auth: Token
  • Request body:
    {
      "permission": "write|read|admin"
    }
    
  • Response 204 (no body)
  • Store in collaborators[owner/repo] set

11. GET /api/v1/repos/{owner}/{repo}/labels

  • Auth: Token
  • Query: ?limit=50
  • Response 200: [{"id": N, "name": "backlog", "color": "#hex"}, ...]

12. POST /api/v1/repos/{owner}/{repo}/labels

  • Auth: Token
  • Request body:
    {
      "name": "string (required)",
      "color": "#hex (required)",
      "description": "string"
    }
    
  • Response 201: Label object
  • Store in labels[owner/repo] list

13. POST /api/v1/repos/{owner}/{repo}/branch_protections

  • Auth: Token
  • Request body:
    {
      "branch_name": "main",
      "rule_name": "string",
      "enable_push": false,
      "enable_merge_whitelist": true,
      "merge_whitelist_usernames": ["admin"],
      "required_approvals": 1,
      "apply_to_admins": true
    }
    
  • Response 201: BranchProtection object
  • Store in protections[owner/repo] dict

14. GET /api/v1/user/applications/oauth2

  • Auth: Token
  • Response 200: [] (empty — no existing OAuth2 apps)

15. POST /api/v1/user/applications/oauth2

  • Auth: Token
  • Request body:
    {
      "name": "woodpecker-ci",
      "redirect_uris": ["http://localhost:8000/authorize"],
      "confidential_client": true
    }
    
  • Response 201: {"id": N, "name": "woodpecker-ci", "client_id": "<uuid>", "client_secret": "<hex>"}

Server implementation

# tests/mock-forgejo.py
# ~150 lines, stdlib only (http.server + json)
# Starts on port from MOCK_FORGE_PORT env (default 3000)
# State: users={}, tokens={}, repos={}, orgs={}, labels={}, collaborators={}, protections={}
# Auth: validates Token header exists (doesn't check value), validates Basic auth username exists in users dict
# Logging: prints each request method+path for debugging
# Shutdown: SIGTERM or /mock/shutdown endpoint

Auth handling

The mock should:

  • Accept Authorization: token <any> for token auth (don't validate the token value — init generates tokens dynamically)
  • Accept Authorization: Basic <base64> for basic auth — decode and check username exists in users dict
  • Return 401 for endpoints that require auth when no header present

Affected files

  • tests/mock-forgejo.py (new — ~150-200 lines)

Acceptance criteria

  • All 15 endpoints respond correctly
  • State is stored in-memory (dicts)
  • Server starts in <1s
  • Deterministic token generation (sha256-based)
  • Auth validation (token header present, basic auth username exists)
  • Logging of all requests for debugging
  • Handles concurrent requests (ThreadingHTTPServer)
  • Clean shutdown via SIGTERM or /mock/shutdown
  • CI green (no new dependencies — stdlib only)

Dependencies

None — standalone component.

## Problem The `smoke-init` CI pipeline was removed because it spun up a real Forgejo instance inside the Woodpecker CI container, which never started within the 5-minute timeout (Forgejo takes >60s in Docker-in-LXD, often hangs entirely). The test itself (`tests/smoke-init.sh`) is valuable — it validates the full `disinto init` flow. We need to restore it with a mock Forgejo API instead of a real instance. ## What to build A Python HTTP mock server (`tests/mock-forgejo.py`) that implements the 15 Forgejo API endpoints that `disinto init` calls. The mock stores state in-memory (dicts), responds instantly, and validates that init sends correct requests. ### Endpoints to implement Based on the Forgejo v11.0 Swagger spec (Gitea 1.22.0 compatible): #### 1. `GET /api/v1/version` - No auth required - Response: `{"version": "11.0.0-mock"}` #### 2. `POST /api/v1/admin/users` - Auth: Token (admin) - Request body: ```json { "username": "string (required)", "email": "string (required)", "password": "string", "must_change_password": false, "login_name": "string", "source_id": 0, "full_name": "string", "send_notify": false, "visibility": "string" } ``` - Response 201: User object `{"id": N, "login": "username", "email": "...", "is_admin": false, ...}` - Store in `users` dict keyed by username #### 3. `PATCH /api/v1/admin/users/{username}` - Auth: Token (admin) - Request body (all optional): ```json { "admin": true, "must_change_password": false, "password": "string", "login_name": "string", "source_id": 0, "email": "string" } ``` - Response 200: Updated user object - Update `users[username]` in-memory #### 4. `GET /api/v1/users/{username}` - Auth: None (public) or Token - Response 200: User object - Response 404: `{"message": "user does not exist"}` if not in `users` dict #### 5. `POST /api/v1/users/{username}/tokens` - Auth: Basic (username:password) - Request body: ```json { "name": "string (required)", "scopes": ["all"] } ``` - Response 201: `{"id": N, "name": "token-name", "sha1": "<generated-hex-40>", "scopes": ["all"]}` - Generate a deterministic fake token: `sha256(username + name)[:40]` - Store in `tokens` dict #### 6. `GET /api/v1/repos/{owner}/{repo}` - Auth: Token - Response 200: Repo object `{"id": N, "full_name": "owner/repo", "name": "repo", "owner": {"login": "owner"}, "empty": false, "default_branch": "main", ...}` - Response 404: if not in `repos` dict #### 7. `POST /api/v1/orgs` - Auth: Token - Request body: ```json { "username": "string (required)", "visibility": "public" } ``` - Response 201: Org object `{"id": N, "username": "org-name", ...}` - Store in `orgs` dict #### 8. `POST /api/v1/orgs/{org}/repos` - Auth: Token - Request body: ```json { "name": "string (required)", "auto_init": false, "default_branch": "main", "description": "string", "private": false } ``` - Response 201: Repo object - Store in `repos` dict keyed by `"{org}/{name}"` #### 9. `POST /api/v1/user/repos` - Auth: Token - Same request body as org/repos - Response 201: Repo object - Store in `repos` dict keyed by `"{authenticated_user}/{name}"` #### 10. `PUT /api/v1/repos/{owner}/{repo}/collaborators/{collaborator}` - Auth: Token - Request body: ```json { "permission": "write|read|admin" } ``` - Response 204 (no body) - Store in `collaborators[owner/repo]` set #### 11. `GET /api/v1/repos/{owner}/{repo}/labels` - Auth: Token - Query: `?limit=50` - Response 200: `[{"id": N, "name": "backlog", "color": "#hex"}, ...]` #### 12. `POST /api/v1/repos/{owner}/{repo}/labels` - Auth: Token - Request body: ```json { "name": "string (required)", "color": "#hex (required)", "description": "string" } ``` - Response 201: Label object - Store in `labels[owner/repo]` list #### 13. `POST /api/v1/repos/{owner}/{repo}/branch_protections` - Auth: Token - Request body: ```json { "branch_name": "main", "rule_name": "string", "enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": ["admin"], "required_approvals": 1, "apply_to_admins": true } ``` - Response 201: BranchProtection object - Store in `protections[owner/repo]` dict #### 14. `GET /api/v1/user/applications/oauth2` - Auth: Token - Response 200: `[]` (empty — no existing OAuth2 apps) #### 15. `POST /api/v1/user/applications/oauth2` - Auth: Token - Request body: ```json { "name": "woodpecker-ci", "redirect_uris": ["http://localhost:8000/authorize"], "confidential_client": true } ``` - Response 201: `{"id": N, "name": "woodpecker-ci", "client_id": "<uuid>", "client_secret": "<hex>"}` ### Server implementation ```python # tests/mock-forgejo.py # ~150 lines, stdlib only (http.server + json) # Starts on port from MOCK_FORGE_PORT env (default 3000) # State: users={}, tokens={}, repos={}, orgs={}, labels={}, collaborators={}, protections={} # Auth: validates Token header exists (doesn't check value), validates Basic auth username exists in users dict # Logging: prints each request method+path for debugging # Shutdown: SIGTERM or /mock/shutdown endpoint ``` ### Auth handling The mock should: - Accept `Authorization: token <any>` for token auth (don't validate the token value — init generates tokens dynamically) - Accept `Authorization: Basic <base64>` for basic auth — decode and check username exists in users dict - Return 401 for endpoints that require auth when no header present ## Affected files - `tests/mock-forgejo.py` (new — ~150-200 lines) ## Acceptance criteria - [ ] All 15 endpoints respond correctly - [ ] State is stored in-memory (dicts) - [ ] Server starts in <1s - [ ] Deterministic token generation (sha256-based) - [ ] Auth validation (token header present, basic auth username exists) - [ ] Logging of all requests for debugging - [ ] Handles concurrent requests (ThreadingHTTPServer) - [ ] Clean shutdown via SIGTERM or `/mock/shutdown` - [ ] CI green (no new dependencies — stdlib only) ## Dependencies None — standalone component.
disinto-admin added the
backlog
label 2026-04-01 18:31:42 +00:00
dev-qwen self-assigned this 2026-04-01 18:37:15 +00:00
dev-qwen added
in-progress
and removed
backlog
labels 2026-04-01 18:37:16 +00:00
dev-qwen removed their assignment 2026-04-01 19:16:35 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: johba/disinto#123
No description provided.