575 lines
12 KiB
Go
575 lines
12 KiB
Go
package controllers_test
|
|
|
|
import (
|
|
"context"
|
|
|
|
"encoding/json"
|
|
|
|
"io"
|
|
|
|
"log/slog"
|
|
|
|
"net/http"
|
|
|
|
"net/http/httptest"
|
|
|
|
"os"
|
|
|
|
"os/exec"
|
|
|
|
"path/filepath"
|
|
|
|
"strings"
|
|
|
|
"testing"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
|
|
|
|
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
|
|
)
|
|
|
|
// emptyGetManager returns a GetResult that sets neither Project nor Degraded —
|
|
|
|
// a Manager-contract violation — so the test can prove the handler answers a
|
|
|
|
// clean 500 before writing the 200 status.
|
|
|
|
type emptyGetManager struct{ projectsvc.Manager }
|
|
|
|
func (emptyGetManager) Get(context.Context, domain.ProjectID) (projectsvc.GetResult, error) {
|
|
|
|
return projectsvc.GetResult{}, nil
|
|
|
|
}
|
|
|
|
// TestProjectsAPI_GetEmptyResultIs500 locks the fix for the discriminated-union
|
|
|
|
// invariant: a degenerate GetResult must surface as a parseable 500 envelope,
|
|
|
|
// not a 200 with truncated JSON.
|
|
|
|
func TestProjectsAPI_GetEmptyResultIs500(t *testing.T) {
|
|
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
|
|
srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{
|
|
|
|
Projects: emptyGetManager{},
|
|
}, httpd.ControlDeps{}))
|
|
|
|
t.Cleanup(srv.Close)
|
|
|
|
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/whatever", "")
|
|
|
|
assertJSON(t, headers)
|
|
|
|
assertErrorCode(t, body, status, http.StatusInternalServerError, "INTERNAL_ERROR")
|
|
|
|
}
|
|
|
|
func newTestServer(t *testing.T) *httptest.Server {
|
|
|
|
t.Helper()
|
|
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
|
|
store, err := sqlite.Open(t.TempDir())
|
|
|
|
if err != nil {
|
|
|
|
t.Fatalf("open store: %v", err)
|
|
|
|
}
|
|
|
|
t.Cleanup(func() { _ = store.Close() })
|
|
|
|
srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{
|
|
|
|
Projects: projectsvc.New(store),
|
|
}, httpd.ControlDeps{}))
|
|
|
|
t.Cleanup(srv.Close)
|
|
|
|
return srv
|
|
|
|
}
|
|
|
|
func TestProjectsRoutes_DefaultToStubsWithoutManager(t *testing.T) {
|
|
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
|
|
srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{}, httpd.ControlDeps{}))
|
|
|
|
t.Cleanup(srv.Close)
|
|
|
|
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "")
|
|
|
|
assertJSON(t, headers)
|
|
|
|
assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
|
|
|
|
}
|
|
|
|
func TestProjectsAPI_ListAddGet(t *testing.T) {
|
|
|
|
srv := newTestServer(t)
|
|
|
|
repo := gitRepo(t, "agent-orchestrator")
|
|
|
|
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "")
|
|
|
|
if status != http.StatusOK {
|
|
|
|
t.Fatalf("GET projects = %d, want 200; body=%s", status, body)
|
|
|
|
}
|
|
|
|
assertJSON(t, headers)
|
|
|
|
var list struct {
|
|
Projects []projectSummary `json:"projects"`
|
|
}
|
|
|
|
mustJSON(t, body, &list)
|
|
|
|
if len(list.Projects) != 0 {
|
|
|
|
t.Fatalf("initial project count = %d, want 0", len(list.Projects))
|
|
|
|
}
|
|
|
|
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"ao","name":"Agent Orchestrator"}`)
|
|
|
|
if status != http.StatusCreated {
|
|
|
|
t.Fatalf("POST project = %d, want 201; body=%s", status, body)
|
|
|
|
}
|
|
|
|
var add struct {
|
|
Project projectBody `json:"project"`
|
|
}
|
|
|
|
mustJSON(t, body, &add)
|
|
|
|
if add.Project.ID != "ao" || add.Project.Name != "Agent Orchestrator" || add.Project.DefaultBranch != "main" {
|
|
|
|
t.Fatalf("created project = %#v", add.Project)
|
|
|
|
}
|
|
|
|
body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/ao", "")
|
|
|
|
if status != http.StatusOK {
|
|
|
|
t.Fatalf("GET project = %d, want 200; body=%s", status, body)
|
|
|
|
}
|
|
|
|
var get struct {
|
|
Status string `json:"status"`
|
|
|
|
Project projectBody `json:"project"`
|
|
}
|
|
|
|
mustJSON(t, body, &get)
|
|
|
|
if get.Status != "ok" || get.Project.ID != "ao" {
|
|
|
|
t.Fatalf("get response = %#v", get)
|
|
|
|
}
|
|
|
|
body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects", "")
|
|
if status != http.StatusOK {
|
|
t.Fatalf("GET projects after add = %d, want 200; body=%s", status, body)
|
|
}
|
|
mustJSON(t, body, &list)
|
|
if len(list.Projects) != 1 || list.Projects[0].Path != repo {
|
|
t.Fatalf("project summary path = %#v, want path %q", list.Projects, repo)
|
|
}
|
|
|
|
}
|
|
|
|
func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) {
|
|
|
|
srv := newTestServer(t)
|
|
|
|
repoA := gitRepo(t, "repo-a")
|
|
|
|
repoB := gitRepo(t, "repo-b")
|
|
|
|
notRepo := t.TempDir()
|
|
|
|
cases := []struct {
|
|
name, body, wantCode string
|
|
|
|
wantStatus int
|
|
}{
|
|
|
|
{name: "invalid json", body: `{`, wantStatus: 400, wantCode: "INVALID_JSON"},
|
|
|
|
{name: "missing path", body: `{}`, wantStatus: 400, wantCode: "PATH_REQUIRED"},
|
|
|
|
{name: "not git", body: `{"path":` + quote(notRepo) + `}`, wantStatus: 400, wantCode: "NOT_A_GIT_REPO"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", tc.body)
|
|
|
|
assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"shared"}`)
|
|
|
|
if status != http.StatusCreated {
|
|
|
|
t.Fatalf("seed create = %d, want 201; body=%s", status, body)
|
|
|
|
}
|
|
|
|
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"other"}`)
|
|
|
|
assertErrorCode(t, body, status, http.StatusConflict, "PATH_ALREADY_REGISTERED")
|
|
|
|
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoB)+`,"projectId":"shared"}`)
|
|
|
|
assertErrorCode(t, body, status, http.StatusConflict, "ID_ALREADY_REGISTERED")
|
|
|
|
}
|
|
|
|
func TestProjectsAPI_Delete(t *testing.T) {
|
|
|
|
srv := newTestServer(t)
|
|
|
|
repo := gitRepo(t, "repo")
|
|
|
|
body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"proj"}`)
|
|
|
|
if status != http.StatusCreated {
|
|
|
|
t.Fatalf("seed create = %d, want 201; body=%s", status, body)
|
|
|
|
}
|
|
|
|
body, status, _ = doRequest(t, srv, "DELETE", "/api/v1/projects/proj", "")
|
|
|
|
if status != http.StatusOK {
|
|
|
|
t.Fatalf("DELETE = %d, want 200; body=%s", status, body)
|
|
|
|
}
|
|
|
|
var removed struct {
|
|
ProjectID string `json:"projectId"`
|
|
|
|
RemovedStorageDir bool `json:"removedStorageDir"`
|
|
}
|
|
|
|
mustJSON(t, body, &removed)
|
|
|
|
if removed.ProjectID != "proj" || removed.RemovedStorageDir {
|
|
|
|
t.Fatalf("delete response = %#v", removed)
|
|
|
|
}
|
|
|
|
body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/proj", "")
|
|
|
|
if status != http.StatusNotFound {
|
|
|
|
t.Fatalf("GET archived project = %d, want 404; body=%s", status, body)
|
|
|
|
}
|
|
|
|
body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects", "")
|
|
|
|
if status != http.StatusOK {
|
|
|
|
t.Fatalf("GET projects after archive = %d, want 200; body=%s", status, body)
|
|
|
|
}
|
|
|
|
var list struct {
|
|
Projects []projectSummary `json:"projects"`
|
|
}
|
|
|
|
mustJSON(t, body, &list)
|
|
|
|
if len(list.Projects) != 0 {
|
|
|
|
t.Fatalf("active projects after archive = %d, want 0", len(list.Projects))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// TestProjectsAPI_RejectsUnknownConfigKeys locks the strict-decoder gate on the
|
|
// project config endpoints: a misspelled or removed field surfaces as a clear
|
|
// 400 instead of being silently dropped, so the API cannot accumulate dead
|
|
// config the daemon never reads.
|
|
func TestProjectsAPI_RejectsUnknownConfigKeys(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
repo := gitRepo(t, "rejects-unknown")
|
|
body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"rej"}`)
|
|
if status != http.StatusCreated {
|
|
t.Fatalf("seed create = %d, want 201; body=%s", status, body)
|
|
}
|
|
|
|
// PUT a config body with an extraneous top-level key.
|
|
body, status, _ = doRequest(t, srv, "PUT", "/api/v1/projects/rej/config", `{"config":{"defaultBranch":"develop"},"surprise":"!"}`)
|
|
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
|
|
|
|
// PUT a config body with a removed field inside the nested config — the
|
|
// canonical regression: agentRules / tracker are no longer modelled, so
|
|
// projects can't sneak them back in.
|
|
body, status, _ = doRequest(t, srv, "PUT", "/api/v1/projects/rej/config", `{"config":{"agentRules":"x"}}`)
|
|
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
|
|
body, status, _ = doRequest(t, srv, "PUT", "/api/v1/projects/rej/config", `{"config":{"tracker":{"plugin":"github"}}}`)
|
|
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
|
|
|
|
// POST /projects gets the same gate, so add-time config rides the same rail.
|
|
otherRepo := gitRepo(t, "rejects-unknown-add")
|
|
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(otherRepo)+`,"projectId":"rej2","config":{"orchestratorRules":"x"}}`)
|
|
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
|
|
}
|
|
|
|
func TestProjectsRoutes_LegacyUnregistered(t *testing.T) {
|
|
|
|
srv := newTestServer(t)
|
|
|
|
cases := []struct {
|
|
method, path, wantCode, why string
|
|
|
|
wantStatus int
|
|
}{
|
|
|
|
{method: "PUT", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R3 PUT not registered"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
|
|
t.Run(tc.why, func(t *testing.T) {
|
|
|
|
body, status, _ := doRequest(t, srv, tc.method, tc.path, "")
|
|
|
|
assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
func TestProjectsRoutes_MissingRoute(t *testing.T) {
|
|
|
|
srv := newTestServer(t)
|
|
|
|
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/p1/does-not-exist", "")
|
|
|
|
assertJSON(t, headers)
|
|
|
|
assertErrorCode(t, body, status, http.StatusNotFound, "ROUTE_NOT_FOUND")
|
|
|
|
}
|
|
|
|
func TestOpenAPIYAMLServed(t *testing.T) {
|
|
|
|
srv := newTestServer(t)
|
|
|
|
body, status, headers := doRequest(t, srv, "GET", "/api/v1/openapi.yaml", "")
|
|
|
|
if status != http.StatusOK {
|
|
|
|
t.Fatalf("status = %d, want 200", status)
|
|
|
|
}
|
|
|
|
if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") {
|
|
|
|
t.Errorf("Content-Type = %q, want application/yaml*", ct)
|
|
|
|
}
|
|
|
|
if !strings.Contains(string(body), "openapi: 3.1.0") {
|
|
|
|
t.Errorf("served body did not start with an OpenAPI 3.1 doc")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
type projectSummary struct {
|
|
ID string `json:"id"`
|
|
|
|
Name string `json:"name"`
|
|
|
|
Path string `json:"path"`
|
|
|
|
SessionPrefix string `json:"sessionPrefix"`
|
|
}
|
|
|
|
type projectBody struct {
|
|
ID string `json:"id"`
|
|
|
|
Name string `json:"name"`
|
|
|
|
Path string `json:"path"`
|
|
|
|
Repo string `json:"repo"`
|
|
|
|
DefaultBranch string `json:"defaultBranch"`
|
|
|
|
Agent string `json:"agent"`
|
|
}
|
|
|
|
type errorBody struct {
|
|
Error string `json:"error"`
|
|
|
|
Code string `json:"code"`
|
|
|
|
Message string `json:"message"`
|
|
|
|
Details map[string]any `json:"details"`
|
|
}
|
|
|
|
func doRequest(t *testing.T, srv *httptest.Server, method, path, body string) ([]byte, int, http.Header) {
|
|
|
|
t.Helper()
|
|
|
|
var req *http.Request
|
|
|
|
var err error
|
|
|
|
if body != "" {
|
|
|
|
req, err = http.NewRequest(method, srv.URL+path, strings.NewReader(body))
|
|
|
|
} else {
|
|
|
|
req, err = http.NewRequest(method, srv.URL+path, nil)
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
t.Fatalf("new request: %v", err)
|
|
|
|
}
|
|
|
|
if body != "" {
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
}
|
|
|
|
resp, err := srv.Client().Do(req)
|
|
|
|
if err != nil {
|
|
|
|
t.Fatalf("do request: %v", err)
|
|
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
buf, err := io.ReadAll(resp.Body)
|
|
|
|
if err != nil {
|
|
|
|
t.Fatalf("read body: %v", err)
|
|
|
|
}
|
|
|
|
return buf, resp.StatusCode, resp.Header
|
|
|
|
}
|
|
|
|
func gitRepo(t *testing.T, name string) string {
|
|
|
|
t.Helper()
|
|
|
|
dir := filepath.Join(t.TempDir(), name)
|
|
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
|
|
t.Fatalf("create git repo fixture: %v", err)
|
|
|
|
}
|
|
|
|
if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil {
|
|
|
|
t.Fatalf("git init fixture: %v\n%s", err, out)
|
|
|
|
}
|
|
|
|
return dir
|
|
|
|
}
|
|
|
|
func quote(s string) string {
|
|
|
|
b, _ := json.Marshal(s)
|
|
|
|
return string(b)
|
|
|
|
}
|
|
|
|
func mustJSON(t *testing.T, body []byte, out any) {
|
|
|
|
t.Helper()
|
|
|
|
if err := json.Unmarshal(body, out); err != nil {
|
|
|
|
t.Fatalf("unmarshal: %v\nbody=%s", err, body)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
func assertJSON(t *testing.T, headers http.Header) {
|
|
|
|
t.Helper()
|
|
|
|
if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
|
|
|
t.Fatalf("Content-Type = %q, want JSON", ct)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
func assertErrorCode(t *testing.T, body []byte, status, wantStatus int, wantCode string) {
|
|
|
|
t.Helper()
|
|
|
|
if status != wantStatus {
|
|
|
|
t.Fatalf("status = %d, want %d\nbody=%s", status, wantStatus, body)
|
|
|
|
}
|
|
|
|
var got errorBody
|
|
|
|
mustJSON(t, body, &got)
|
|
|
|
if got.Code != wantCode {
|
|
|
|
t.Fatalf("code = %q, want %q\nbody=%s", got.Code, wantCode, body)
|
|
|
|
}
|
|
|
|
}
|