Files
DDI/server/internal/httpapi/harness_test.go
T

318 lines
9.9 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/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))
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())
})
}
// 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
}
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 {
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()
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"}})
opts := Options{Service: svc, ProductName: "DDI-Suite", Version: "test", Logger: log}
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()))
srv.Start()
t.Cleanup(srv.Close)
return &env{t: t, srv: srv, svc: svc, st: st, lic: lic, now: &now, pki: authority, hub: hub}
}
// 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
}