oto/services/core/internal/httpserver/server_test.go
toki 5fd116e447 feat(control-plane): CI/CD 상태 API를 추가한다
OTO Core가 job, execution, log, artifact 상태를 서버 내부에서 소유하고 조회할 수 있어야 runner dispatch와 통합 smoke 단계로 이어질 수 있다. in-memory 상태 저장소와 HTTP API, 검증 테스트, 관련 agent-task 산출물을 함께 반영한다.
2026-06-06 06:22:47 +09:00

725 lines
25 KiB
Go

package httpserver
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/toki/oto/services/core/internal/cicdstate"
"github.com/toki/oto/services/core/internal/runnerregistry"
otopb "github.com/toki/oto/services/core/oto"
)
func TestHandleHealthz(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rr := httptest.NewRecorder()
handleHealthz(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handleHealthz returned wrong status code: got %v want %v", status, http.StatusOK)
}
expected := "OK"
if body := rr.Body.String(); body != expected {
t.Errorf("handleHealthz returned unexpected body: got %v want %v", body, expected)
}
}
func TestHandleHealthz_MethodNotAllowed(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/healthz", nil)
rr := httptest.NewRecorder()
handleHealthz(rr, req)
if status := rr.Code; status != http.StatusMethodNotAllowed {
t.Errorf("handleHealthz for POST returned wrong status code: got %v want %v", status, http.StatusMethodNotAllowed)
}
}
func TestHandleReadyz(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rr := httptest.NewRecorder()
handleReadyz(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handleReadyz returned wrong status code: got %v want %v", status, http.StatusOK)
}
expected := "OK"
if body := rr.Body.String(); body != expected {
t.Errorf("handleReadyz returned unexpected body: got %v want %v", body, expected)
}
}
func TestHandleReadyz_MethodNotAllowed(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/readyz", nil)
rr := httptest.NewRecorder()
handleReadyz(rr, req)
if status := rr.Code; status != http.StatusMethodNotAllowed {
t.Errorf("handleReadyz for POST returned wrong status code: got %v want %v", status, http.StatusMethodNotAllowed)
}
}
func TestServerMux(t *testing.T) {
registry := runnerregistry.New()
server := NewServerWithRegistry(":0", registry)
mux := server.httpServer.Handler.(*http.ServeMux)
// Test healthz routing
reqHealth := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rrHealth := httptest.NewRecorder()
mux.ServeHTTP(rrHealth, reqHealth)
if rrHealth.Code != http.StatusOK {
t.Errorf("mux /healthz failed: got status %v", rrHealth.Code)
}
// Test readyz routing
reqReady := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rrReady := httptest.NewRecorder()
mux.ServeHTTP(rrReady, reqReady)
if rrReady.Code != http.StatusOK {
t.Errorf("mux /readyz failed: got status %v", rrReady.Code)
}
// Test fallback/not found routing
reqNotFound := httptest.NewRequest(http.MethodGet, "/unknown", nil)
rrNotFound := httptest.NewRecorder()
mux.ServeHTTP(rrNotFound, reqNotFound)
if rrNotFound.Code != http.StatusNotFound {
t.Errorf("mux /unknown routing status code: got %v want %v", rrNotFound.Code, http.StatusNotFound)
}
}
func TestHandleRunnerRegisterAcceptsAndStoresRunner(t *testing.T) {
registry := runnerregistry.New()
body := bytes.NewBufferString(`{
"enrollment_token":"token-123",
"runner_id":"runner-123",
"alias":"linux-build",
"protocol_version":"oto.runner.v1",
"capability":{"name":"oto-runner","version":"1.0.0"},
"command_catalog":{"command_types":["Shell","Git"]}
}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/register", body)
rr := httptest.NewRecorder()
handleRunnerRegister(registry)(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String())
}
var response otopb.RegisterRunnerResponse
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if !response.GetAccepted() {
t.Fatalf("accepted=false, reason=%q", response.GetRejectReason())
}
record, ok := registry.Snapshot("runner-123")
if !ok {
t.Fatal("registered runner was not stored")
}
if record.Alias != "linux-build" {
t.Fatalf("alias = %q, want linux-build", record.Alias)
}
if got := record.CommandTypes; len(got) != 2 || got[0] != "Shell" || got[1] != "Git" {
t.Fatalf("command types = %#v, want [Shell Git]", got)
}
}
func TestHandleRunnerRegisterRejectsMissingToken(t *testing.T) {
registry := runnerregistry.New()
body := bytes.NewBufferString(`{
"runner_id":"runner-123",
"protocol_version":"oto.runner.v1"
}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/register", body)
rr := httptest.NewRecorder()
handleRunnerRegister(registry)(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %v, want %v", rr.Code, http.StatusOK)
}
var response otopb.RegisterRunnerResponse
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.GetAccepted() {
t.Fatal("missing token registration was accepted")
}
if response.GetRejectReason() != "missing enrollment token" {
t.Fatalf("reject reason = %q, want missing enrollment token", response.GetRejectReason())
}
}
func TestHandleRunnerRegisterMethodNotAllowed(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/runners/register", nil)
rr := httptest.NewRecorder()
handleRunnerRegister(runnerregistry.New())(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %v, want %v", rr.Code, http.StatusMethodNotAllowed)
}
}
func TestHandleRunnerHeartbeat(t *testing.T) {
registry := runnerregistry.New()
// 1. Heartbeat on unregistered runner should fail (404)
body := bytes.NewBufferString(`{"runner_id":"runner-123", "status":1}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/heartbeat", body)
rr := httptest.NewRecorder()
handleRunnerHeartbeat(registry)(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %v, want %v for unregistered runner", rr.Code, http.StatusNotFound)
}
// 2. Register runner first
registry.Register(&otopb.RegisterRunnerRequest{
EnrollmentToken: "token-123",
RunnerId: "runner-123",
ProtocolVersion: "oto.runner.v1",
})
// 3. Valid Heartbeat on registered runner should succeed
body = bytes.NewBufferString(`{"runner_id":"runner-123", "status":1}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/heartbeat", body)
rr = httptest.NewRecorder()
handleRunnerHeartbeat(registry)(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %v, want %v", rr.Code, http.StatusOK)
}
var response otopb.HeartbeatResponse
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatalf("decode heartbeat response: %v", err)
}
if !response.GetSuccess() {
t.Fatal("heartbeat failed")
}
// Verify status is online
record, _ := registry.Snapshot("runner-123")
if record.Status != runnerregistry.StatusOnline {
t.Fatalf("status = %q, want %q", record.Status, runnerregistry.StatusOnline)
}
}
func TestHandleRunnerDisconnect(t *testing.T) {
registry := runnerregistry.New()
// 1. Disconnect unregistered runner should fail (404)
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/disconnect", nil)
rr := httptest.NewRecorder()
handleRunnerDisconnect(registry)(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %v, want %v", rr.Code, http.StatusNotFound)
}
// 2. Register runner
registry.Register(&otopb.RegisterRunnerRequest{
EnrollmentToken: "token-123",
RunnerId: "runner-123",
ProtocolVersion: "oto.runner.v1",
})
// 3. Disconnect registered runner should succeed
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/disconnect", nil)
rr = httptest.NewRecorder()
handleRunnerDisconnect(registry)(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %v, want %v", rr.Code, http.StatusOK)
}
var res struct {
Success bool `json:"success"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil {
t.Fatalf("decode response: %v", err)
}
if !res.Success {
t.Fatal("expected success=true")
}
record, _ := registry.Snapshot("runner-123")
if record.Status != runnerregistry.StatusDisconnected {
t.Fatalf("status = %q, want %q", record.Status, runnerregistry.StatusDisconnected)
}
}
func TestHandleGetRunner(t *testing.T) {
registry := runnerregistry.New()
// 1. Get unregistered runner should return 404
req := httptest.NewRequest(http.MethodGet, "/api/v1/runners/runner-123", nil)
rr := httptest.NewRecorder()
handleGetRunner(registry)(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %v, want %v", rr.Code, http.StatusNotFound)
}
// 2. Register runner
registry.Register(&otopb.RegisterRunnerRequest{
EnrollmentToken: "token-123",
RunnerId: "runner-123",
Alias: "build-linux",
ProtocolVersion: "oto.runner.v1",
})
// 3. Get registered runner should succeed (200) and return JSON record status
req = httptest.NewRequest(http.MethodGet, "/api/v1/runners/runner-123", nil)
rr = httptest.NewRecorder()
handleGetRunner(registry)(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %v, want %v", rr.Code, http.StatusOK)
}
var res map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil {
t.Fatalf("decode response: %v", err)
}
if res["runner_id"] != "runner-123" {
t.Fatalf("runner_id = %v", res["runner_id"])
}
if res["status"] != runnerregistry.StatusAccepted {
t.Fatalf("status = %v", res["status"])
}
}
func TestHandleRunnerHeartbeatMismatch(t *testing.T) {
registry := runnerregistry.New()
// Register runner-a
registry.Register(&otopb.RegisterRunnerRequest{
EnrollmentToken: "token-123",
RunnerId: "runner-a",
ProtocolVersion: "oto.runner.v1",
})
// Register runner-b
registry.Register(&otopb.RegisterRunnerRequest{
EnrollmentToken: "token-123",
RunnerId: "runner-b",
ProtocolVersion: "oto.runner.v1",
})
// Heartbeat with mismatched runner ID in body vs path
body := bytes.NewBufferString(`{"runner_id":"runner-b", "status":1}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-a/heartbeat", body)
rr := httptest.NewRecorder()
handleRunnerHeartbeat(registry)(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %v, want %v", rr.Code, http.StatusBadRequest)
}
var response otopb.HeartbeatResponse
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.GetSuccess() {
t.Fatal("expected heartbeat to fail due to ID mismatch")
}
if response.GetErrorMessage() != "runner id mismatch between path and body" {
t.Fatalf("error message = %q", response.GetErrorMessage())
}
// Verify runner-b remains accepted (not revived/online)
rec, _ := registry.Snapshot("runner-b")
if rec.Status != runnerregistry.StatusAccepted {
t.Fatalf("runner-b status = %q, want %q", rec.Status, runnerregistry.StatusAccepted)
}
}
func TestHandleRunnerBootstrapCommand(t *testing.T) {
registry := runnerregistry.New()
// 1. Missing runner_id or enrollment_token should be rejected
bodyMissing := bytes.NewBufferString(`{"runner_id":"","enrollment_token":""}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyMissing)
rr := httptest.NewRecorder()
handleRunnerBootstrapCommand(registry)(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %v, want %v for missing runner_id/token", rr.Code, http.StatusBadRequest)
}
// 2. Missing token only should be rejected
bodyNoToken := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":""}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyNoToken)
rr = httptest.NewRecorder()
handleRunnerBootstrapCommand(registry)(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %v, want %v for missing token", rr.Code, http.StatusBadRequest)
}
// 3. Request should be rejected if server is HTTP and OTO_RUNNER_RELEASE_BASE_URL is not set
t.Setenv("OTO_RUNNER_RELEASE_BASE_URL", "")
bodyValid := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
req.Host = "localhost:8080"
rr = httptest.NewRecorder()
handleRunnerBootstrapCommand(registry)(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400 Bad Request when HTTP and no release override, got %v", rr.Code)
}
// 4. Request should be rejected if OTO_RUNNER_RELEASE_BASE_URL is not HTTPS
t.Setenv("OTO_RUNNER_RELEASE_BASE_URL", "http://example.com/releases")
bodyValid = bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
req.Host = "localhost:8080"
rr = httptest.NewRecorder()
handleRunnerBootstrapCommand(registry)(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400 Bad Request when override is not HTTPS, got %v", rr.Code)
}
// 5. Valid request should return correct escaped command when OTO_RUNNER_RELEASE_BASE_URL is set to HTTPS
t.Setenv("OTO_RUNNER_RELEASE_BASE_URL", "https://example.com/releases")
bodyValid = bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
req.Host = "localhost:8080"
rr = httptest.NewRecorder()
handleRunnerBootstrapCommand(registry)(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %v, want %v", rr.Code, http.StatusOK)
}
var response otopb.BootstrapCommandResponse
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatalf("decode bootstrap command response: %v", err)
}
expectedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'"
if response.GetBootstrapCommand() != expectedCmd {
t.Fatalf("bootstrap_command = %q, want %q", response.GetBootstrapCommand(), expectedCmd)
}
// 6. Request with shell metacharacters in runner_id/token should be safely escaped
bodyMalicious := bytes.NewBufferString(`{"runner_id":"runner; rm -rf /","enrollment_token":"token'$(say hello)'"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyMalicious)
req.Host = "localhost:8080"
rr = httptest.NewRecorder()
handleRunnerBootstrapCommand(registry)(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %v, want %v", rr.Code, http.StatusOK)
}
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatalf("decode bootstrap command response: %v", err)
}
expectedEscapedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --agent-id 'runner; rm -rf /' --enrollment-token 'token'\\''$(say hello)'\\''' --release-base-url 'https://example.com/releases'"
if response.GetBootstrapCommand() != expectedEscapedCmd {
t.Fatalf("bootstrap_command = %q, want %q", response.GetBootstrapCommand(), expectedEscapedCmd)
}
// 7. Malicious Host header with invalid characters should be rejected
bodyValid = bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
req.Host = "localhost; rm -rf /"
rr = httptest.NewRecorder()
handleRunnerBootstrapCommand(registry)(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400 Bad Request for malicious Host header, got %v", rr.Code)
}
}
func TestHandleServeBootstrapScript(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/bootstrap/oto-agent.sh", nil)
rr := httptest.NewRecorder()
handleServeBootstrapScript()(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %v, want %v; body = %s", rr.Code, http.StatusOK, rr.Body.String())
}
if contentType := rr.Header().Get("Content-Type"); contentType != "application/x-sh" {
t.Fatalf("Content-Type = %q, want application/x-sh", contentType)
}
if len(rr.Body.Bytes()) == 0 {
t.Fatal("empty bootstrap script returned")
}
}
func TestHandleCreateJob(t *testing.T) {
store := cicdstate.NewStore()
// Valid job creation should succeed
body := bytes.NewBufferString(`{"id":"job-1","name":"build"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", body)
rr := httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %v, want %v; body=%s", rr.Code, http.StatusCreated, rr.Body.String())
}
var res map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil {
t.Fatalf("decode response: %v", err)
}
if res["id"] != "job-1" {
t.Fatalf("id = %v, want job-1", res["id"])
}
if res["name"] != "build" {
t.Fatalf("name = %v, want build", res["name"])
}
if res["state"] != cicdstate.StateQueued {
t.Fatalf("state = %v, want %s", res["state"], cicdstate.StateQueued)
}
// Get job by ID
req = httptest.NewRequest(http.MethodGet, "/api/v1/jobs/job-1", nil)
rr = httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String())
}
if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil {
t.Fatalf("decode response: %v", err)
}
if res["id"] != "job-1" {
t.Fatalf("GET job id = %v, want job-1", res["id"])
}
// Unknown job ID should return 404
req = httptest.NewRequest(http.MethodGet, "/api/v1/jobs/nonexistent", nil)
rr = httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("GET unknown job status = %v, want %v", rr.Code, http.StatusNotFound)
}
// Duplicate job should return 409
body = bytes.NewBufferString(`{"id":"job-1","name":"duplicate"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/jobs", body)
rr = httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusConflict {
t.Fatalf("duplicate job status = %v, want %v", rr.Code, http.StatusConflict)
}
// Missing fields should return 400
body = bytes.NewBufferString(`{"id":"job-2"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/jobs", body)
rr = httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("missing fields status = %v, want %v", rr.Code, http.StatusBadRequest)
}
}
func TestHandleCreateExecution(t *testing.T) {
store := cicdstate.NewStore()
store.CreateJob("job-1", "build")
body := bytes.NewBufferString(`{"id":"exec-1"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-1/executions", body)
rr := httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %v, want %v; body=%s", rr.Code, http.StatusCreated, rr.Body.String())
}
var execRes map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &execRes); err != nil {
t.Fatalf("decode response: %v", err)
}
if execRes["id"] != "exec-1" {
t.Fatalf("execution id = %v, want exec-1", execRes["id"])
}
// Get execution by ID
req = httptest.NewRequest(http.MethodGet, "/api/v1/executions/exec-1", nil)
rr = httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET execution status = %v, want %v", rr.Code, http.StatusOK)
}
if err := json.Unmarshal(rr.Body.Bytes(), &execRes); err != nil {
t.Fatalf("decode response: %v", err)
}
if execRes["id"] != "exec-1" {
t.Fatalf("GET execution id = %v, want exec-1", execRes["id"])
}
// Unknown execution should return 404
req = httptest.NewRequest(http.MethodGet, "/api/v1/executions/nonexistent", nil)
rr = httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("GET unknown execution status = %v, want %v", rr.Code, http.StatusNotFound)
}
// Execution for unknown job should return 400
body = bytes.NewBufferString(`{"id":"exec-99"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/jobs/unknown/executions", body)
rr = httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("unknown job execution status = %v, want %v", rr.Code, http.StatusBadRequest)
}
}
func TestHandleExecutionLogsAndArtifacts(t *testing.T) {
store := cicdstate.NewStore()
store.CreateJob("job-1", "build")
store.CreateExecution("job-1", "exec-1")
// Append log
logBody := bytes.NewBufferString(`{"line":"build starting"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/executions/exec-1/logs", logBody)
rr := httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("append log status = %v, want %v; body=%s", rr.Code, http.StatusCreated, rr.Body.String())
}
// Append another log
logBody = bytes.NewBufferString(`{"line":"build done"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/executions/exec-1/logs", logBody)
rr = httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("append log 2 status = %v, want %v", rr.Code, http.StatusCreated)
}
// Get logs
req = httptest.NewRequest(http.MethodGet, "/api/v1/executions/exec-1/logs", nil)
rr = httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("get logs status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String())
}
var logRes map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &logRes); err != nil {
t.Fatalf("decode log response: %v", err)
}
logsArr, ok := logRes["logs"].([]interface{})
if !ok {
t.Fatalf("logs key not an array")
}
if len(logsArr) != 2 {
t.Fatalf("log count = %d, want 2", len(logsArr))
}
// Append artifact
artifactBody := bytes.NewBufferString(`{"name":"binary","path":"/dist/app"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/executions/exec-1/artifacts", artifactBody)
rr = httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("append artifact status = %v, want %v; body=%s", rr.Code, http.StatusCreated, rr.Body.String())
}
// Get artifacts
req = httptest.NewRequest(http.MethodGet, "/api/v1/executions/exec-1/artifacts", nil)
rr = httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("get artifacts status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String())
}
var artRes map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &artRes); err != nil {
t.Fatalf("decode artifact response: %v", err)
}
artifactsArr, ok := artRes["artifacts"].([]interface{})
if !ok {
t.Fatalf("artifacts key not an array")
}
if len(artifactsArr) != 1 {
t.Fatalf("artifact count = %d, want 1", len(artifactsArr))
}
// Unknown execution logs/artifacts should return 404
logBody2 := bytes.NewBufferString(`{"line":"second log line"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/executions/unknown/logs", logBody2)
rr = httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("unknown log append status = %v, want %v", rr.Code, http.StatusNotFound)
}
artifactBody2 := bytes.NewBufferString(`{"name":"binary","path":"/dist/app"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/executions/unknown/artifacts", artifactBody2)
rr = httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("unknown artifact append status = %v, want %v", rr.Code, http.StatusNotFound)
}
}
func TestHandleCicdUnknownIds(t *testing.T) {
store := cicdstate.NewStore()
// Get unknown job -> 404
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/nonexistent", nil)
rr := httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("GET unknown job = %v, want %v", rr.Code, http.StatusNotFound)
}
// Create execution for unknown job -> 400
body := bytes.NewBufferString(`{"id":"exec-1"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/jobs/nonexistent/executions", body)
rr = httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("exec unknown job = %v, want %v", rr.Code, http.StatusBadRequest)
}
// Get unknown execution -> 404
req = httptest.NewRequest(http.MethodGet, "/api/v1/executions/nonexistent", nil)
rr = httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("GET unknown exec = %v, want %v", rr.Code, http.StatusNotFound)
}
}
func TestHandleCicdMethodNotAllowed(t *testing.T) {
store := cicdstate.NewStore()
// GET on /api/v1/jobs (create endpoint) should return method not allowed
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs", nil)
rr := httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET /api/v1/jobs = %v, want %v", rr.Code, http.StatusMethodNotAllowed)
}
}
func TestHandleCicdNotFound(t *testing.T) {
store := cicdstate.NewStore()
// Unknown path should return 404
req := httptest.NewRequest(http.MethodGet, "/api/v1/unknown", nil)
rr := httptest.NewRecorder()
handleRouter(store, nil)(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("unknown path = %v, want %v", rr.Code, http.StatusNotFound)
}
}