molecule-core/workspace-server/internal/artifacts/client_test.go
Hongming Wang 8059fee128 fix(tenant-guard): allowlist /registry/register + /registry/heartbeat (#1236)
* fix(security): call redactSecrets before seeding workspace memories (F1085)

seedInitialMemories() in workspace_provision.go was inserting template/config
memories directly into agent_memories without scrubbing credential patterns.
A workspace provisioned from a template containing API keys, tokens, or other
secrets would store them in plain text — the same class of issue as #838.

Fix: call redactSecrets(workspaceID, content) on the truncated memory content
before the INSERT. The truncation (maxMemoryContentLength = 100 KiB, CWE-400)
is preserved — redaction runs after truncation so the size limit still applies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(workspace_provision): add seedInitialMemories coverage for #1208

Cover the truncate-at-100k boundary (PR #1167, CWE-400) and the
redactSecrets call (F1085 / #1132), both identified as untested in #1208.

- TestSeedInitialMemories_TruncatesOversizedContent: boundary at exactly
  100k, 1 byte over, far over, and well under. Verifies INSERT receives
  exactly maxMemoryContentLength bytes.
- TestSeedInitialMemories_RedactsSecrets: verifies redactSecrets runs
  before INSERT, regression test for F1085.
- TestSeedInitialMemories_InvalidScopeSkipped: invalid scope is silently
  skipped, no INSERT called.
- TestSeedInitialMemories_EmptyMemoriesNil: nil slice is handled without
  DB calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(marketing): Discord adapter launch visual assets (#1209)

Squash-merge: Discord adapter launch visual assets (3 PNGs) + social copy. Acceptance: assets on staging.

* fix(ci): golangci-lint errcheck failures on staging

Suppress errcheck warnings for calls where the return value is safely
ignored:
  - resp.Body.Close() (artifacts/client.go): deferred cleanup — failure
    to close a response body is non-critical; the defer itself is what
    matters for connection reuse.
  - rows.Close() (bundle/exporter.go): deferred cleanup in a loop where
    rows.Err() already handles query errors.
  - filepath.Walk (bundle/exporter.go): top-level walk call; errors in
    sub-directory traversal are handled by the inner callback (which
    returns nil for err != nil).
  - broadcaster.RecordAndBroadcast (bundle/importer.go): fire-and-forget
    event broadcast; errors are logged internally by the broadcaster.
  - db.DB.ExecContext (bundle/importer.go): best-effort runtime column
    update; non-critical auxiliary data that the provisioner re-extracts
    if needed.

Fixes: #1143

* test(artifacts): suppress w.Write return values to satisfy errcheck

All httptest.ResponseWriter.Write calls in client_test.go now discard
the byte count and error return with _, _ = prefix. The Write method
is safe to discard in test handlers — httptest.ResponseWriter.Write
never returns an error for in-memory buffers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(CI): move changes job off self-hosted runner + add workflow concurrency

Cherry-pick from staging PR #1194 for main. Two changes to relieve
macOS arm64 runner saturation:

1. `changes` job: runs on ubuntu-latest instead of
   [self-hosted, macos, arm64]. This job does a plain `git diff`
   with zero macOS dependencies — moving it off the runner frees
   a slot immediately on every workflow trigger.

2. Add workflow-level concurrency:
   concurrency: group: ci-${{ github.ref }}; cancel-in-progress: true

   Prevents multiple stale in-flight CI runs from queuing on the
   same ref when new commits arrive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(security): call redactSecrets before seeding workspace memories (F1085) (#1203)

seedInitialMemories() in workspace_provision.go was inserting template/config
memories directly into agent_memories without scrubbing credential patterns.
A workspace provisioned from a template containing API keys, tokens, or other
secrets would store them in plain text — the same class of issue as #838.

Fix: call redactSecrets(workspaceID, content) on the truncated memory content
before the INSERT. The truncation (maxMemoryContentLength = 100 KiB, CWE-400)
is preserved — redaction runs after truncation so the size limit still applies.

Co-authored-by: Molecule AI Core-BE <core-be@agents.moleculesai.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* tick: 2026-04-21 ~03:40Z — CI stalled 59+ min, GH_TOKEN 4th rotation, PR reviews done

* fix(tenant-guard): allowlist /registry/register + /registry/heartbeat

Final layer of today's stuck-provisioning saga. With the private-IP
platform_url fix and the intra-VPC :8080 SG rule in place, workspace
EC2s finally reached the tenant on the right port — only to have every
POST bounced with a synthetic 404 by TenantGuard.

TenantGuard is the SaaS hook that rejects cross-tenant routing. It
demands X-Molecule-Org-Id on every request, but CP's workspace user-
data doesn't export MOLECULE_ORG_ID (only WORKSPACE_ID, PLATFORM_URL,
RUNTIME, PORT), so the runtime can't attach the header. Net effect:
every workspace's first heartbeat to /registry/heartbeat was a silent
404, and the workspace sat in 'provisioning' until the platform
sweeper timed it out.

Allowlist the two workspace-boot paths:
  - /registry/register  — one-shot at runtime startup
  - /registry/heartbeat — every 30s

Both are still gated by wsauth.HasAnyLiveToken (workspaces with a
token on file must present it; legacy tokenless workspaces are
grandfathered). And the tenant SG already scopes :8080 to the VPC
CIDR, so only intra-VPC callers can reach these paths in the first
place. The allowlist bypasses cross-org routing, not auth.

Follow-up: passing MOLECULE_ORG_ID into the workspace env would let
the runtime attach the header and drop this allowlist entry. Tracked
separately; not urgent since the multi-layer auth above is already
adequate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Molecule AI Core-BE <core-be@agents.moleculesai.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Molecule AI Infra-SRE <infra-sre@agents.moleculesai.app>
Co-authored-by: molecule-ai[bot] <276602405+molecule-ai[bot]@users.noreply.github.com>
Co-authored-by: Molecule AI Core-DevOps <core-devops@agents.moleculesai.app>
Co-authored-by: Molecule AI Core-UIUX <core-uiux@agents.moleculesai.app>
Co-authored-by: Hongming Wang <hongmingwang.rabbit@users.noreply.github.com>
2026-04-21 02:47:27 +00:00

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")
}
}