Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <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")
|
|
}
|
|
}
|