Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions acceptance/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package acceptance
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"encoding/pem"
"fmt"
"net/http/httptest"
"os"
"os/exec"
Expand Down Expand Up @@ -393,3 +395,118 @@ func TestValidateRunRemoteUsesSSH(t *testing.T) {
execReqs := filterByPath(reqs, "/api/v2/sidecar/instances/sidecar-123/exec")
assert.Equal(t, len(execReqs), 0, "expected 0 HTTP exec requests (SSH should be used)")
}

func TestValidateAutoCreatesSidecar(t *testing.T) {
// Verify that chunk validate (no --remote) auto-creates a sidecar using the
// image stored in validation.sidecarImage when no active sidecar exists.
cci := fakes.NewFakeCircleCI()
cci.AddKeyURL = "127.0.0.1" // SSH will fail — no real server at port 2222
srv := httptest.NewServer(cci)
defer srv.Close()

workDir := gitrepo.SetupGitRepo(t, "test-org", "test-repo")

// Write a config with a remote command and a snapshot image reference.
chunkDir := filepath.Join(workDir, ".chunk")
assert.NilError(t, os.MkdirAll(chunkDir, 0o755))
cfg := map[string]interface{}{
"commands": []map[string]interface{}{
{"name": "test", "run": "echo test-output", "remote": true},
},
"validation": map[string]interface{}{
"sidecarImage": "my-snapshot-abc123",
},
}
data, err := json.Marshal(cfg)
assert.NilError(t, err)
assert.NilError(t, os.WriteFile(filepath.Join(chunkDir, "config.json"), data, 0o644))

sshDir := filepath.Join(t.TempDir(), ".ssh")
assert.NilError(t, os.MkdirAll(sshDir, 0o700))
identityFile := filepath.Join(sshDir, "chunk_ai")
assert.NilError(t, generateTestSSHKey(t, identityFile))

env := testenv.NewTestEnv(t)
env.CircleCIURL = srv.URL
env.Extra["CIRCLECI_ORG_ID"] = "org-aaa"

result := binary.RunCLI(t, []string{
"validate",
"--identity-file", identityFile,
}, env, workDir)

// SSH to 127.0.0.1:2222 fails — expected, but a sidecar must have been created first.
assert.Assert(t, result.ExitCode != 0, "expected failure because no SSH server is running")

reqs := cci.Recorder.AllRequests()

// A sidecar must have been created with the configured image.
createReqs := filterByPath(reqs, "/api/v2/sidecar/instances")
assert.Equal(t, len(createReqs), 1, "expected 1 create-sidecar request; got: %v", reqs)

var body map[string]interface{}
assert.NilError(t, json.Unmarshal(createReqs[0].Body, &body))
assert.Equal(t, body["image"], "my-snapshot-abc123", "expected sidecar image from config")
assert.Equal(t, body["org_id"], "org-aaa", "expected org from CIRCLECI_ORG_ID")

// AddSSHKey must be called on the newly created sidecar — proves it was used.
addKeyReqs := filterByPath(reqs, "/api/v2/sidecar/instances/sidecar-new-123/ssh/add-key")
assert.Equal(t, len(addKeyReqs), 1, "expected 1 add-key request for newly created sidecar; got: %v", reqs)
}

// writeRemoteProjectConfig writes a config with a single remote command.
func writeRemoteProjectConfig(t *testing.T, workDir string) {
t.Helper()
chunkDir := filepath.Join(workDir, ".chunk")
assert.NilError(t, os.MkdirAll(chunkDir, 0o755))
cfg := `{"commands":[{"name":"test","run":"true","remote":true}]}`
assert.NilError(t, os.WriteFile(filepath.Join(chunkDir, "config.json"), []byte(cfg), 0o644))
}

// writeSidecarState writes a session-keyed sidecar state file into the test
// environment's XDG data directory for the given project root.
func writeSidecarState(t *testing.T, e *testenv.TestEnv, projectRoot, sessionID, sidecarID string) {
t.Helper()
// Resolve symlinks so the hash matches what os.Getwd() returns in the subprocess.
// On macOS, t.TempDir() returns /var/folders/... but os.Getwd() resolves to /private/var/...
realRoot, err := filepath.EvalSymlinks(projectRoot)
assert.NilError(t, err)
// Compute the data dir directly from e.HomeDir so we don't touch the parent process env.
// This mirrors config.ProjectDataDir: <XDG_DATA_HOME>/chunk/<sha256(root)>
sum := sha256.Sum256([]byte(filepath.Clean(realRoot)))
dir := filepath.Join(e.HomeDir, ".local", "share", "chunk", fmt.Sprintf("%x", sum))
assert.NilError(t, os.MkdirAll(dir, 0o755))
filename := "sidecar." + sessionID + ".json"
data := []byte(`{"sidecar_id":"` + sidecarID + `"}`)
assert.NilError(t, os.WriteFile(filepath.Join(dir, filename), data, 0o644))
}

// TestValidateHookMode_SessionIsolation verifies that two concurrent Claude
// sessions each see their own sidecar state rather than sharing one file.
func TestValidateHookMode_SessionIsolation(t *testing.T) {
workDir := gitrepo.SetupGitRepo(t, "test-org", "test-repo")
writeRemoteProjectConfig(t, workDir)
// Add an untracked file so the working tree is dirty and validate runs.
assert.NilError(t, os.WriteFile(filepath.Join(workDir, "dirty.txt"), []byte("x"), 0o644))

envA := testenv.NewTestEnv(t)
envB := testenv.NewTestEnv(t)

writeSidecarState(t, envA, workDir, "sess-a", "sidecar-aaa")
writeSidecarState(t, envB, workDir, "sess-b", "sidecar-bbb")

resultA := binary.RunCLIWithStdin(t, []string{"validate"}, envA, workDir,
hookStdin(t, "sess-a", true))
resultB := binary.RunCLIWithStdin(t, []string{"validate"}, envB, workDir,
hookStdin(t, "sess-b", true))

assert.Assert(t, strings.Contains(resultA.Stderr, "sidecar-aaa"),
"session A should load sidecar-aaa; stderr: %s", resultA.Stderr)
assert.Assert(t, !strings.Contains(resultA.Stderr, "sidecar-bbb"),
"session A should not see sidecar-bbb; stderr: %s", resultA.Stderr)

assert.Assert(t, strings.Contains(resultB.Stderr, "sidecar-bbb"),
"session B should load sidecar-bbb; stderr: %s", resultB.Stderr)
assert.Assert(t, !strings.Contains(resultB.Stderr, "sidecar-aaa"),
"session B should not see sidecar-aaa; stderr: %s", resultB.Stderr)
}
Loading