- PATCH /agents/{id} switches the mode and reconnects the agent
- POST /revisions/{n}/deploy creates pending push deployments, sends
ApplyRevisionCommand with the deployment ID, re-sends open pushes on
connect and announces them in GetDesiredState; reports with the ID
complete the deployment
- POST /revisions/{n}/rollback re-renders the stored generator input
with the next revision number
- GET /revisions/{n}/diff and GET /config/pending with unified diffs
- GET /events streams agent, deployment, revision and pending events
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
367 lines
12 KiB
Go
367 lines
12 KiB
Go
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
|
|
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/getkin/kin-openapi/openapi3"
|
|
"github.com/getkin/kin-openapi/openapi3filter"
|
|
"github.com/getkin/kin-openapi/routers"
|
|
"github.com/getkin/kin-openapi/routers/legacy"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/server/api"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/events"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/httpserver"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/license"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/pki"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/service"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/store"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/store/storetest"
|
|
)
|
|
|
|
// skipRequestValidation marks requests that are deliberately invalid; their
|
|
// responses are still validated.
|
|
const skipRequestValidation = "X-Test-Invalid-Request"
|
|
|
|
var (
|
|
specOnce sync.Once
|
|
specDoc *openapi3.T
|
|
specRouter routers.Router
|
|
specErr error
|
|
)
|
|
|
|
func loadSpec(t testing.TB) (*openapi3.T, routers.Router) {
|
|
t.Helper()
|
|
specOnce.Do(func() {
|
|
openapi3filter.RegisterBodyDecoder("text/csv", openapi3filter.FileBodyDecoder)
|
|
openapi3filter.RegisterBodyDecoder("application/x-ndjson", openapi3filter.FileBodyDecoder)
|
|
loader := openapi3.NewLoader()
|
|
specDoc, specErr = loader.LoadFromData(api.OpenAPI)
|
|
if specErr != nil {
|
|
return
|
|
}
|
|
if specErr = specDoc.Validate(context.Background()); specErr != nil {
|
|
return
|
|
}
|
|
// Absolute server URL so that the router can match test requests.
|
|
specDoc.Servers = openapi3.Servers{{URL: "http://ddi.test/api/v1"}}
|
|
specRouter, specErr = legacy.NewRouter(specDoc)
|
|
})
|
|
if specErr != nil {
|
|
t.Fatalf("OpenAPI spec: %v", specErr)
|
|
}
|
|
return specDoc, specRouter
|
|
}
|
|
|
|
// contractHandler validates every request and response against the spec.
|
|
func contractHandler(t *testing.T, next http.Handler) http.Handler {
|
|
_, router := loadSpec(t)
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.HasPrefix(r.URL.Path, "/api/") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
vr := r.Clone(r.Context())
|
|
vr.URL.Scheme, vr.URL.Host, vr.Host = "http", "ddi.test", "ddi.test"
|
|
vr.Body = io.NopCloser(bytes.NewReader(body))
|
|
|
|
route, params, err := router.FindRoute(vr)
|
|
if err != nil {
|
|
if r.Header.Get(skipRequestValidation) == "" {
|
|
t.Errorf("contract: %s %s is not in the spec: %v", r.Method, r.URL.Path, err)
|
|
}
|
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
in := &openapi3filter.RequestValidationInput{Request: vr, PathParams: params, Route: route,
|
|
Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, IncludeResponseStatus: true}}
|
|
if r.Header.Get(skipRequestValidation) == "" {
|
|
if err := openapi3filter.ValidateRequest(r.Context(), in); err != nil {
|
|
t.Errorf("contract: request %s %s violates the spec: %v", r.Method, r.URL.Path, err)
|
|
}
|
|
}
|
|
|
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
|
if route.Operation.OperationID == "streamEvents" {
|
|
// A stream cannot be buffered: check status and content
|
|
// type as they are written (TestEventStream checks the rest).
|
|
next.ServeHTTP(&streamChecker{ResponseWriter: w, t: t, path: r.URL.Path}, r)
|
|
return
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
next.ServeHTTP(rec, r)
|
|
|
|
out := &openapi3filter.ResponseValidationInput{RequestValidationInput: in, Status: rec.Code, Header: rec.Header(),
|
|
Options: &openapi3filter.Options{IncludeResponseStatus: true}}
|
|
out.SetBodyBytes(rec.Body.Bytes())
|
|
if err := openapi3filter.ValidateResponse(r.Context(), out); err != nil {
|
|
t.Errorf("contract: response %d of %s %s violates the spec: %v\nbody: %s", rec.Code, r.Method, r.URL.Path, err, rec.Body.String())
|
|
}
|
|
|
|
for k, v := range rec.Header() {
|
|
w.Header()[k] = v
|
|
}
|
|
w.WriteHeader(rec.Code)
|
|
_, _ = w.Write(rec.Body.Bytes())
|
|
})
|
|
}
|
|
|
|
// streamChecker validates the headers of a streaming response.
|
|
type streamChecker struct {
|
|
http.ResponseWriter
|
|
t *testing.T
|
|
path string
|
|
wrote bool
|
|
}
|
|
|
|
func (s *streamChecker) WriteHeader(code int) {
|
|
if !s.wrote {
|
|
s.wrote = true
|
|
ct := s.Header().Get("Content-Type")
|
|
switch {
|
|
case code == http.StatusOK && ct != "text/event-stream":
|
|
s.t.Errorf("contract: stream %s has content type %q", s.path, ct)
|
|
case code != http.StatusOK && ct != "application/problem+json":
|
|
s.t.Errorf("contract: error %d of %s has content type %q", code, s.path, ct)
|
|
}
|
|
}
|
|
s.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (s *streamChecker) Write(p []byte) (int, error) {
|
|
if !s.wrote {
|
|
s.WriteHeader(http.StatusOK)
|
|
}
|
|
return s.ResponseWriter.Write(p)
|
|
}
|
|
|
|
// Unwrap lets http.ResponseController reach Flush and the deadlines.
|
|
func (s *streamChecker) Unwrap() http.ResponseWriter { return s.ResponseWriter }
|
|
|
|
// env is a running API backed by a fresh database.
|
|
type env struct {
|
|
t *testing.T
|
|
srv *httptest.Server
|
|
svc *service.Service
|
|
st *store.Store
|
|
lic *license.Manager
|
|
now *time.Time
|
|
pki *pki.Authority
|
|
hub *agenthub.Hub
|
|
bus *events.Bus
|
|
}
|
|
|
|
type stubProvider struct{}
|
|
|
|
func (stubProvider) Validate(context.Context, string, string) (*license.Validation, error) {
|
|
return &license.Validation{Code: "NOT_FOUND"}, nil
|
|
}
|
|
func (stubProvider) Activate(context.Context, string, string, string, string) error { return nil }
|
|
func (stubProvider) Entitlements(context.Context, string, string) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
func (stubProvider) Heartbeat(context.Context, string, string) error { return nil }
|
|
func (stubProvider) Deactivate(context.Context, string, string) error { return nil }
|
|
|
|
type storePersistence struct{ st *store.Store }
|
|
|
|
func (p storePersistence) GetLicenseState(ctx context.Context) ([]byte, error) {
|
|
b, err := p.st.Q().GetLicenseState(ctx)
|
|
if err == store.ErrNotFound { //nolint:errorlint // sentinel returned as is
|
|
return nil, license.ErrNoState
|
|
}
|
|
return b, err
|
|
}
|
|
func (p storePersistence) PutLicenseState(ctx context.Context, d []byte) error {
|
|
return p.st.Q().PutLicenseState(ctx, d)
|
|
}
|
|
func (p storePersistence) DeleteLicenseState(ctx context.Context) error {
|
|
return p.st.Q().DeleteLicenseState(ctx)
|
|
}
|
|
func (p storePersistence) EnsureInstalledAt(ctx context.Context, now time.Time) (time.Time, error) {
|
|
return p.st.Q().EnsureInstalledAt(ctx, now)
|
|
}
|
|
|
|
func newEnv(t *testing.T) *env { return newEnvWith(t, nil) }
|
|
|
|
// newEnvWith allows tests to adjust the API options before start.
|
|
func newEnvWith(t *testing.T, adjust func(*Options)) *env { return newEnvServer(t, adjust, nil) }
|
|
|
|
// newEnvServer also allows tests to adjust the HTTP server (timeouts).
|
|
func newEnvServer(t *testing.T, adjust func(*Options), adjustServer func(*http.Server)) *env {
|
|
t.Helper()
|
|
st := storetest.New(t)
|
|
keyData, _ := secrets.GenerateKeyFile("k1")
|
|
kr, err := secrets.ParseKeyFile(keyData)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Now().UTC()
|
|
clock := func() time.Time { return now }
|
|
log := slog.New(slog.DiscardHandler)
|
|
lic := license.NewManager(license.ManagerOptions{Provider: stubProvider{}, Persistence: storePersistence{st},
|
|
Keyring: kr, Fingerprint: "fp", Grace: 14 * 24 * time.Hour, Now: clock, Logger: log})
|
|
if err := lic.Load(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
authority, err := pki.LoadOrCreate(context.Background(), st, kr, log, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hub := agenthub.New()
|
|
bus := events.New()
|
|
svc := service.New(service.Options{Store: st, Keyring: kr, License: lic, ProductName: "DDI-Suite", Logger: log,
|
|
Now: func() time.Time { return now }, PKI: authority, Hub: hub, AgentAPIAddress: "ddi.example.test:8444",
|
|
AgentServerNames: []string{"ddi.example.test"}, Events: bus})
|
|
opts := Options{Service: svc, ProductName: "DDI-Suite", Version: "test", Logger: log, EventKeepalive: 100 * time.Millisecond}
|
|
srv := httptest.NewUnstartedServer(nil)
|
|
if adjust != nil {
|
|
opts.PublicURL = "http://" + srv.Listener.Addr().String()
|
|
adjust(&opts)
|
|
}
|
|
a := New(opts)
|
|
srv.Config.Handler = httpserver.RequestID(contractHandler(t, a.Handler()))
|
|
if adjustServer != nil {
|
|
adjustServer(srv.Config)
|
|
}
|
|
srv.Start()
|
|
t.Cleanup(srv.Close)
|
|
// Runs first: open event streams end, so that Close does not wait.
|
|
t.Cleanup(bus.Close)
|
|
return &env{t: t, srv: srv, svc: svc, st: st, lic: lic, now: &now, pki: authority, hub: hub, bus: bus}
|
|
}
|
|
|
|
// advance moves the shared clock (sessions, license).
|
|
func (e *env) advance(d time.Duration) { *e.now = e.now.Add(d) }
|
|
|
|
// client is a browser-like client with cookie jar and CSRF token.
|
|
type client struct {
|
|
e *env
|
|
http *http.Client
|
|
csrf string
|
|
}
|
|
|
|
func (e *env) client() *client {
|
|
jar, _ := cookiejar.New(nil)
|
|
return &client{e: e, http: &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}}}
|
|
}
|
|
|
|
type response struct {
|
|
Status int
|
|
Header http.Header
|
|
Body []byte
|
|
}
|
|
|
|
func (r *response) json(t *testing.T, v any) {
|
|
t.Helper()
|
|
if err := json.Unmarshal(r.Body, v); err != nil {
|
|
t.Fatalf("decode %s: %v", r.Body, err)
|
|
}
|
|
}
|
|
|
|
func (r *response) problem(t *testing.T) Problem {
|
|
t.Helper()
|
|
var p Problem
|
|
r.json(t, &p)
|
|
return p
|
|
}
|
|
|
|
func (c *client) do(method, path string, body any, hdr ...string) *response {
|
|
c.e.t.Helper()
|
|
var rdr io.Reader
|
|
ct := ""
|
|
switch b := body.(type) {
|
|
case nil:
|
|
case string:
|
|
rdr, ct = strings.NewReader(b), "text/csv"
|
|
default:
|
|
data, err := json.Marshal(b)
|
|
if err != nil {
|
|
c.e.t.Fatal(err)
|
|
}
|
|
rdr, ct = bytes.NewReader(data), "application/json"
|
|
}
|
|
req, err := http.NewRequestWithContext(context.Background(), method, c.e.srv.URL+path, rdr)
|
|
if err != nil {
|
|
c.e.t.Fatal(err)
|
|
}
|
|
if ct != "" {
|
|
req.Header.Set("Content-Type", ct)
|
|
}
|
|
if c.csrf != "" {
|
|
req.Header.Set(CSRFHeader, c.csrf)
|
|
}
|
|
for i := 0; i+1 < len(hdr); i += 2 {
|
|
req.Header.Set(hdr[i], hdr[i+1])
|
|
}
|
|
res, err := c.http.Do(req)
|
|
if err != nil {
|
|
c.e.t.Fatal(err)
|
|
}
|
|
defer res.Body.Close() //nolint:errcheck // test
|
|
data, _ := io.ReadAll(res.Body)
|
|
return &response{Status: res.StatusCode, Header: res.Header, Body: data}
|
|
}
|
|
|
|
func (c *client) mustStatus(want int, method, path string, body any, hdr ...string) *response {
|
|
c.e.t.Helper()
|
|
r := c.do(method, path, body, hdr...)
|
|
if r.Status != want {
|
|
c.e.t.Fatalf("%s %s: status %d, want %d\n%s", method, path, r.Status, want, r.Body)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// login signs in and stores the CSRF token.
|
|
func (c *client) login(username, password string) sessionResponse {
|
|
c.e.t.Helper()
|
|
r := c.mustStatus(200, "POST", "/api/v1/auth/login", loginRequest{Username: username, Password: password})
|
|
var s sessionResponse
|
|
r.json(c.e.t, &s)
|
|
c.csrf = s.CSRFToken
|
|
return s
|
|
}
|
|
|
|
// adminClient bootstraps the first admin, changes the password and returns
|
|
// a logged-in client.
|
|
func (e *env) adminClient() *client {
|
|
e.t.Helper()
|
|
pw, err := e.svc.BootstrapAdmin(context.Background(), "admin")
|
|
if err != nil {
|
|
e.t.Fatal(err)
|
|
}
|
|
c := e.client()
|
|
c.login("admin", pw)
|
|
c.mustStatus(204, "POST", "/api/v1/auth/password", passwordChangeRequest{CurrentPassword: pw, NewPassword: "admin-password-123"})
|
|
return c
|
|
}
|
|
|
|
// userClient creates a user with role via admin and logs in as that user.
|
|
func (e *env) userClient(admin *client, username, role string) *client {
|
|
e.t.Helper()
|
|
r := admin.mustStatus(201, "POST", "/api/v1/users", map[string]any{"username": username, "role": role, "password": "initial-password-1"})
|
|
_ = r
|
|
c := e.client()
|
|
c.login(username, "initial-password-1")
|
|
c.mustStatus(204, "POST", "/api/v1/auth/password", passwordChangeRequest{CurrentPassword: "initial-password-1", NewPassword: "changed-password-1"})
|
|
return c
|
|
}
|