Files
silo/internal/api/job_handlers_test.go
Forbes 4fd4013360 feat(modules): wire registry into server startup
Add modules.Registry and config.Config fields to Server struct.
Create registry in main.go, load state from YAML+DB, log all
module states at startup.

Conditionally start job/runner sweeper goroutines only when the
jobs module is enabled.

Update all 5 test files to pass registry to NewServer.

Ref #95, #96
2026-02-14 14:00:24 -06:00

341 lines
8.7 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/db"
"github.com/kindredsystems/silo/internal/modules"
"github.com/kindredsystems/silo/internal/schema"
"github.com/kindredsystems/silo/internal/testutil"
"github.com/rs/zerolog"
)
func newJobTestServer(t *testing.T) *Server {
t.Helper()
pool := testutil.MustConnectTestPool(t)
database := db.NewFromPool(pool)
broker := NewBroker(zerolog.Nop())
state := NewServerState(zerolog.Nop(), nil, broker)
return NewServer(
zerolog.Nop(),
database,
map[string]*schema.Schema{},
"",
nil, nil, nil, nil, nil,
broker, state,
nil, "",
modules.NewRegistry(), nil,
)
}
func newJobRouter(s *Server) http.Handler {
r := chi.NewRouter()
r.Route("/api/jobs", func(r chi.Router) {
r.Get("/", s.HandleListJobs)
r.Get("/{jobID}", s.HandleGetJob)
r.Get("/{jobID}/logs", s.HandleGetJobLogs)
r.Post("/", s.HandleCreateJob)
r.Post("/{jobID}/cancel", s.HandleCancelJob)
})
r.Route("/api/job-definitions", func(r chi.Router) {
r.Get("/", s.HandleListJobDefinitions)
r.Get("/{name}", s.HandleGetJobDefinition)
})
r.Route("/api/runners", func(r chi.Router) {
r.Get("/", s.HandleListRunners)
r.Post("/", s.HandleRegisterRunner)
r.Delete("/{runnerID}", s.HandleDeleteRunner)
})
return r
}
func seedJobDefinition(t *testing.T, s *Server) *db.JobDefinitionRecord {
t.Helper()
rec := &db.JobDefinitionRecord{
Name: "test-validate",
Version: 1,
TriggerType: "manual",
ScopeType: "item",
ComputeType: "validate",
RunnerTags: []string{"create"},
TimeoutSeconds: 300,
MaxRetries: 1,
Priority: 100,
Definition: map[string]any{"compute": map[string]any{"command": "create-validate"}},
Enabled: true,
}
if err := s.jobs.UpsertDefinition(context.Background(), rec); err != nil {
t.Fatalf("seeding job definition: %v", err)
}
return rec
}
func TestHandleListJobDefinitions(t *testing.T) {
s := newJobTestServer(t)
r := newJobRouter(s)
seedJobDefinition(t, s)
req := httptest.NewRequest("GET", "/api/job-definitions", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var defs []map[string]any
json.Unmarshal(w.Body.Bytes(), &defs)
if len(defs) == 0 {
t.Error("expected at least one definition")
}
}
func TestHandleGetJobDefinition(t *testing.T) {
s := newJobTestServer(t)
r := newJobRouter(s)
seedJobDefinition(t, s)
req := httptest.NewRequest("GET", "/api/job-definitions/test-validate", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var def map[string]any
json.Unmarshal(w.Body.Bytes(), &def)
if def["name"] != "test-validate" {
t.Errorf("expected name test-validate, got %v", def["name"])
}
}
func TestHandleCreateAndGetJob(t *testing.T) {
s := newJobTestServer(t)
r := newJobRouter(s)
seedJobDefinition(t, s)
// Create a job
body := `{"definition_name": "test-validate"}`
req := httptest.NewRequest("POST", "/api/jobs", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var job map[string]any
json.Unmarshal(w.Body.Bytes(), &job)
jobID := job["ID"].(string)
if jobID == "" {
t.Fatal("job ID is empty")
}
// Get the job
req2 := httptest.NewRequest("GET", "/api/jobs/"+jobID, nil)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("get: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
}
func TestHandleCancelJob(t *testing.T) {
s := newJobTestServer(t)
r := newJobRouter(s)
seedJobDefinition(t, s)
// Create a job
body := `{"definition_name": "test-validate"}`
req := httptest.NewRequest("POST", "/api/jobs", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
var job map[string]any
json.Unmarshal(w.Body.Bytes(), &job)
jobID := job["ID"].(string)
// Cancel the job
req2 := httptest.NewRequest("POST", "/api/jobs/"+jobID+"/cancel", nil)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("cancel: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
}
func TestHandleListJobs(t *testing.T) {
s := newJobTestServer(t)
r := newJobRouter(s)
seedJobDefinition(t, s)
// Create a job
body := `{"definition_name": "test-validate"}`
req := httptest.NewRequest("POST", "/api/jobs", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// List jobs
req2 := httptest.NewRequest("GET", "/api/jobs", nil)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("list: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
var jobs []map[string]any
json.Unmarshal(w2.Body.Bytes(), &jobs)
if len(jobs) == 0 {
t.Error("expected at least one job")
}
}
func TestHandleListJobs_FilterByStatus(t *testing.T) {
s := newJobTestServer(t)
r := newJobRouter(s)
seedJobDefinition(t, s)
// Create a job
body := `{"definition_name": "test-validate"}`
req := httptest.NewRequest("POST", "/api/jobs", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// Filter by pending
req2 := httptest.NewRequest("GET", "/api/jobs?status=pending", nil)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w2.Code)
}
var jobs []map[string]any
json.Unmarshal(w2.Body.Bytes(), &jobs)
if len(jobs) == 0 {
t.Error("expected pending jobs")
}
// Filter by completed (should be empty)
req3 := httptest.NewRequest("GET", "/api/jobs?status=completed", nil)
w3 := httptest.NewRecorder()
r.ServeHTTP(w3, req3)
var completedJobs []map[string]any
json.Unmarshal(w3.Body.Bytes(), &completedJobs)
if len(completedJobs) != 0 {
t.Errorf("expected no completed jobs, got %d", len(completedJobs))
}
}
func TestHandleRegisterAndListRunners(t *testing.T) {
s := newJobTestServer(t)
r := newJobRouter(s)
// Register a runner
body := `{"name": "test-runner-1", "tags": ["create", "linux"]}`
req := httptest.NewRequest("POST", "/api/runners", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("register: expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["token"] == nil || resp["token"] == "" {
t.Error("expected a token in response")
}
if !strings.HasPrefix(resp["token"].(string), "silo_runner_") {
t.Errorf("expected token to start with silo_runner_, got %s", resp["token"])
}
// List runners
req2 := httptest.NewRequest("GET", "/api/runners", nil)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("list: expected 200, got %d", w2.Code)
}
var runners []map[string]any
json.Unmarshal(w2.Body.Bytes(), &runners)
if len(runners) == 0 {
t.Error("expected at least one runner")
}
// Token hash should not be exposed
for _, runner := range runners {
if runner["token_hash"] != nil {
t.Error("token_hash should not be in response")
}
}
}
func TestHandleDeleteRunner(t *testing.T) {
s := newJobTestServer(t)
r := newJobRouter(s)
// Register a runner
body := `{"name": "test-runner-delete", "tags": ["create"]}`
req := httptest.NewRequest("POST", "/api/runners", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp)
runnerID := resp["id"].(string)
// Delete the runner
req2 := httptest.NewRequest("DELETE", "/api/runners/"+runnerID, nil)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusNoContent {
t.Fatalf("delete: expected 204, got %d: %s", w2.Code, w2.Body.String())
}
}
func TestGenerateRunnerToken(t *testing.T) {
raw, hash, prefix := generateRunnerToken()
if !strings.HasPrefix(raw, "silo_runner_") {
t.Errorf("raw token should start with silo_runner_, got %s", raw[:20])
}
if len(hash) != 64 {
t.Errorf("hash should be 64 hex chars, got %d", len(hash))
}
if len(prefix) != 20 {
t.Errorf("prefix should be 20 chars, got %d: %s", len(prefix), prefix)
}
// Two tokens should be different
raw2, _, _ := generateRunnerToken()
if raw == raw2 {
t.Error("two generated tokens should be different")
}
}