Files
fn_registry/functions/infra/docker_container_exec_test.go
T
egutierrez 621e8895c9 feat(infra): auto-commit con 86 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:38:15 +02:00

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