package api import ( "encoding/json" "net/http" "net/http/httptest" "strconv" "testing" "time" "github.com/kami/hexis/internal/domain" "github.com/kami/hexis/internal/storage" ) // seedExecution inserts a finished execution against targetEntityID. func seedExecution(t *testing.T, store *storage.Store, capID, targetEntityID string) *domain.Execution { t.Helper() now := time.Now().UTC() e := &domain.Execution{ ID: domain.NewExecutionID(), CapabilityID: capID, TargetEntityID: targetEntityID, Status: domain.ExecutionSucceeded, Arguments: map[string]any{}, CreatedAt: now, UpdatedAt: now, } if err := store.CreateExecution(e); err != nil { t.Fatalf("create execution: %v", err) } return e } func getExecutions(t *testing.T, h *Handler, query string) []*domain.Execution { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/v1/executions"+query, nil) w := httptest.NewRecorder() h.handleExecutions(w, req) if w.Code != http.StatusOK { t.Fatalf("GET /api/v1/executions%s: expected 200, got %d: %s", query, w.Code, w.Body.String()) } var out []*domain.Execution if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { t.Fatalf("decode response: %v", err) } return out } // TestListExecutions_FiltersByEntity pins the entity_id filter from // ECOSYSTEM-SPEC.md §4.5 — the Command Center renders per-entity history and // must not be shown another entity's executions. func TestListExecutions_FiltersByEntity(t *testing.T) { h, store := newTestHandler(t) seedExecution(t, store, "cap_a", "ent_alpha") seedExecution(t, store, "cap_b", "ent_beta") wantAlpha := seedExecution(t, store, "cap_c", "ent_alpha") all := getExecutions(t, h, "") if len(all) != 3 { t.Fatalf("unfiltered: expected 3 executions, got %d", len(all)) } alpha := getExecutions(t, h, "?entity_id=ent_alpha") if len(alpha) != 2 { t.Fatalf("entity_id=ent_alpha: expected 2 executions, got %d", len(alpha)) } for _, e := range alpha { if e.TargetEntityID != "ent_alpha" { t.Errorf("entity_id filter leaked execution for %s", e.TargetEntityID) } } if alpha[1].ID != wantAlpha.ID { t.Errorf("expected ascending order ending at %s, got %s", wantAlpha.ID, alpha[1].ID) } if none := getExecutions(t, h, "?entity_id=ent_nonexistent"); len(none) != 0 { t.Errorf("unknown entity: expected 0 executions, got %d", len(none)) } } // TestListExecutions_SinceIsAnExclusiveCursor pins the pagination convention: // `since` is the same exclusive integer cursor the /api/v1/changes feed uses, // so a client learns one paging idiom. A cursor that returned rows it had // already seen would make the Executions surface duplicate history. func TestListExecutions_SinceIsAnExclusiveCursor(t *testing.T) { h, store := newTestHandler(t) for i := 0; i < 4; i++ { seedExecution(t, store, "cap_a", "ent_alpha") } all := getExecutions(t, h, "") if len(all) != 4 { t.Fatalf("expected 4 executions, got %d", len(all)) } for i, e := range all { if e.Seq == 0 { t.Fatalf("execution %d has no seq cursor", i) } if i > 0 && e.Seq <= all[i-1].Seq { t.Fatalf("seq not strictly increasing: %d then %d", all[i-1].Seq, e.Seq) } } cursor := all[1].Seq page := getExecutions(t, h, "?since="+strconv.FormatInt(cursor, 10)) if len(page) != 2 { t.Fatalf("since=%d: expected 2 executions, got %d", cursor, len(page)) } for _, e := range page { if e.Seq <= cursor { t.Errorf("execution seq %d should have been excluded by since=%d", e.Seq, cursor) } } // Draining the cursor yields an empty array, not null — the Command // Center iterates the response unconditionally. last := all[len(all)-1].Seq if drained := getExecutions(t, h, "?since="+strconv.FormatInt(last, 10)); len(drained) != 0 { t.Errorf("since=%d (latest): expected 0 executions, got %d", last, len(drained)) } } // TestListExecutions_CombinesEntityAndSince checks the two filters compose, // which is the actual Command Center polling call. func TestListExecutions_CombinesEntityAndSince(t *testing.T) { h, store := newTestHandler(t) seedExecution(t, store, "cap_a", "ent_alpha") seedExecution(t, store, "cap_b", "ent_beta") third := seedExecution(t, store, "cap_c", "ent_alpha") all := getExecutions(t, h, "") cursor := all[0].Seq got := getExecutions(t, h, "?entity_id=ent_alpha&since="+strconv.FormatInt(cursor, 10)) if len(got) != 1 { t.Fatalf("expected 1 execution, got %d", len(got)) } if got[0].ID != third.ID { t.Errorf("expected %s, got %s", third.ID, got[0].ID) } } func TestListExecutions_LimitBoundsThePage(t *testing.T) { h, store := newTestHandler(t) for i := 0; i < 5; i++ { seedExecution(t, store, "cap_a", "ent_alpha") } got := getExecutions(t, h, "?limit=2") if len(got) != 2 { t.Fatalf("limit=2: expected 2 executions, got %d", len(got)) } } func TestListExecutions_RejectsBadCursor(t *testing.T) { h, _ := newTestHandler(t) for _, q := range []string{"?since=abc", "?since=-1", "?limit=0", "?limit=nope"} { req := httptest.NewRequest(http.MethodGet, "/api/v1/executions"+q, nil) w := httptest.NewRecorder() h.handleExecutions(w, req) if w.Code != http.StatusBadRequest { t.Errorf("%s: expected 400, got %d: %s", q, w.Code, w.Body.String()) } } } func TestListExecutions_RejectsNonGET(t *testing.T) { h, _ := newTestHandler(t) req := httptest.NewRequest(http.MethodPost, "/api/v1/executions", nil) w := httptest.NewRecorder() h.handleExecutions(w, req) if w.Code != http.StatusMethodNotAllowed { t.Errorf("expected 405, got %d", w.Code) } } // TestListExecutions_RequiresBearerToken pins that the new endpoint sits // behind the same auth as the rest of /api/v1/ rather than being routed // around it. Execution history names every entity and capability on the box; // it is not public. func TestListExecutions_RequiresBearerToken(t *testing.T) { h, store := newTestHandler(t) seedExecution(t, store, "cap_a", "ent_alpha") mux := http.NewServeMux() h.Register(mux) // No token at all. w := httptest.NewRecorder() mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/executions?entity_id=ent_alpha", nil)) if w.Code != http.StatusUnauthorized { t.Fatalf("unauthenticated: expected 401, got %d: %s", w.Code, w.Body.String()) } // Wrong token. req := httptest.NewRequest(http.MethodGet, "/api/v1/executions", nil) req.Header.Set("Authorization", "Bearer wrong-token") w = httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("wrong token: expected 401, got %d", w.Code) } // Correct token. req = httptest.NewRequest(http.MethodGet, "/api/v1/executions", nil) req.Header.Set("Authorization", "Bearer "+testToken) w = httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("authenticated: expected 200, got %d: %s", w.Code, w.Body.String()) } var out []*domain.Execution if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { t.Fatalf("decode: %v", err) } if len(out) != 1 { t.Fatalf("expected 1 execution, got %d", len(out)) } } // TestListExecutions_RoutedSeparatelyFromGetByID guards the ServeMux split: // "/api/v1/executions" (list) and "/api/v1/executions/" (by ID) are distinct // patterns, and adding the former must not have shadowed the latter. func TestListExecutions_RoutedSeparatelyFromGetByID(t *testing.T) { h, store := newTestHandler(t) exec := seedExecution(t, store, "cap_a", "ent_alpha") mux := http.NewServeMux() h.Register(mux) req := httptest.NewRequest(http.MethodGet, "/api/v1/executions/"+exec.ID, nil) req.Header.Set("Authorization", "Bearer "+testToken) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("get by id: expected 200, got %d: %s", w.Code, w.Body.String()) } var single domain.Execution if err := json.Unmarshal(w.Body.Bytes(), &single); err != nil { t.Fatalf("decode: %v", err) } if single.ID != exec.ID { t.Errorf("expected %s, got %s", exec.ID, single.ID) } }