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>
371 lines
11 KiB
Go
371 lines
11 KiB
Go
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")
|
|
}
|
|
}
|