621e8895c9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
5.8 KiB
Go
191 lines
5.8 KiB
Go
package infra
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// newUnixDockerServer levanta un httptest-style server en un socket Unix temporal.
|
|
// Retorna el server (ya iniciado) y el path al socket.
|
|
func newUnixDockerServer(t *testing.T, handler http.Handler) (socketPath string) {
|
|
t.Helper()
|
|
|
|
socketPath = t.TempDir() + "/docker_exec_test.sock"
|
|
ln, err := net.Listen("unix", socketPath)
|
|
if err != nil {
|
|
t.Fatalf("listen unix %s: %v", socketPath, err)
|
|
}
|
|
|
|
srv := &http.Server{Handler: handler}
|
|
go srv.Serve(ln) //nolint:errcheck
|
|
t.Cleanup(func() {
|
|
srv.Close()
|
|
ln.Close()
|
|
})
|
|
return socketPath
|
|
}
|
|
|
|
// buildDockerExecHandler construye un http.Handler que simula el flujo completo del
|
|
// Docker Engine API para exec: create → start (stream multiplexado) → inspect.
|
|
func buildDockerExecHandler(t *testing.T, exitCode int, stdoutPayload, stderrPayload string, delayStart time.Duration) http.Handler {
|
|
t.Helper()
|
|
|
|
const fakeExecID = "deadbeefcafe"
|
|
|
|
// Construir stream multiplexado de Docker (Tty=false).
|
|
// Frame: [type(1)] [0 0 0(3)] [size big-endian uint32(4)] [payload]
|
|
buildFrame := func(streamType byte, payload string) []byte {
|
|
if payload == "" {
|
|
return nil
|
|
}
|
|
b := make([]byte, 8+len(payload))
|
|
b[0] = streamType
|
|
binary.BigEndian.PutUint32(b[4:8], uint32(len(payload)))
|
|
copy(b[8:], payload)
|
|
return b
|
|
}
|
|
|
|
var streamBody []byte
|
|
streamBody = append(streamBody, buildFrame(1, stdoutPayload)...)
|
|
streamBody = append(streamBody, buildFrame(2, stderrPayload)...)
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
// POST /containers/{id}/exec — devuelve ExecID.
|
|
mux.HandleFunc("/containers/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/exec") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(map[string]string{"Id": fakeExecID}) //nolint:errcheck
|
|
})
|
|
|
|
// POST /exec/{id}/start — transmite stream multiplexado.
|
|
// GET /exec/{id}/json — devuelve ExitCode.
|
|
mux.HandleFunc("/exec/", func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/start") && r.Method == http.MethodPost:
|
|
if delayStart > 0 {
|
|
select {
|
|
case <-r.Context().Done():
|
|
http.Error(w, "cancelled", http.StatusGatewayTimeout)
|
|
return
|
|
case <-time.After(delayStart):
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
if len(streamBody) > 0 {
|
|
w.Write(streamBody) //nolint:errcheck
|
|
}
|
|
|
|
case strings.HasSuffix(r.URL.Path, "/json") && r.Method == http.MethodGet:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]int{"ExitCode": exitCode}) //nolint:errcheck
|
|
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
|
|
return mux
|
|
}
|
|
|
|
func TestDockerContainerExec(t *testing.T) {
|
|
const containerID = "abc123container"
|
|
|
|
t.Run("binario en whitelist exitcode 0 stdout stderr capturados", func(t *testing.T) {
|
|
handler := buildDockerExecHandler(t, 0, "hello stdout\n", "hello stderr\n", 0)
|
|
socketPath := newUnixDockerServer(t, handler)
|
|
|
|
result, err := DockerContainerExec(DockerExecOpts{
|
|
ContainerID: containerID,
|
|
Cmd: []string{"ls", "-la"},
|
|
BinariesAllowed: []string{"ls", "cat", "echo"},
|
|
DockerHost: "unix://" + socketPath,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.ExitCode != 0 {
|
|
t.Errorf("ExitCode: got %d, want 0", result.ExitCode)
|
|
}
|
|
if result.Stdout != "hello stdout\n" {
|
|
t.Errorf("Stdout: got %q, want %q", result.Stdout, "hello stdout\n")
|
|
}
|
|
if result.Stderr != "hello stderr\n" {
|
|
t.Errorf("Stderr: got %q, want %q", result.Stderr, "hello stderr\n")
|
|
}
|
|
if result.Duration < 0 {
|
|
t.Errorf("Duration should be >= 0, got %d", result.Duration)
|
|
}
|
|
})
|
|
|
|
t.Run("binario NO en whitelist error sin contactar engine", func(t *testing.T) {
|
|
// No levantamos server: si se contacta el engine, falla con connection refused.
|
|
// La validacion debe fallar ANTES de intentar conectar.
|
|
_, err := DockerContainerExec(DockerExecOpts{
|
|
ContainerID: containerID,
|
|
Cmd: []string{"bash", "-c", "rm -rf /"},
|
|
BinariesAllowed: []string{"ls", "cat"},
|
|
DockerHost: "unix:///tmp/nonexistent-docker-for-test.sock",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for binary not in whitelist, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "not in whitelist") {
|
|
t.Errorf("error should mention whitelist, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("whitelist vacia rechaza todo", func(t *testing.T) {
|
|
_, err := DockerContainerExec(DockerExecOpts{
|
|
ContainerID: containerID,
|
|
Cmd: []string{"ls"},
|
|
BinariesAllowed: []string{},
|
|
DockerHost: "unix:///tmp/nonexistent-docker-for-test.sock",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for empty whitelist, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "no binaries whitelisted") {
|
|
t.Errorf("error should mention empty whitelist, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("timeout simulado", func(t *testing.T) {
|
|
// El handler demora 2s en /start, el timeout de la funcion es 1s.
|
|
handler := buildDockerExecHandler(t, 0, "should not arrive", "", 2*time.Second)
|
|
socketPath := newUnixDockerServer(t, handler)
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := DockerContainerExec(DockerExecOpts{
|
|
ContainerID: containerID,
|
|
Cmd: []string{"sleep"},
|
|
BinariesAllowed: []string{"sleep"},
|
|
DockerHost: "unix://" + socketPath,
|
|
TimeoutSeconds: 1,
|
|
})
|
|
done <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
t.Fatal("expected timeout error, got nil")
|
|
}
|
|
// Cualquier error de contexto/red es valido (context deadline exceeded, connection reset, etc.)
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("test itself timed out — DockerContainerExec did not respect TimeoutSeconds")
|
|
}
|
|
})
|
|
}
|