build source
This commit is contained in:
commit
ee1fec43ed
4171 changed files with 1351288 additions and 0 deletions
BIN
internal/forgejo/._client.go
Normal file
BIN
internal/forgejo/._client.go
Normal file
Binary file not shown.
BIN
internal/forgejo/._client_test.go
Normal file
BIN
internal/forgejo/._client_test.go
Normal file
Binary file not shown.
264
internal/forgejo/client.go
Normal file
264
internal/forgejo/client.go
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
package forgejo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Client talks to the Forgejo admin API for user management.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
adminUser string
|
||||
adminPassword string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a Forgejo API client.
|
||||
func NewClient(baseURL, adminUser, adminPassword string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
adminUser: adminUser,
|
||||
adminPassword: adminPassword,
|
||||
httpClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
// CreateForgejoUser creates a user in Forgejo and returns a personal access token.
|
||||
func (c *Client) CreateForgejoUser(ctx context.Context, username string) (string, error) {
|
||||
password, err := generatePassword(16)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate password: %w", err)
|
||||
}
|
||||
|
||||
email := username + "@spinoff.dev"
|
||||
if err := c.createUser(ctx, username, email, password); err != nil {
|
||||
return "", fmt.Errorf("create forgejo user: %w", err)
|
||||
}
|
||||
|
||||
token, err := c.createAccessToken(ctx, username, password, "dev-pod-access")
|
||||
if err != nil {
|
||||
_ = c.DeleteForgejoUser(ctx, username)
|
||||
return "", fmt.Errorf("create forgejo token: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// DeleteForgejoUser removes a user from Forgejo including all their repos.
|
||||
func (c *Client) DeleteForgejoUser(ctx context.Context, username string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete,
|
||||
fmt.Sprintf("%s/api/v1/admin/users/%s?purge=true", c.baseURL, username), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.SetBasicAuth(c.adminUser, c.adminPassword)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete user request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("delete user: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) createUser(ctx context.Context, username, email, password string) error {
|
||||
payload := map[string]interface{}{
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"must_change_password": false,
|
||||
"visibility": "private",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/api/v1/admin/users", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.SetBasicAuth(c.adminUser, c.adminPassword)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnprocessableEntity {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("user already exists or invalid: %s", respBody)
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("status %d: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) createAccessToken(ctx context.Context, username, password, tokenName string) (string, error) {
|
||||
payload := map[string]interface{}{
|
||||
"name": tokenName,
|
||||
"scopes": []string{
|
||||
"write:repository",
|
||||
"write:issue",
|
||||
"write:organization",
|
||||
"write:user",
|
||||
"write:notification",
|
||||
"write:misc",
|
||||
"write:activitypub",
|
||||
"write:package",
|
||||
"read:repository",
|
||||
"read:issue",
|
||||
"read:organization",
|
||||
"read:user",
|
||||
"read:notification",
|
||||
"read:misc",
|
||||
"read:activitypub",
|
||||
"read:package",
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
fmt.Sprintf("%s/api/v1/users/%s/tokens", c.baseURL, username),
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.SetBasicAuth(username, password)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("status %d: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
SHA1 string `json:"sha1"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if result.SHA1 == "" {
|
||||
return "", fmt.Errorf("empty token in response")
|
||||
}
|
||||
return result.SHA1, nil
|
||||
}
|
||||
|
||||
// GetRepoFileContent reads a file from a Forgejo repository.
|
||||
// Returns the raw file content. Returns nil with no error if the file does not exist.
|
||||
func (c *Client) GetRepoFileContent(ctx context.Context, owner, repo, filepath, ref string) ([]byte, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, owner, repo, filepath)
|
||||
if ref != "" {
|
||||
url += "?ref=" + ref
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.SetBasicAuth(c.adminUser, c.adminPassword)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Content string `json:"content"`
|
||||
Encoding string `json:"encoding"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
|
||||
if result.Encoding != "base64" {
|
||||
return []byte(result.Content), nil
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(result.Content)
|
||||
if err != nil {
|
||||
// Forgejo sometimes uses standard base64 with newlines
|
||||
cleaned := strings.ReplaceAll(result.Content, "\n", "")
|
||||
decoded, err = base64.StdEncoding.DecodeString(cleaned)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode base64 content: %w", err)
|
||||
}
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// CreateIssueComment posts a comment on a Forgejo issue.
|
||||
func (c *Client) CreateIssueComment(ctx context.Context, owner, repo string, issueNumber int, body string) error {
|
||||
payload := map[string]string{"body": body}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", c.baseURL, owner, repo, issueNumber)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payloadBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.SetBasicAuth(c.adminUser, c.adminPassword)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("status %d: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generatePassword(length int) (string, error) {
|
||||
b := make([]byte, length)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
269
internal/forgejo/client_test.go
Normal file
269
internal/forgejo/client_test.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
package forgejo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateForgejoUser_Success(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("POST /api/v1/admin/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != "admin" || pass != "secret" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["username"] != "alice" {
|
||||
t.Fatalf("expected username 'alice', got %v", body["username"])
|
||||
}
|
||||
if body["must_change_password"] != false {
|
||||
t.Fatal("expected must_change_password false")
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"id": 2, "login": "alice"})
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /api/v1/users/alice/tokens", func(w http.ResponseWriter, r *http.Request) {
|
||||
user, _, ok := r.BasicAuth()
|
||||
if !ok || user != "alice" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["name"] != "dev-pod-access" {
|
||||
t.Fatalf("expected token name 'dev-pod-access', got %v", body["name"])
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"sha1": "tok_abc123"})
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "admin", "secret")
|
||||
token, err := client.CreateForgejoUser(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateForgejoUser: %v", err)
|
||||
}
|
||||
if token != "tok_abc123" {
|
||||
t.Fatalf("expected token 'tok_abc123', got %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateForgejoUser_CreateUserFails(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/v1/admin/users", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
w.Write([]byte(`{"message": "user already exists"}`))
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "admin", "secret")
|
||||
_, err := client.CreateForgejoUser(context.Background(), "alice")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "user already exists") {
|
||||
t.Fatalf("expected 'user already exists' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateForgejoUser_TokenCreationFails_CleansUpUser(t *testing.T) {
|
||||
var isUserDeleted bool
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/v1/admin/users", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"id": 2, "login": "alice"})
|
||||
})
|
||||
mux.HandleFunc("POST /api/v1/users/alice/tokens", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message": "db error"}`))
|
||||
})
|
||||
mux.HandleFunc("DELETE /api/v1/admin/users/alice", func(w http.ResponseWriter, _ *http.Request) {
|
||||
isUserDeleted = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "admin", "secret")
|
||||
_, err := client.CreateForgejoUser(context.Background(), "alice")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on token creation failure")
|
||||
}
|
||||
if !isUserDeleted {
|
||||
t.Fatal("expected user to be cleaned up after token creation failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForgejoUser_Success(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("DELETE /api/v1/admin/users/alice", func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != "admin" || pass != "secret" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("purge") != "true" {
|
||||
t.Fatal("expected purge=true query param")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "admin", "secret")
|
||||
if err := client.DeleteForgejoUser(context.Background(), "alice"); err != nil {
|
||||
t.Fatalf("DeleteForgejoUser: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForgejoUser_NotFound_NoError(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("DELETE /api/v1/admin/users/alice", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "admin", "secret")
|
||||
if err := client.DeleteForgejoUser(context.Background(), "alice"); err != nil {
|
||||
t.Fatalf("expected no error for 404, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRepoFileContent_Success(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/v1/repos/alice/myrepo/contents/.spinoff.yml", func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != "admin" || pass != "secret" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("ref") != "main" {
|
||||
t.Fatalf("expected ref=main, got %s", r.URL.Query().Get("ref"))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"content": "dG9vbHM6IFtydXN0XQo=",
|
||||
"encoding": "base64",
|
||||
})
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "admin", "secret")
|
||||
content, err := client.GetRepoFileContent(context.Background(), "alice", "myrepo", ".spinoff.yml", "main")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRepoFileContent: %v", err)
|
||||
}
|
||||
if string(content) != "tools: [rust]\n" {
|
||||
t.Fatalf("expected 'tools: [rust]\\n', got %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRepoFileContent_NotFound(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/v1/repos/alice/myrepo/contents/.spinoff.yml", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "admin", "secret")
|
||||
content, err := client.GetRepoFileContent(context.Background(), "alice", "myrepo", ".spinoff.yml", "main")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for 404, got: %v", err)
|
||||
}
|
||||
if content != nil {
|
||||
t.Fatalf("expected nil content for 404, got %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIssueComment_Success(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/v1/repos/alice/myrepo/issues/42/comments", func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != "admin" || pass != "secret" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["body"] != "Builder pod created" {
|
||||
t.Fatalf("expected body 'Builder pod created', got %q", body["body"])
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"id": 1})
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "admin", "secret")
|
||||
err := client.CreateIssueComment(context.Background(), "alice", "myrepo", 42, "Builder pod created")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssueComment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIssueComment_Failure(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/v1/repos/alice/myrepo/issues/42/comments", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"message": "forbidden"}`))
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "admin", "secret")
|
||||
err := client.CreateIssueComment(context.Background(), "alice", "myrepo", 42, "test")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Fatalf("expected 403 in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePassword(t *testing.T) {
|
||||
p1, err := generatePassword(16)
|
||||
if err != nil {
|
||||
t.Fatalf("generatePassword: %v", err)
|
||||
}
|
||||
if len(p1) != 32 {
|
||||
t.Fatalf("expected 32 hex chars, got %d", len(p1))
|
||||
}
|
||||
|
||||
p2, _ := generatePassword(16)
|
||||
if p1 == p2 {
|
||||
t.Fatal("expected different passwords")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue