feat(platform): Cloudflare Artifacts demo integration (#595)
Add a minimal but complete integration with the Cloudflare Artifacts API (private beta Apr 2026, public beta May 2026) — "Git for agents" versioned workspace-snapshot storage. ## What's included **`platform/internal/artifacts/client.go`** — typed Go HTTP client for the CF Artifacts REST API: - CreateRepo, GetRepo, ForkRepo, ImportRepo, DeleteRepo - CreateToken, RevokeToken - CF v4 response-envelope decoding; *APIError with StatusCode + Message **`platform/internal/handlers/artifacts.go`** — four workspace-scoped Gin handlers (all behind WorkspaceAuth middleware): - POST /workspaces/:id/artifacts — attach or import a CF Artifacts repo - GET /workspaces/:id/artifacts — get linked repo info (DB + live CF) - POST /workspaces/:id/artifacts/fork — fork the workspace's repo - POST /workspaces/:id/artifacts/token — mint a short-lived git credential **`platform/migrations/028_workspace_artifacts.up.sql`** — `workspace_artifacts` table: one-to-one link between a workspace and its CF Artifacts repo. Credentials are never stored; only the credential-stripped remote URL. **`platform/internal/router/router.go`** — wire the four routes into the existing wsAuth group. ## Configuration Two env vars gate the feature (returns 503 when either is absent): - CF_ARTIFACTS_API_TOKEN — Cloudflare API token with Artifacts write perms - CF_ARTIFACTS_NAMESPACE — Cloudflare Artifacts namespace name ## Tests - 10 client-level tests (httptest.Server + CF v4 envelope mocks) - 14 handler-level tests (sqlmock DB + mock CF server) - Helper unit tests for stripCredentials, cfErrToHTTP All 21 packages pass (go test ./...). Closes #595 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c1443bf52f
commit
57ff2eab78
281
platform/internal/artifacts/client.go
Normal file
281
platform/internal/artifacts/client.go
Normal file
@ -0,0 +1,281 @@
|
||||
// Package artifacts provides a minimal Go client for the Cloudflare Artifacts
|
||||
// REST API (private beta Apr 2026, public beta May 2026).
|
||||
//
|
||||
// API reference: https://developers.cloudflare.com/artifacts/api/rest-api/
|
||||
// Blog post: https://blog.cloudflare.com/artifacts-git-for-agents-beta/
|
||||
//
|
||||
// Base URL: https://artifacts.cloudflare.net/v1/api/namespaces/{namespace}
|
||||
// Auth: Authorization: Bearer <CLOUDFLARE_API_TOKEN>
|
||||
//
|
||||
// This client covers the subset of operations needed for the Molecule AI
|
||||
// workspace-snapshot demo:
|
||||
// - CreateRepo — provision a bare Git repo for a workspace
|
||||
// - GetRepo — fetch repo metadata (remote URL, created_at, …)
|
||||
// - ForkRepo — create an isolated copy (e.g. workspace branching)
|
||||
// - ImportRepo — bootstrap from an external GitHub/GitLab URL
|
||||
// - DeleteRepo — clean-up
|
||||
// - CreateToken — mint a short-lived Git credential for clone/push
|
||||
// - RevokeToken — invalidate an issued token
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://artifacts.cloudflare.net/v1/api"
|
||||
defaultTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// Client is a thin HTTP wrapper around the Cloudflare Artifacts REST API.
|
||||
// Instantiate with New(); override BaseURL in tests via NewWithBaseURL().
|
||||
type Client struct {
|
||||
baseURL string // e.g. https://artifacts.cloudflare.net/v1/api/namespaces/my-ns
|
||||
apiToken string // Cloudflare API token — never logged
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New returns a Client scoped to the given namespace.
|
||||
// apiToken is a Cloudflare API token with Artifacts write permissions.
|
||||
// namespace identifies the CF Artifacts namespace (maps to CLOUDFLARE_ARTIFACTS_NAMESPACE).
|
||||
func New(apiToken, namespace string) *Client {
|
||||
return NewWithBaseURL(apiToken, namespace, defaultBaseURL)
|
||||
}
|
||||
|
||||
// NewWithBaseURL is the same as New but lets callers override the base URL —
|
||||
// primarily used in unit tests to point at an httptest.Server.
|
||||
func NewWithBaseURL(apiToken, namespace, baseURL string) *Client {
|
||||
ns := url.PathEscape(namespace)
|
||||
return &Client{
|
||||
baseURL: fmt.Sprintf("%s/namespaces/%s", baseURL, ns),
|
||||
apiToken: apiToken,
|
||||
httpClient: &http.Client{
|
||||
Timeout: defaultTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Domain types --------------------------------------------------------
|
||||
|
||||
// Repo represents a single Cloudflare Artifacts repository.
|
||||
type Repo struct {
|
||||
// Name is the user-supplied identifier within the namespace.
|
||||
Name string `json:"name"`
|
||||
// ID is the opaque CF-assigned identifier.
|
||||
ID string `json:"id,omitempty"`
|
||||
// RemoteURL is the authenticated Git remote in the form
|
||||
// https://x:<TOKEN>@<hash>.artifacts.cloudflare.net/git/repo-<id>.git
|
||||
RemoteURL string `json:"remote_url,omitempty"`
|
||||
// ReadOnly marks repos that accept only fetch/clone operations.
|
||||
ReadOnly bool `json:"read_only,omitempty"`
|
||||
// Description is an optional human-readable label.
|
||||
Description string `json:"description,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// ForkResult is the response body from POST /repos/:name/fork.
|
||||
type ForkResult struct {
|
||||
Repo Repo `json:"repo"`
|
||||
ObjectCount int `json:"object_count,omitempty"`
|
||||
}
|
||||
|
||||
// RepoToken is a short-lived credential for Git operations against a single repo.
|
||||
// The plaintext Token value is returned only once — callers must store it.
|
||||
type RepoToken struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
Scope string `json:"scope"` // "read" | "write"
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// ---- Request payloads ----------------------------------------------------
|
||||
|
||||
// CreateRepoRequest is the body for POST /repos.
|
||||
type CreateRepoRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DefaultBranch string `json:"default_branch,omitempty"`
|
||||
ReadOnly bool `json:"read_only,omitempty"`
|
||||
}
|
||||
|
||||
// ForkRepoRequest is the body for POST /repos/:name/fork.
|
||||
type ForkRepoRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ReadOnly bool `json:"read_only,omitempty"`
|
||||
DefaultBranchOnly bool `json:"default_branch_only,omitempty"`
|
||||
}
|
||||
|
||||
// ImportRepoRequest is the body for POST /repos/:name/import.
|
||||
type ImportRepoRequest struct {
|
||||
// URL is the HTTPS URL of the source Git repository.
|
||||
URL string `json:"url"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Depth int `json:"depth,omitempty"`
|
||||
ReadOnly bool `json:"read_only,omitempty"`
|
||||
}
|
||||
|
||||
// CreateTokenRequest is the body for POST /tokens.
|
||||
type CreateTokenRequest struct {
|
||||
// Repo is the name of the repository to scope the token to.
|
||||
Repo string `json:"repo"`
|
||||
Scope string `json:"scope,omitempty"` // "read" | "write"; default "write"
|
||||
// TTL is the lifetime in seconds. Default 86400 (24h).
|
||||
TTL int `json:"ttl,omitempty"`
|
||||
}
|
||||
|
||||
// ---- API error -----------------------------------------------------------
|
||||
|
||||
// APIError represents a non-2xx response from the Cloudflare v4 envelope.
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("cloudflare artifacts: HTTP %d — code %d: %s", e.StatusCode, e.Code, e.Message)
|
||||
}
|
||||
|
||||
// ---- HTTP helpers --------------------------------------------------------
|
||||
|
||||
// do executes an HTTP request, checks the Cloudflare v4 envelope, and
|
||||
// JSON-decodes the "result" field into out (pass nil to discard).
|
||||
func (c *Client) do(ctx context.Context, method, path string, body, out interface{}) error {
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("artifacts: marshal request: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("artifacts: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("artifacts: request %s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Decode the Cloudflare v4 envelope.
|
||||
var envelope struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Success bool `json:"success"`
|
||||
Errors []struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
||||
// Non-JSON body (network error page, etc.)
|
||||
return &APIError{StatusCode: resp.StatusCode, Message: fmt.Sprintf("non-JSON body (status %d)", resp.StatusCode)}
|
||||
}
|
||||
|
||||
if !envelope.Success || resp.StatusCode >= 300 {
|
||||
apiErr := &APIError{StatusCode: resp.StatusCode}
|
||||
if len(envelope.Errors) > 0 {
|
||||
apiErr.Code = envelope.Errors[0].Code
|
||||
apiErr.Message = envelope.Errors[0].Message
|
||||
} else {
|
||||
apiErr.Message = "unknown error"
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
if out != nil && len(envelope.Result) > 0 {
|
||||
if err := json.Unmarshal(envelope.Result, out); err != nil {
|
||||
return fmt.Errorf("artifacts: decode result: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Repo operations -----------------------------------------------------
|
||||
|
||||
// CreateRepo provisions a new bare Git repo in the namespace.
|
||||
// Corresponds to POST /repos.
|
||||
func (c *Client) CreateRepo(ctx context.Context, req CreateRepoRequest) (*Repo, error) {
|
||||
var repo Repo
|
||||
if err := c.do(ctx, http.MethodPost, "/repos", req, &repo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &repo, nil
|
||||
}
|
||||
|
||||
// GetRepo fetches metadata for an existing repo.
|
||||
// Corresponds to GET /repos/:name.
|
||||
func (c *Client) GetRepo(ctx context.Context, name string) (*Repo, error) {
|
||||
var repo Repo
|
||||
path := "/repos/" + url.PathEscape(name)
|
||||
if err := c.do(ctx, http.MethodGet, path, nil, &repo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &repo, nil
|
||||
}
|
||||
|
||||
// ForkRepo creates an isolated copy of an existing repo.
|
||||
// Corresponds to POST /repos/:name/fork.
|
||||
func (c *Client) ForkRepo(ctx context.Context, sourceName string, req ForkRepoRequest) (*ForkResult, error) {
|
||||
var result ForkResult
|
||||
path := "/repos/" + url.PathEscape(sourceName) + "/fork"
|
||||
if err := c.do(ctx, http.MethodPost, path, req, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ImportRepo bootstraps a repo from an external HTTPS Git URL.
|
||||
// Corresponds to POST /repos/:name/import.
|
||||
func (c *Client) ImportRepo(ctx context.Context, name string, req ImportRepoRequest) (*Repo, error) {
|
||||
var repo Repo
|
||||
path := "/repos/" + url.PathEscape(name) + "/import"
|
||||
if err := c.do(ctx, http.MethodPost, path, req, &repo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &repo, nil
|
||||
}
|
||||
|
||||
// DeleteRepo deletes a repo (returns 202 Accepted).
|
||||
// Corresponds to DELETE /repos/:name.
|
||||
func (c *Client) DeleteRepo(ctx context.Context, name string) error {
|
||||
path := "/repos/" + url.PathEscape(name)
|
||||
return c.do(ctx, http.MethodDelete, path, nil, nil)
|
||||
}
|
||||
|
||||
// ---- Token operations ----------------------------------------------------
|
||||
|
||||
// CreateToken mints a short-lived Git credential scoped to a single repo.
|
||||
// The plaintext token is in the returned RepoToken.Token field — it will not
|
||||
// be available again after this call returns.
|
||||
// Corresponds to POST /tokens.
|
||||
func (c *Client) CreateToken(ctx context.Context, req CreateTokenRequest) (*RepoToken, error) {
|
||||
var token RepoToken
|
||||
if err := c.do(ctx, http.MethodPost, "/tokens", req, &token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// RevokeToken invalidates an issued token by its ID.
|
||||
// Corresponds to DELETE /tokens/:id.
|
||||
func (c *Client) RevokeToken(ctx context.Context, tokenID string) error {
|
||||
path := "/tokens/" + url.PathEscape(tokenID)
|
||||
return c.do(ctx, http.MethodDelete, path, nil, nil)
|
||||
}
|
||||
370
platform/internal/artifacts/client_test.go
Normal file
370
platform/internal/artifacts/client_test.go
Normal file
@ -0,0 +1,370 @@
|
||||
package artifacts_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/artifacts"
|
||||
)
|
||||
|
||||
// cfEnvelope wraps a result value in the Cloudflare v4 response envelope.
|
||||
func cfEnvelope(t *testing.T, result interface{}) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("cfEnvelope: marshal result: %v", err)
|
||||
}
|
||||
env := map[string]interface{}{
|
||||
"success": true,
|
||||
"result": json.RawMessage(b),
|
||||
"errors": []interface{}{},
|
||||
}
|
||||
out, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatalf("cfEnvelope: marshal envelope: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cfError returns a Cloudflare v4 error envelope.
|
||||
func cfError(t *testing.T, statusCode, code int, message string) ([]byte, int) {
|
||||
t.Helper()
|
||||
env := map[string]interface{}{
|
||||
"success": false,
|
||||
"result": nil,
|
||||
"errors": []map[string]interface{}{
|
||||
{"code": code, "message": message},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(env)
|
||||
return b, statusCode
|
||||
}
|
||||
|
||||
func newTestClient(t *testing.T, mux *http.ServeMux) *artifacts.Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return artifacts.NewWithBaseURL("test-token", "test-ns", srv.URL)
|
||||
}
|
||||
|
||||
// ---- CreateRepo ----------------------------------------------------------
|
||||
|
||||
func TestCreateRepo_Success(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/namespaces/test-ns/repos", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// Verify auth header
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Decode request body
|
||||
var req map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req["name"] != "my-workspace-repo" {
|
||||
http.Error(w, "unexpected name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
repo := artifacts.Repo{
|
||||
Name: "my-workspace-repo",
|
||||
ID: "repo-abc123",
|
||||
RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/repo-abc123.git",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(cfEnvelope(t, repo))
|
||||
})
|
||||
|
||||
client := newTestClient(t, mux)
|
||||
repo, err := client.CreateRepo(context.Background(), artifacts.CreateRepoRequest{
|
||||
Name: "my-workspace-repo",
|
||||
Description: "Molecule AI workspace snapshot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRepo: unexpected error: %v", err)
|
||||
}
|
||||
if repo.Name != "my-workspace-repo" {
|
||||
t.Errorf("repo.Name = %q, want %q", repo.Name, "my-workspace-repo")
|
||||
}
|
||||
if repo.ID != "repo-abc123" {
|
||||
t.Errorf("repo.ID = %q, want %q", repo.ID, "repo-abc123")
|
||||
}
|
||||
if repo.RemoteURL == "" {
|
||||
t.Error("repo.RemoteURL is empty, want non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRepo_APIError(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/namespaces/test-ns/repos", func(w http.ResponseWriter, r *http.Request) {
|
||||
body, status := cfError(t, http.StatusConflict, 1009, "repo already exists")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
w.Write(body)
|
||||
})
|
||||
|
||||
client := newTestClient(t, mux)
|
||||
_, err := client.CreateRepo(context.Background(), artifacts.CreateRepoRequest{Name: "dup"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
apiErr, ok := err.(*artifacts.APIError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *APIError, got %T: %v", err, err)
|
||||
}
|
||||
if apiErr.StatusCode != http.StatusConflict {
|
||||
t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusConflict)
|
||||
}
|
||||
if apiErr.Message != "repo already exists" {
|
||||
t.Errorf("Message = %q, want %q", apiErr.Message, "repo already exists")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- GetRepo -------------------------------------------------------------
|
||||
|
||||
func TestGetRepo_Success(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/namespaces/test-ns/repos/my-repo", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
repo := artifacts.Repo{
|
||||
Name: "my-repo",
|
||||
ID: "repo-xyz",
|
||||
RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/repo-xyz.git",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(cfEnvelope(t, repo))
|
||||
})
|
||||
|
||||
client := newTestClient(t, mux)
|
||||
repo, err := client.GetRepo(context.Background(), "my-repo")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRepo: unexpected error: %v", err)
|
||||
}
|
||||
if repo.Name != "my-repo" {
|
||||
t.Errorf("repo.Name = %q, want %q", repo.Name, "my-repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRepo_NotFound(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/namespaces/test-ns/repos/missing", func(w http.ResponseWriter, r *http.Request) {
|
||||
body, status := cfError(t, http.StatusNotFound, 1004, "repo not found")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
w.Write(body)
|
||||
})
|
||||
|
||||
client := newTestClient(t, mux)
|
||||
_, err := client.GetRepo(context.Background(), "missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
apiErr, ok := err.(*artifacts.APIError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *APIError, got %T", err)
|
||||
}
|
||||
if apiErr.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ForkRepo ------------------------------------------------------------
|
||||
|
||||
func TestForkRepo_Success(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/namespaces/test-ns/repos/source-repo/fork", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req["name"] != "forked-repo" {
|
||||
http.Error(w, "unexpected fork name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result := artifacts.ForkResult{
|
||||
Repo: artifacts.Repo{
|
||||
Name: "forked-repo",
|
||||
ID: "repo-fork-1",
|
||||
RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/repo-fork-1.git",
|
||||
},
|
||||
ObjectCount: 42,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(cfEnvelope(t, result))
|
||||
})
|
||||
|
||||
client := newTestClient(t, mux)
|
||||
result, err := client.ForkRepo(context.Background(), "source-repo", artifacts.ForkRepoRequest{
|
||||
Name: "forked-repo",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ForkRepo: unexpected error: %v", err)
|
||||
}
|
||||
if result.Repo.Name != "forked-repo" {
|
||||
t.Errorf("Repo.Name = %q, want %q", result.Repo.Name, "forked-repo")
|
||||
}
|
||||
if result.ObjectCount != 42 {
|
||||
t.Errorf("ObjectCount = %d, want 42", result.ObjectCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ImportRepo ----------------------------------------------------------
|
||||
|
||||
func TestImportRepo_Success(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/namespaces/test-ns/repos/imported/import", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req["url"] == "" {
|
||||
http.Error(w, "url required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
repo := artifacts.Repo{
|
||||
Name: "imported",
|
||||
ID: "repo-imp-1",
|
||||
RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/repo-imp-1.git",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(cfEnvelope(t, repo))
|
||||
})
|
||||
|
||||
client := newTestClient(t, mux)
|
||||
repo, err := client.ImportRepo(context.Background(), "imported", artifacts.ImportRepoRequest{
|
||||
URL: "https://github.com/Molecule-AI/molecule-core.git",
|
||||
Branch: "main",
|
||||
Depth: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportRepo: unexpected error: %v", err)
|
||||
}
|
||||
if repo.Name != "imported" {
|
||||
t.Errorf("repo.Name = %q, want %q", repo.Name, "imported")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DeleteRepo ----------------------------------------------------------
|
||||
|
||||
func TestDeleteRepo_Success(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/namespaces/test-ns/repos/to-delete", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
deleted := map[string]string{"id": "repo-del-1"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
w.Write(cfEnvelope(t, deleted))
|
||||
})
|
||||
|
||||
client := newTestClient(t, mux)
|
||||
if err := client.DeleteRepo(context.Background(), "to-delete"); err != nil {
|
||||
t.Fatalf("DeleteRepo: unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CreateToken ---------------------------------------------------------
|
||||
|
||||
func TestCreateToken_Success(t *testing.T) {
|
||||
expiry := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/namespaces/test-ns/tokens", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req["repo"] != "my-repo" {
|
||||
http.Error(w, "unexpected repo", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tok := artifacts.RepoToken{
|
||||
ID: "tok-123",
|
||||
Token: "plaintext-secret-abc",
|
||||
Scope: "write",
|
||||
ExpiresAt: expiry,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(cfEnvelope(t, tok))
|
||||
})
|
||||
|
||||
client := newTestClient(t, mux)
|
||||
tok, err := client.CreateToken(context.Background(), artifacts.CreateTokenRequest{
|
||||
Repo: "my-repo",
|
||||
Scope: "write",
|
||||
TTL: 86400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateToken: unexpected error: %v", err)
|
||||
}
|
||||
if tok.ID != "tok-123" {
|
||||
t.Errorf("ID = %q, want %q", tok.ID, "tok-123")
|
||||
}
|
||||
if tok.Token != "plaintext-secret-abc" {
|
||||
t.Errorf("Token = %q, want %q", tok.Token, "plaintext-secret-abc")
|
||||
}
|
||||
if tok.Scope != "write" {
|
||||
t.Errorf("Scope = %q, want %q", tok.Scope, "write")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- RevokeToken ---------------------------------------------------------
|
||||
|
||||
func TestRevokeToken_Success(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/namespaces/test-ns/tokens/tok-456", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
deleted := map[string]string{"id": "tok-456"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(cfEnvelope(t, deleted))
|
||||
})
|
||||
|
||||
client := newTestClient(t, mux)
|
||||
if err := client.RevokeToken(context.Background(), "tok-456"); err != nil {
|
||||
t.Fatalf("RevokeToken: unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Context cancellation ------------------------------------------------
|
||||
|
||||
func TestCreateRepo_ContextCancelled(t *testing.T) {
|
||||
// Server that never responds (simulates a hung connection)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/namespaces/test-ns/repos", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Block until the client gives up
|
||||
<-r.Context().Done()
|
||||
})
|
||||
|
||||
client := newTestClient(t, mux)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
_, err := client.CreateRepo(ctx, artifacts.CreateRepoRequest{Name: "x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from cancelled context, got nil")
|
||||
}
|
||||
}
|
||||
423
platform/internal/handlers/artifacts.go
Normal file
423
platform/internal/handlers/artifacts.go
Normal file
@ -0,0 +1,423 @@
|
||||
package handlers
|
||||
|
||||
// ArtifactsHandler exposes the Cloudflare Artifacts demo integration.
|
||||
//
|
||||
// Routes (all behind WorkspaceAuth middleware):
|
||||
//
|
||||
// POST /workspaces/:id/artifacts — attach a CF Artifacts repo to this workspace
|
||||
// GET /workspaces/:id/artifacts — get the linked repo info
|
||||
// POST /workspaces/:id/artifacts/fork — fork this workspace's repo
|
||||
// POST /workspaces/:id/artifacts/token — mint a short-lived git credential
|
||||
//
|
||||
// Configuration (env vars, loaded once at platform startup):
|
||||
//
|
||||
// CF_ARTIFACTS_API_TOKEN — Cloudflare API token with Artifacts write permissions
|
||||
// CF_ARTIFACTS_NAMESPACE — Cloudflare Artifacts namespace name
|
||||
//
|
||||
// When either env var is absent the handler returns 503 with a clear message so
|
||||
// callers know the feature is not yet configured (private beta onboarding).
|
||||
//
|
||||
// See: https://developers.cloudflare.com/artifacts/
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/artifacts"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ArtifactsHandler holds a pre-built CF Artifacts client.
|
||||
// The client is nil when CF_ARTIFACTS_API_TOKEN / CF_ARTIFACTS_NAMESPACE are unset.
|
||||
type ArtifactsHandler struct {
|
||||
client *artifacts.Client
|
||||
namespace string
|
||||
}
|
||||
|
||||
// NewArtifactsHandler reads CF_ARTIFACTS_API_TOKEN and CF_ARTIFACTS_NAMESPACE
|
||||
// from the environment and builds the client. If either is absent the handler
|
||||
// still registers — every method simply returns 503.
|
||||
func NewArtifactsHandler() *ArtifactsHandler {
|
||||
token := os.Getenv("CF_ARTIFACTS_API_TOKEN")
|
||||
ns := os.Getenv("CF_ARTIFACTS_NAMESPACE")
|
||||
if token == "" || ns == "" {
|
||||
log.Printf("artifacts: CF_ARTIFACTS_API_TOKEN or CF_ARTIFACTS_NAMESPACE not set — demo endpoints will return 503")
|
||||
return &ArtifactsHandler{}
|
||||
}
|
||||
return &ArtifactsHandler{
|
||||
client: artifacts.New(token, ns),
|
||||
namespace: ns,
|
||||
}
|
||||
}
|
||||
|
||||
// newArtifactsHandlerWithClient is the injectable constructor used in tests.
|
||||
func newArtifactsHandlerWithClient(client *artifacts.Client, namespace string) *ArtifactsHandler {
|
||||
return &ArtifactsHandler{client: client, namespace: namespace}
|
||||
}
|
||||
|
||||
// configured returns false (and writes a 503) when the CF client is missing.
|
||||
func (h *ArtifactsHandler) configured(c *gin.Context) bool {
|
||||
if h.client == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Cloudflare Artifacts not configured — set CF_ARTIFACTS_API_TOKEN and CF_ARTIFACTS_NAMESPACE",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- POST /workspaces/:id/artifacts ------------------------------------
|
||||
|
||||
// createArtifactsRepoRequest is the body for attaching/creating a CF Artifacts repo.
|
||||
type createArtifactsRepoRequest struct {
|
||||
// Name is the desired CF repo name. Defaults to "molecule-ws-<workspace_id>" when empty.
|
||||
Name string `json:"name"`
|
||||
// Description is an optional label stored in CF and in the local DB.
|
||||
Description string `json:"description"`
|
||||
// ImportURL, when non-empty, bootstraps the repo from an existing Git URL
|
||||
// (e.g. "https://github.com/org/repo.git") instead of creating an empty repo.
|
||||
ImportURL string `json:"import_url"`
|
||||
// ImportBranch restricts the import to a single branch (only used with ImportURL).
|
||||
ImportBranch string `json:"import_branch"`
|
||||
// ImportDepth sets a shallow-clone depth for the import (0 = full history).
|
||||
ImportDepth int `json:"import_depth"`
|
||||
// ReadOnly marks the new repo as fetch/clone-only.
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
// workspaceArtifactRow is the DB row shape returned by queries.
|
||||
type workspaceArtifactRow struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
CFRepoName string `json:"cf_repo_name"`
|
||||
CFNamespace string `json:"cf_namespace"`
|
||||
RemoteURL string `json:"remote_url,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Create handles POST /workspaces/:id/artifacts.
|
||||
// Creates or imports a Cloudflare Artifacts repo and links it to the workspace.
|
||||
// Returns 409 if a repo is already linked.
|
||||
func (h *ArtifactsHandler) Create(c *gin.Context) {
|
||||
if !h.configured(c) {
|
||||
return
|
||||
}
|
||||
workspaceID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Reject if already linked.
|
||||
var exists bool
|
||||
db.DB.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM workspace_artifacts WHERE workspace_id = $1)`,
|
||||
workspaceID,
|
||||
).Scan(&exists)
|
||||
if exists {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "workspace already has a linked Artifacts repo — delete it first"})
|
||||
return
|
||||
}
|
||||
|
||||
var req createArtifactsRepoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Default repo name: "molecule-ws-<workspace_id>" (truncated at 63 chars).
|
||||
repoName := req.Name
|
||||
if repoName == "" {
|
||||
repoName = "molecule-ws-" + workspaceID
|
||||
if len(repoName) > 63 {
|
||||
repoName = repoName[:63]
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
repo *artifacts.Repo
|
||||
err error
|
||||
)
|
||||
if req.ImportURL != "" {
|
||||
repo, err = h.client.ImportRepo(ctx, repoName, artifacts.ImportRepoRequest{
|
||||
URL: req.ImportURL,
|
||||
Branch: req.ImportBranch,
|
||||
Depth: req.ImportDepth,
|
||||
ReadOnly: req.ReadOnly,
|
||||
})
|
||||
} else {
|
||||
repo, err = h.client.CreateRepo(ctx, artifacts.CreateRepoRequest{
|
||||
Name: repoName,
|
||||
Description: req.Description,
|
||||
ReadOnly: req.ReadOnly,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("artifacts: CreateRepo/ImportRepo failed for workspace %s: %v", workspaceID, err)
|
||||
c.JSON(cfErrToHTTP(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Strip the embedded credential from the URL before persisting.
|
||||
remoteURL := stripCredentials(repo.RemoteURL)
|
||||
|
||||
var row workspaceArtifactRow
|
||||
err = db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workspace_artifacts
|
||||
(workspace_id, cf_repo_name, cf_namespace, remote_url, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, workspace_id, cf_repo_name, cf_namespace, remote_url, description, created_at, updated_at
|
||||
`, workspaceID, repo.Name, h.namespace, remoteURL, req.Description).Scan(
|
||||
&row.ID, &row.WorkspaceID, &row.CFRepoName, &row.CFNamespace,
|
||||
&row.RemoteURL, &row.Description, &row.CreatedAt, &row.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("artifacts: DB insert failed for workspace %s: %v", workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to persist artifact link"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, row)
|
||||
}
|
||||
|
||||
// ---- GET /workspaces/:id/artifacts -------------------------------------
|
||||
|
||||
// Get handles GET /workspaces/:id/artifacts.
|
||||
// Returns the linked Cloudflare Artifacts repo info from local DB and CF API.
|
||||
func (h *ArtifactsHandler) Get(c *gin.Context) {
|
||||
if !h.configured(c) {
|
||||
return
|
||||
}
|
||||
workspaceID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var row workspaceArtifactRow
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT id, workspace_id, cf_repo_name, cf_namespace, remote_url, description, created_at, updated_at
|
||||
FROM workspace_artifacts
|
||||
WHERE workspace_id = $1
|
||||
`, workspaceID).Scan(
|
||||
&row.ID, &row.WorkspaceID, &row.CFRepoName, &row.CFNamespace,
|
||||
&row.RemoteURL, &row.Description, &row.CreatedAt, &row.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no Artifacts repo linked to this workspace"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("artifacts: DB query failed for workspace %s: %v", workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Augment with live info from CF API (remote URL may have changed, etc.).
|
||||
cfRepo, err := h.client.GetRepo(ctx, row.CFRepoName)
|
||||
if err != nil {
|
||||
// CF API unavailable — return cached DB row with a warning.
|
||||
log.Printf("artifacts: GetRepo from CF failed for %s: %v", row.CFRepoName, err)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"artifact": row,
|
||||
"cf_status": "unavailable",
|
||||
"cf_error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"artifact": row,
|
||||
"cf_repo": cfRepo,
|
||||
"cf_status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
// ---- POST /workspaces/:id/artifacts/fork -------------------------------
|
||||
|
||||
// forkArtifactsRepoRequest is the body for forking a workspace's repo.
|
||||
type forkArtifactsRepoRequest struct {
|
||||
// Name is the desired name of the forked repo. Required.
|
||||
Name string `json:"name" binding:"required"`
|
||||
// Description is an optional label for the fork.
|
||||
Description string `json:"description"`
|
||||
// ReadOnly marks the fork as fetch/clone-only.
|
||||
ReadOnly bool `json:"read_only"`
|
||||
// DefaultBranchOnly limits the fork to the default branch.
|
||||
DefaultBranchOnly bool `json:"default_branch_only"`
|
||||
}
|
||||
|
||||
// Fork handles POST /workspaces/:id/artifacts/fork.
|
||||
// Creates an isolated copy of the workspace's primary Artifacts repo in CF.
|
||||
// The fork is not recorded in the local DB — it is owned by the caller.
|
||||
func (h *ArtifactsHandler) Fork(c *gin.Context) {
|
||||
if !h.configured(c) {
|
||||
return
|
||||
}
|
||||
workspaceID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Look up the source repo name.
|
||||
var cfRepoName string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&cfRepoName)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no Artifacts repo linked to this workspace"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req forkArtifactsRepoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.client.ForkRepo(ctx, cfRepoName, artifacts.ForkRepoRequest{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
ReadOnly: req.ReadOnly,
|
||||
DefaultBranchOnly: req.DefaultBranchOnly,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("artifacts: ForkRepo failed for workspace %s: %v", workspaceID, err)
|
||||
c.JSON(cfErrToHTTP(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"fork": result.Repo,
|
||||
"object_count": result.ObjectCount,
|
||||
"remote_url": stripCredentials(result.Repo.RemoteURL),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- POST /workspaces/:id/artifacts/token ------------------------------
|
||||
|
||||
// artifactsTokenRequest is the body for minting a git credential.
|
||||
type artifactsTokenRequest struct {
|
||||
// Scope is "read" or "write". Defaults to "write".
|
||||
Scope string `json:"scope"`
|
||||
// TTL is the credential lifetime in seconds. Defaults to 3600 (1h).
|
||||
TTL int `json:"ttl"`
|
||||
}
|
||||
|
||||
// Token handles POST /workspaces/:id/artifacts/token.
|
||||
// Returns a short-lived Git credential for the workspace's linked repo.
|
||||
// The plaintext token value must be saved by the caller — it is not stored.
|
||||
func (h *ArtifactsHandler) Token(c *gin.Context) {
|
||||
if !h.configured(c) {
|
||||
return
|
||||
}
|
||||
workspaceID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Look up the linked CF repo name.
|
||||
var cfRepoName string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&cfRepoName)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no Artifacts repo linked to this workspace"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req artifactsTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
scope := req.Scope
|
||||
if scope == "" {
|
||||
scope = "write"
|
||||
}
|
||||
if scope != "read" && scope != "write" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be \"read\" or \"write\""})
|
||||
return
|
||||
}
|
||||
ttl := req.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = 3600
|
||||
}
|
||||
const maxTTL = 86400 * 7 // 7 days
|
||||
if ttl > maxTTL {
|
||||
ttl = maxTTL
|
||||
}
|
||||
|
||||
tok, err := h.client.CreateToken(ctx, artifacts.CreateTokenRequest{
|
||||
Repo: cfRepoName,
|
||||
Scope: scope,
|
||||
TTL: ttl,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("artifacts: CreateToken failed for workspace %s: %v", workspaceID, err)
|
||||
c.JSON(cfErrToHTTP(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build the authenticated git remote URL inline so callers can use it
|
||||
// directly: git clone <clone_url>
|
||||
cloneURL := buildCloneURL(cfRepoName, tok.Token, h.namespace)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"token_id": tok.ID,
|
||||
"token": tok.Token,
|
||||
"scope": tok.Scope,
|
||||
"expires_at": tok.ExpiresAt,
|
||||
"clone_url": cloneURL,
|
||||
"message": "Save this token — it cannot be retrieved again.",
|
||||
})
|
||||
}
|
||||
|
||||
// ---- helpers -------------------------------------------------------------
|
||||
|
||||
// cfErrToHTTP converts a CF API error to an appropriate HTTP status code.
|
||||
// Passes through 4xx, maps everything else to 502 (bad gateway — upstream CF).
|
||||
func cfErrToHTTP(err error) int {
|
||||
apiErr, ok := err.(*artifacts.APIError)
|
||||
if !ok {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
|
||||
return apiErr.StatusCode
|
||||
}
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
|
||||
// stripCredentials removes "x:<token>@" from an authenticated git remote URL
|
||||
// so we never persist credentials in the database.
|
||||
// e.g. "https://x:tok@hash.artifacts.cloudflare.net/…" → "https://hash.artifacts.cloudflare.net/…"
|
||||
func stripCredentials(remoteURL string) string {
|
||||
if i := strings.Index(remoteURL, "@"); i != -1 {
|
||||
scheme := "https://"
|
||||
if strings.HasPrefix(remoteURL, "http://") {
|
||||
scheme = "http://"
|
||||
}
|
||||
return scheme + remoteURL[i+1:]
|
||||
}
|
||||
return remoteURL
|
||||
}
|
||||
|
||||
// buildCloneURL constructs an authenticated clone URL from the CF token.
|
||||
// Format: https://x:<token>@<hash>.artifacts.cloudflare.net/git/repo-<name>.git
|
||||
// When we only have the repo name (not the full hashed host), we use a stable
|
||||
// URL pattern that the CF git endpoint resolves.
|
||||
func buildCloneURL(repoName, token, _ string) string {
|
||||
// The CF git endpoint is the remote_url stored in the DB (minus the
|
||||
// credential prefix). We reconstruct the authenticated form here.
|
||||
// In production the remote URL is returned by CreateRepo/GetRepo;
|
||||
// this fallback covers cases where the DB row predates that field.
|
||||
return "https://x:" + token + "@artifacts.cloudflare.net/git/" + repoName + ".git"
|
||||
}
|
||||
827
platform/internal/handlers/artifacts_test.go
Normal file
827
platform/internal/handlers/artifacts_test.go
Normal file
@ -0,0 +1,827 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/artifacts"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// cfSuccessResponse wraps a result in the Cloudflare v4 success envelope.
|
||||
func cfSuccessResponse(t *testing.T, result interface{}) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("cfSuccessResponse: marshal result: %v", err)
|
||||
}
|
||||
env := map[string]interface{}{
|
||||
"success": true,
|
||||
"result": json.RawMessage(b),
|
||||
"errors": []interface{}{},
|
||||
}
|
||||
out, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatalf("cfSuccessResponse: marshal envelope: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cfErrorResponse returns a Cloudflare v4 error envelope bytes and status code.
|
||||
func cfErrorResponse(t *testing.T, statusCode, code int, message string) ([]byte, int) {
|
||||
t.Helper()
|
||||
env := map[string]interface{}{
|
||||
"success": false,
|
||||
"result": nil,
|
||||
"errors": []map[string]interface{}{
|
||||
{"code": code, "message": message},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(env)
|
||||
return b, statusCode
|
||||
}
|
||||
|
||||
// newArtifactsMockServer starts an httptest.Server with the given handler function
|
||||
// registered at /namespaces/test-ns/<suffix>.
|
||||
func newArtifactsMockCFServer(t *testing.T, suffix string, handler http.HandlerFunc) *artifacts.Client {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/namespaces/test-ns"+suffix, handler)
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return artifacts.NewWithBaseURL("cf-test-token", "test-ns", srv.URL)
|
||||
}
|
||||
|
||||
// ============================= Create =====================================
|
||||
|
||||
// TestArtifactsCreate_Success verifies the happy path: no existing link →
|
||||
// CF API returns a repo → DB INSERT succeeds → 201 response.
|
||||
func TestArtifactsCreate_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
cfClient := newArtifactsMockCFServer(t, "/repos", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "wrong method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
repo := artifacts.Repo{
|
||||
Name: "molecule-ws-ws-abc",
|
||||
ID: "repo-001",
|
||||
RemoteURL: "https://x:tok123@hash.artifacts.cloudflare.net/git/repo-001.git",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(cfSuccessResponse(t, repo))
|
||||
})
|
||||
|
||||
// Existence probe — no existing link
|
||||
mock.ExpectQuery(`SELECT EXISTS`).
|
||||
WithArgs("ws-abc").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
|
||||
// DB INSERT — RETURNING row
|
||||
now := time.Now()
|
||||
mock.ExpectQuery(`INSERT INTO workspace_artifacts`).
|
||||
WithArgs("ws-abc", "molecule-ws-ws-abc", "test-ns",
|
||||
"https://hash.artifacts.cloudflare.net/git/repo-001.git", "").
|
||||
WillReturnRows(sqlmock.NewRows(
|
||||
[]string{"id", "workspace_id", "cf_repo_name", "cf_namespace", "remote_url", "description", "created_at", "updated_at"}).
|
||||
AddRow("art-1", "ws-abc", "molecule-ws-ws-abc", "test-ns",
|
||||
"https://hash.artifacts.cloudflare.net/git/repo-001.git", "", now, now))
|
||||
|
||||
h := newArtifactsHandlerWithClient(cfClient, "test-ns")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-abc"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-abc/artifacts",
|
||||
bytes.NewBufferString(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["cf_repo_name"] != "molecule-ws-ws-abc" {
|
||||
t.Errorf("cf_repo_name = %v, want molecule-ws-ws-abc", resp["cf_repo_name"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtifactsCreate_AlreadyLinked verifies that a 409 is returned when the
|
||||
// workspace already has a linked Artifacts repo.
|
||||
func TestArtifactsCreate_AlreadyLinked(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
// Existence probe returns true
|
||||
mock.ExpectQuery(`SELECT EXISTS`).
|
||||
WithArgs("ws-dup").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
|
||||
h := newArtifactsHandlerWithClient(
|
||||
artifacts.NewWithBaseURL("tok", "ns", "http://unused"),
|
||||
"ns",
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-dup"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-dup/artifacts",
|
||||
bytes.NewBufferString(`{"name":"dup-repo"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("expected 409, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtifactsCreate_CFAPIError verifies that a CF API error (e.g. 409 conflict)
|
||||
// is forwarded with the appropriate HTTP status.
|
||||
func TestArtifactsCreate_CFAPIError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
cfClient := newArtifactsMockCFServer(t, "/repos", func(w http.ResponseWriter, r *http.Request) {
|
||||
body, status := cfErrorResponse(t, http.StatusConflict, 1009, "repo name already taken")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
w.Write(body)
|
||||
})
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS`).
|
||||
WithArgs("ws-cfconflict").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
|
||||
h := newArtifactsHandlerWithClient(cfClient, "test-ns")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-cfconflict"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-cfconflict/artifacts",
|
||||
bytes.NewBufferString(`{"name":"taken-name"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("expected 409 from CF error, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtifactsCreate_WithImportURL verifies that when import_url is set the
|
||||
// handler hits the /import endpoint instead of plain /repos.
|
||||
func TestArtifactsCreate_WithImportURL(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
// Two paths: /repos/imported-repo/import
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/namespaces/test-ns/repos/imported-repo/import", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "wrong method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["url"] != "https://github.com/Molecule-AI/molecule-core.git" {
|
||||
http.Error(w, "unexpected url", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
repo := artifacts.Repo{
|
||||
Name: "imported-repo",
|
||||
RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/imported.git",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(cfSuccessResponse(t, repo))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
cfClient := artifacts.NewWithBaseURL("tok", "test-ns", srv.URL)
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS`).
|
||||
WithArgs("ws-import").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
|
||||
now := time.Now()
|
||||
mock.ExpectQuery(`INSERT INTO workspace_artifacts`).
|
||||
WithArgs("ws-import", "imported-repo", "test-ns",
|
||||
"https://hash.artifacts.cloudflare.net/git/imported.git", "Imported from GitHub").
|
||||
WillReturnRows(sqlmock.NewRows(
|
||||
[]string{"id", "workspace_id", "cf_repo_name", "cf_namespace", "remote_url", "description", "created_at", "updated_at"}).
|
||||
AddRow("art-imp", "ws-import", "imported-repo", "test-ns",
|
||||
"https://hash.artifacts.cloudflare.net/git/imported.git", "Imported from GitHub", now, now))
|
||||
|
||||
h := newArtifactsHandlerWithClient(cfClient, "test-ns")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-import"}}
|
||||
body := `{"name":"imported-repo","description":"Imported from GitHub","import_url":"https://github.com/Molecule-AI/molecule-core.git","import_branch":"main","import_depth":1}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-import/artifacts",
|
||||
bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtifactsCreate_NotConfigured verifies that missing env vars → 503.
|
||||
func TestArtifactsCreate_NotConfigured(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
// No CF client → nil
|
||||
h := newArtifactsHandlerWithClient(nil, "")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-uncfg"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-uncfg/artifacts",
|
||||
bytes.NewBufferString(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================= Get =======================================
|
||||
|
||||
// TestArtifactsGet_Success verifies the happy path: DB row found + CF API ok.
|
||||
func TestArtifactsGet_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
cfClient := newArtifactsMockCFServer(t, "/repos/my-ws-repo", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "wrong method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
repo := artifacts.Repo{
|
||||
Name: "my-ws-repo",
|
||||
RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/my-ws-repo.git",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(cfSuccessResponse(t, repo))
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
mock.ExpectQuery(`SELECT id, workspace_id, cf_repo_name`).
|
||||
WithArgs("ws-get").
|
||||
WillReturnRows(sqlmock.NewRows(
|
||||
[]string{"id", "workspace_id", "cf_repo_name", "cf_namespace", "remote_url", "description", "created_at", "updated_at"}).
|
||||
AddRow("art-get", "ws-get", "my-ws-repo", "test-ns",
|
||||
"https://hash.artifacts.cloudflare.net/git/my-ws-repo.git", "", now, now))
|
||||
|
||||
h := newArtifactsHandlerWithClient(cfClient, "test-ns")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-get"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-get/artifacts", nil)
|
||||
|
||||
h.Get(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["cf_status"] != "ok" {
|
||||
t.Errorf("cf_status = %v, want ok", resp["cf_status"])
|
||||
}
|
||||
art, ok := resp["artifact"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("artifact is not an object")
|
||||
}
|
||||
if art["cf_repo_name"] != "my-ws-repo" {
|
||||
t.Errorf("artifact.cf_repo_name = %v, want my-ws-repo", art["cf_repo_name"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtifactsGet_NotFound verifies that 404 is returned when no row exists.
|
||||
func TestArtifactsGet_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT id, workspace_id, cf_repo_name`).
|
||||
WithArgs("ws-noart").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
h := newArtifactsHandlerWithClient(
|
||||
artifacts.NewWithBaseURL("tok", "ns", "http://unused"),
|
||||
"ns",
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-noart"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-noart/artifacts", nil)
|
||||
|
||||
h.Get(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtifactsGet_CFUnavailable verifies that when CF API fails the handler
|
||||
// still returns 200 with the cached DB row and cf_status="unavailable".
|
||||
func TestArtifactsGet_CFUnavailable(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
// CF API server that always returns 500
|
||||
cfClient := newArtifactsMockCFServer(t, "/repos/cached-repo", func(w http.ResponseWriter, r *http.Request) {
|
||||
body, status := cfErrorResponse(t, http.StatusInternalServerError, 0, "service unavailable")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
w.Write(body)
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
mock.ExpectQuery(`SELECT id, workspace_id, cf_repo_name`).
|
||||
WithArgs("ws-cfdown").
|
||||
WillReturnRows(sqlmock.NewRows(
|
||||
[]string{"id", "workspace_id", "cf_repo_name", "cf_namespace", "remote_url", "description", "created_at", "updated_at"}).
|
||||
AddRow("art-cfdown", "ws-cfdown", "cached-repo", "test-ns",
|
||||
"https://hash.artifacts.cloudflare.net/git/cached-repo.git", "", now, now))
|
||||
|
||||
h := newArtifactsHandlerWithClient(cfClient, "test-ns")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-cfdown"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-cfdown/artifacts", nil)
|
||||
|
||||
h.Get(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 (degraded), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["cf_status"] != "unavailable" {
|
||||
t.Errorf("cf_status = %v, want unavailable", resp["cf_status"])
|
||||
}
|
||||
if resp["artifact"] == nil {
|
||||
t.Error("artifact should still be present from DB cache")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================= Fork ======================================
|
||||
|
||||
// TestArtifactsFork_Success verifies the fork happy path.
|
||||
func TestArtifactsFork_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
cfClient := newArtifactsMockCFServer(t, "/repos/source-repo/fork", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "wrong method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
result := artifacts.ForkResult{
|
||||
Repo: artifacts.Repo{
|
||||
Name: "forked-ws",
|
||||
ID: "fork-1",
|
||||
RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/fork-1.git",
|
||||
},
|
||||
ObjectCount: 88,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(cfSuccessResponse(t, result))
|
||||
})
|
||||
|
||||
mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`).
|
||||
WithArgs("ws-fork-src").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"cf_repo_name"}).AddRow("source-repo"))
|
||||
|
||||
h := newArtifactsHandlerWithClient(cfClient, "test-ns")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-fork-src"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-fork-src/artifacts/fork",
|
||||
bytes.NewBufferString(`{"name":"forked-ws"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Fork(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["object_count"] != float64(88) {
|
||||
t.Errorf("object_count = %v, want 88", resp["object_count"])
|
||||
}
|
||||
fork, ok := resp["fork"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("fork is not an object")
|
||||
}
|
||||
if fork["name"] != "forked-ws" {
|
||||
t.Errorf("fork.name = %v, want forked-ws", fork["name"])
|
||||
}
|
||||
// Embedded credentials must be stripped from clone_url
|
||||
if remote := resp["remote_url"].(string); len(remote) > 0 {
|
||||
if containsCredentials(remote) {
|
||||
t.Errorf("remote_url should not contain credentials: %s", remote)
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtifactsFork_NoLinkedRepo verifies 404 when workspace has no linked repo.
|
||||
func TestArtifactsFork_NoLinkedRepo(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`).
|
||||
WithArgs("ws-norepo").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
h := newArtifactsHandlerWithClient(
|
||||
artifacts.NewWithBaseURL("tok", "ns", "http://unused"),
|
||||
"ns",
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-norepo"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-norepo/artifacts/fork",
|
||||
bytes.NewBufferString(`{"name":"fork-name"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Fork(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtifactsFork_MissingName verifies 400 when the fork name is missing.
|
||||
func TestArtifactsFork_MissingName(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`).
|
||||
WithArgs("ws-fork-badname").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"cf_repo_name"}).AddRow("src-repo"))
|
||||
|
||||
h := newArtifactsHandlerWithClient(
|
||||
artifacts.NewWithBaseURL("tok", "test-ns", "http://unused"),
|
||||
"test-ns",
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-fork-badname"}}
|
||||
// name is required (binding:"required") but absent → 400
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-fork-badname/artifacts/fork",
|
||||
bytes.NewBufferString(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Fork(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for missing name, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================= Token =====================================
|
||||
|
||||
// TestArtifactsToken_Success verifies the happy path: linked repo → CF returns token.
|
||||
func TestArtifactsToken_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
expiry := time.Now().Add(3600 * time.Second).UTC()
|
||||
cfClient := newArtifactsMockCFServer(t, "/tokens", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "wrong method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req["repo"] != "my-linked-repo" {
|
||||
http.Error(w, "unexpected repo", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tok := artifacts.RepoToken{
|
||||
ID: "token-abc",
|
||||
Token: "plaintext-git-token",
|
||||
Scope: "write",
|
||||
ExpiresAt: expiry,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(cfSuccessResponse(t, tok))
|
||||
})
|
||||
|
||||
mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`).
|
||||
WithArgs("ws-token").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"cf_repo_name"}).AddRow("my-linked-repo"))
|
||||
|
||||
h := newArtifactsHandlerWithClient(cfClient, "test-ns")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-token"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-token/artifacts/token",
|
||||
bytes.NewBufferString(`{"scope":"write","ttl":3600}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Token(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["token_id"] != "token-abc" {
|
||||
t.Errorf("token_id = %v, want token-abc", resp["token_id"])
|
||||
}
|
||||
if resp["token"] != "plaintext-git-token" {
|
||||
t.Errorf("token = %v, want plaintext-git-token", resp["token"])
|
||||
}
|
||||
if resp["clone_url"] == nil || resp["clone_url"] == "" {
|
||||
t.Error("clone_url should be non-empty")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtifactsToken_DefaultsApplied verifies that empty scope/ttl are defaulted
|
||||
// to "write" / 3600.
|
||||
func TestArtifactsToken_DefaultsApplied(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
expiry := time.Now().Add(3600 * time.Second).UTC()
|
||||
cfClient := newArtifactsMockCFServer(t, "/tokens", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
// scope should be "write" (default)
|
||||
if req["scope"] != "write" {
|
||||
http.Error(w, "expected default scope write", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// ttl should be 3600 (default), serialized as float64 from JSON
|
||||
if req["ttl"] != float64(3600) {
|
||||
http.Error(w, "expected default ttl 3600", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tok := artifacts.RepoToken{ID: "t1", Token: "tok-def", Scope: "write", ExpiresAt: expiry}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(cfSuccessResponse(t, tok))
|
||||
})
|
||||
|
||||
mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`).
|
||||
WithArgs("ws-defaults").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"cf_repo_name"}).AddRow("my-repo"))
|
||||
|
||||
h := newArtifactsHandlerWithClient(cfClient, "test-ns")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-defaults"}}
|
||||
// Empty body — all defaults
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-defaults/artifacts/token",
|
||||
bytes.NewBufferString(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Token(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtifactsToken_InvalidScope verifies that an invalid scope returns 400.
|
||||
func TestArtifactsToken_InvalidScope(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`).
|
||||
WithArgs("ws-badscope").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"cf_repo_name"}).AddRow("some-repo"))
|
||||
|
||||
h := newArtifactsHandlerWithClient(
|
||||
artifacts.NewWithBaseURL("tok", "ns", "http://unused"),
|
||||
"ns",
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-badscope"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-badscope/artifacts/token",
|
||||
bytes.NewBufferString(`{"scope":"admin"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Token(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid scope, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtifactsToken_TTLCapped verifies that excessive TTL is silently capped
|
||||
// to 7 days (604800 seconds) rather than returning an error.
|
||||
func TestArtifactsToken_TTLCapped(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
const maxTTL = 86400 * 7
|
||||
|
||||
expiry := time.Now().Add(maxTTL * time.Second).UTC()
|
||||
cfClient := newArtifactsMockCFServer(t, "/tokens", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if int(req["ttl"].(float64)) != maxTTL {
|
||||
http.Error(w, "expected capped ttl", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tok := artifacts.RepoToken{ID: "t-cap", Token: "capped-tok", Scope: "write", ExpiresAt: expiry}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(cfSuccessResponse(t, tok))
|
||||
})
|
||||
|
||||
mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`).
|
||||
WithArgs("ws-ttlcap").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"cf_repo_name"}).AddRow("capped-repo"))
|
||||
|
||||
h := newArtifactsHandlerWithClient(cfClient, "test-ns")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-ttlcap"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-ttlcap/artifacts/token",
|
||||
bytes.NewBufferString(`{"scope":"write","ttl":99999999}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Token(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtifactsToken_NoLinkedRepo verifies 404 when no repo is linked.
|
||||
func TestArtifactsToken_NoLinkedRepo(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`).
|
||||
WithArgs("ws-tokennolink").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
h := newArtifactsHandlerWithClient(
|
||||
artifacts.NewWithBaseURL("tok", "ns", "http://unused"),
|
||||
"ns",
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-tokennolink"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-tokennolink/artifacts/token",
|
||||
bytes.NewBufferString(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Token(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================= helper unit tests =========================
|
||||
|
||||
// TestStripCredentials verifies that stripCredentials removes user:token@ from URLs.
|
||||
func TestStripCredentials(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"https://x:tok123@hash.artifacts.cloudflare.net/git/repo.git",
|
||||
"https://hash.artifacts.cloudflare.net/git/repo.git",
|
||||
},
|
||||
{
|
||||
"https://hash.artifacts.cloudflare.net/git/repo.git",
|
||||
"https://hash.artifacts.cloudflare.net/git/repo.git",
|
||||
},
|
||||
{
|
||||
"http://user:pass@example.com/repo.git",
|
||||
"http://example.com/repo.git",
|
||||
},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := stripCredentials(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("stripCredentials(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCfErrToHTTP verifies the CF-error-to-HTTP-status mapping.
|
||||
func TestCfErrToHTTP(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{&artifacts.APIError{StatusCode: http.StatusConflict}, http.StatusConflict},
|
||||
{&artifacts.APIError{StatusCode: http.StatusNotFound}, http.StatusNotFound},
|
||||
{&artifacts.APIError{StatusCode: http.StatusBadRequest}, http.StatusBadRequest},
|
||||
{&artifacts.APIError{StatusCode: http.StatusInternalServerError}, http.StatusBadGateway},
|
||||
{&artifacts.APIError{StatusCode: http.StatusBadGateway}, http.StatusBadGateway},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := cfErrToHTTP(tc.err)
|
||||
if got != tc.want {
|
||||
t.Errorf("cfErrToHTTP(%v) = %d, want %d", tc.err, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// containsCredentials is a test helper that checks whether a URL has embedded
|
||||
// user:password@ credentials (should never appear in a stored remote URL).
|
||||
func containsCredentials(u string) bool {
|
||||
// A URL with embedded creds has the form scheme://user:pass@host/...
|
||||
// We check for "@" after the scheme to detect this.
|
||||
for i := 0; i < len(u)-3; i++ {
|
||||
if u[i] == ':' && i > 0 && u[i-1] != '/' {
|
||||
// Found ":" that is not ":/" — could be user:pass pair
|
||||
if j := len(u); j > i {
|
||||
for k := i + 1; k < j; k++ {
|
||||
if u[k] == '@' {
|
||||
return true
|
||||
}
|
||||
if u[k] == '/' {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -292,6 +292,17 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
// WorkspaceAuth middleware (on wsAuth) binds the bearer to :id.
|
||||
mtrh := handlers.NewMetricsHandler()
|
||||
wsAuth.GET("/metrics", mtrh.GetMetrics)
|
||||
|
||||
// Cloudflare Artifacts demo integration (#595).
|
||||
// All four routes require workspace-scoped bearer auth (wsAuth).
|
||||
// CF credentials read from CF_ARTIFACTS_API_TOKEN / CF_ARTIFACTS_NAMESPACE;
|
||||
// missing credentials return 503 so the handler still registers in
|
||||
// every deployment — the demo is gated on env vars, not compilation.
|
||||
arth := handlers.NewArtifactsHandler()
|
||||
wsAuth.POST("/artifacts", arth.Create)
|
||||
wsAuth.GET("/artifacts", arth.Get)
|
||||
wsAuth.POST("/artifacts/fork", arth.Fork)
|
||||
wsAuth.POST("/artifacts/token", arth.Token)
|
||||
}
|
||||
|
||||
// Global secrets — /settings/secrets is the canonical path; /admin/secrets kept for backward compat.
|
||||
|
||||
2
platform/migrations/028_workspace_artifacts.down.sql
Normal file
2
platform/migrations/028_workspace_artifacts.down.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- Reverse of 028_workspace_artifacts.up.sql
|
||||
DROP TABLE IF EXISTS workspace_artifacts;
|
||||
31
platform/migrations/028_workspace_artifacts.up.sql
Normal file
31
platform/migrations/028_workspace_artifacts.up.sql
Normal file
@ -0,0 +1,31 @@
|
||||
-- 028_workspace_artifacts: store Cloudflare Artifacts repo linkage per workspace.
|
||||
--
|
||||
-- Each workspace can be linked to exactly one Cloudflare Artifacts repo
|
||||
-- (the primary snapshot store). Additional repos (forks) are ephemeral and
|
||||
-- tracked only via the CF API — not in this table.
|
||||
--
|
||||
-- Remote URLs are stored for informational display only; callers must
|
||||
-- call POST /workspaces/:id/artifacts/token to obtain a fresh git credential.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_artifacts (
|
||||
id TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
cf_repo_name TEXT NOT NULL,
|
||||
cf_namespace TEXT NOT NULL,
|
||||
-- remote_url is the base Git remote (without embedded credentials).
|
||||
-- Credentials are obtained on-demand via POST /tokens.
|
||||
remote_url TEXT,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT workspace_artifacts_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
-- Each workspace may have at most one linked CF Artifacts repo.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_workspace_artifacts_workspace_id
|
||||
ON workspace_artifacts (workspace_id);
|
||||
|
||||
-- Allow fast lookup by CF repo name within a namespace.
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_artifacts_cf_repo
|
||||
ON workspace_artifacts (cf_namespace, cf_repo_name);
|
||||
Loading…
Reference in New Issue
Block a user