Four findings from the security audit on PR #641: FIX 1 (MEDIUM): import_url scheme validation - Reject non-HTTPS import URLs with 400 before forwarding to CF API. Prevents SSRF via http://, git://, ssh://, file:// etc. FIX 2 (MEDIUM): CF 5xx error leakage - Add cfErrMessage() helper: returns "upstream service error" for CF 5xx responses and non-CF errors, passes through 4xx messages. - Applied at all four CF-error response sites (Create, Get, Fork, Token). FIX 3 (LOW): repo name validation - Add package-level repoNameRE = ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$ - Validate in Create and Fork handlers when caller supplies an explicit name. Auto-generated names ("molecule-ws-<id>") are always safe and skip validation. FIX 4 (LOW): response body size limit in CF client - Wrap resp.Body with io.LimitReader(1 MB) before json.NewDecoder in do(). Prevents memory exhaustion from a runaway/malicious CF response. Tests: 16 new tests covering all four fixes (cfErrMessage 4xx/5xx/non-API, import_url non-HTTPS cases, invalid repo names in Create and Fork). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
996 lines
32 KiB
Go
996 lines
32 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================= Security fix tests ============================
|
|
|
|
// TestCfErrMessage_5xxReturnsGeneric verifies that CF 5xx errors return a
|
|
// generic message instead of leaking CF internals.
|
|
func TestCfErrMessage_5xxReturnsGeneric(t *testing.T) {
|
|
err := &artifacts.APIError{StatusCode: http.StatusInternalServerError, Message: "internal CF detail"}
|
|
got := cfErrMessage(err)
|
|
if got != "upstream service error" {
|
|
t.Errorf("cfErrMessage(500) = %q, want %q", got, "upstream service error")
|
|
}
|
|
}
|
|
|
|
// TestCfErrMessage_502ReturnsGeneric verifies that CF 502 (bad gateway) is also masked.
|
|
func TestCfErrMessage_502ReturnsGeneric(t *testing.T) {
|
|
err := &artifacts.APIError{StatusCode: http.StatusBadGateway, Message: "gateway detail"}
|
|
got := cfErrMessage(err)
|
|
if got != "upstream service error" {
|
|
t.Errorf("cfErrMessage(502) = %q, want %q", got, "upstream service error")
|
|
}
|
|
}
|
|
|
|
// TestCfErrMessage_4xxPassesThrough verifies that CF 4xx messages are surfaced.
|
|
func TestCfErrMessage_4xxPassesThrough(t *testing.T) {
|
|
msg := "repo name already taken"
|
|
err := &artifacts.APIError{StatusCode: http.StatusConflict, Message: msg}
|
|
got := cfErrMessage(err)
|
|
if got != msg {
|
|
t.Errorf("cfErrMessage(409) = %q, want %q", got, msg)
|
|
}
|
|
}
|
|
|
|
// TestCfErrMessage_NonAPIErrorReturnsGeneric verifies that non-CF errors return generic message.
|
|
func TestCfErrMessage_NonAPIErrorReturnsGeneric(t *testing.T) {
|
|
err := fmt.Errorf("some network error")
|
|
got := cfErrMessage(err)
|
|
if got != "upstream service error" {
|
|
t.Errorf("cfErrMessage(non-API) = %q, want %q", got, "upstream service error")
|
|
}
|
|
}
|
|
|
|
// TestArtifactsCreate_ImportURLNonHTTPS verifies that a non-HTTPS import_url
|
|
// is rejected with 400.
|
|
func TestArtifactsCreate_ImportURLNonHTTPS(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
|
|
mock.ExpectQuery(`SELECT EXISTS`).
|
|
WithArgs("ws-badurl").
|
|
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
|
|
|
h := newArtifactsHandlerWithClient(
|
|
artifacts.NewWithBaseURL("tok", "test-ns", "http://unused"),
|
|
"test-ns",
|
|
)
|
|
|
|
cases := []string{
|
|
"http://github.com/org/repo.git",
|
|
"git://github.com/org/repo.git",
|
|
"ssh://git@github.com/org/repo.git",
|
|
"file:///etc/passwd",
|
|
}
|
|
for _, url := range cases {
|
|
t.Run(url, func(t *testing.T) {
|
|
// Re-register the EXISTS probe expectation for each sub-test case.
|
|
mock.ExpectQuery(`SELECT EXISTS`).
|
|
WithArgs("ws-badurl").
|
|
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-badurl"}}
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"name": "my-repo",
|
|
"import_url": url,
|
|
})
|
|
c.Request = httptest.NewRequest("POST", "/workspaces/ws-badurl/artifacts",
|
|
bytes.NewBuffer(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
h.Create(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("import_url=%q: expected 400, got %d: %s", url, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestArtifactsCreate_InvalidRepoName verifies that invalid repo names return 400.
|
|
func TestArtifactsCreate_InvalidRepoName(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
|
|
h := newArtifactsHandlerWithClient(
|
|
artifacts.NewWithBaseURL("tok", "test-ns", "http://unused"),
|
|
"test-ns",
|
|
)
|
|
|
|
invalidNames := []string{
|
|
"-starts-with-dash",
|
|
"_starts-with-underscore",
|
|
"has spaces",
|
|
"has/slash",
|
|
"has.dot",
|
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // 64 chars
|
|
}
|
|
for _, name := range invalidNames {
|
|
t.Run(name, func(t *testing.T) {
|
|
mock.ExpectQuery(`SELECT EXISTS`).
|
|
WithArgs("ws-badname").
|
|
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-badname"}}
|
|
body, _ := json.Marshal(map[string]interface{}{"name": name})
|
|
c.Request = httptest.NewRequest("POST", "/workspaces/ws-badname/artifacts",
|
|
bytes.NewBuffer(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
h.Create(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("name=%q: expected 400, got %d: %s", name, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestArtifactsFork_InvalidRepoName verifies that invalid fork names return 400.
|
|
func TestArtifactsFork_InvalidRepoName(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
|
|
h := newArtifactsHandlerWithClient(
|
|
artifacts.NewWithBaseURL("tok", "test-ns", "http://unused"),
|
|
"test-ns",
|
|
)
|
|
|
|
invalidNames := []string{
|
|
"-bad-start",
|
|
"has spaces",
|
|
"../traversal",
|
|
}
|
|
for _, name := range invalidNames {
|
|
t.Run(name, func(t *testing.T) {
|
|
mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`).
|
|
WithArgs("ws-forknm").
|
|
WillReturnRows(sqlmock.NewRows([]string{"cf_repo_name"}).AddRow("src"))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-forknm"}}
|
|
body, _ := json.Marshal(map[string]interface{}{"name": name})
|
|
c.Request = httptest.NewRequest("POST", "/workspaces/ws-forknm/artifacts/fork",
|
|
bytes.NewBuffer(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
h.Fork(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("fork name=%q: expected 400, got %d: %s", name, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|