a73x

internal/server/api/spec/spec_test.go

Ref:   Size: 7.6 KiB   History

package spec_test

import (
	"bytes"
	"encoding/json"
	"strings"
	"testing"

	"github.com/a73x/eitri/internal/server/api"
	"github.com/a73x/eitri/internal/server/api/spec"
)

// generate runs the generator once and unmarshals the document.
func generate(t *testing.T) (map[string]any, []byte) {
	t.Helper()
	out, err := spec.Generate()
	if err != nil {
		t.Fatalf("Generate: %v", err)
	}
	var doc map[string]any
	if err := json.Unmarshal(out, &doc); err != nil {
		t.Fatalf("generated spec is not valid JSON: %v", err)
	}
	return doc, out
}

// dig walks nested map[string]any keys, failing the test on a missing step.
func dig(t *testing.T, v any, keys ...string) any {
	t.Helper()
	for _, k := range keys {
		m, ok := v.(map[string]any)
		if !ok {
			t.Fatalf("dig %v: not an object at %q", keys, k)
		}
		v, ok = m[k]
		if !ok {
			t.Fatalf("dig %v: missing key %q", keys, k)
		}
	}
	return v
}

func TestOpenAPIVersion(t *testing.T) {
	doc, _ := generate(t)
	if got := doc["openapi"]; got != "3.1.0" {
		t.Errorf("openapi = %v, want 3.1.0", got)
	}
}

func TestEveryRouteHasAnOperation(t *testing.T) {
	doc, _ := generate(t)
	for _, r := range api.Routes() {
		op, ok := dig(t, doc, "paths", r.Path).(map[string]any)[strings.ToLower(r.Method)]
		if !ok {
			t.Errorf("%s %s: no operation in paths", r.Method, r.Path)
			continue
		}
		// {id}/{tenant} path params must be declared required string params.
		for _, name := range []string{"id", "tenant"} {
			if !strings.Contains(r.Path, "{"+name+"}") {
				continue
			}
			var found bool
			params, _ := op.(map[string]any)["parameters"].([]any)
			for _, p := range params {
				pm := p.(map[string]any)
				if pm["name"] == name && pm["in"] == "path" {
					found = true
					if pm["required"] != true {
						t.Errorf("%s %s: path param %q not required", r.Method, r.Path, name)
					}
					if typ := dig(t, pm, "schema", "type"); typ != "string" {
						t.Errorf("%s %s: path param %q type = %v, want string", r.Method, r.Path, name, typ)
					}
				}
			}
			if !found {
				t.Errorf("%s %s: path param %q not declared", r.Method, r.Path, name)
			}
		}
	}
}

func TestCreateVMOperation(t *testing.T) {
	doc, _ := generate(t)
	op := dig(t, doc, "paths", "/api/v1/vms", "post")

	// The requestBody schema is a plain $ref — never wrapped in anyOf/nullable.
	reqSchema := dig(t, op, "requestBody", "content", "application/json", "schema").(map[string]any)
	if len(reqSchema) != 1 {
		t.Errorf("requestBody schema has extra keys: %v", reqSchema)
	}
	if ref, _ := reqSchema["$ref"].(string); !strings.HasSuffix(ref, "CreateVMRequest") {
		t.Errorf("requestBody $ref = %v, want ...CreateVMRequest", reqSchema["$ref"])
	}

	respRef := dig(t, op, "responses", "201", "content", "application/json", "schema", "$ref").(string)
	if !strings.HasSuffix(respRef, "CreateVMResponse") {
		t.Errorf("201 $ref = %q, want ...CreateVMResponse", respRef)
	}
}

func TestRequiredArrays(t *testing.T) {
	doc, _ := generate(t)

	// Request schemas claim NO required fields: the server defaults absent
	// fields and hand-rolled validation is the authority.
	req := dig(t, doc, "components", "schemas", "CreateVMRequest").(map[string]any)
	if _, ok := req["required"]; ok {
		t.Errorf("CreateVMRequest has a required array: %v", req["required"])
	}

	// Response schemas DO claim required (writeJSON always emits every field),
	// and the array is sorted for deterministic output.
	host := dig(t, doc, "components", "schemas", "Host").(map[string]any)
	raw, ok := host["required"].([]any)
	if !ok {
		t.Fatalf("Host has no required array")
	}
	var required []string
	for _, v := range raw {
		required = append(required, v.(string))
	}
	if len(required) == 0 {
		t.Fatal("Host required array is empty")
	}
	for i := 1; i < len(required); i++ {
		if required[i-1] >= required[i] {
			t.Errorf("Host required not sorted: %q before %q", required[i-1], required[i])
		}
	}
	// Pointer fields are nullable, not required.
	for _, name := range []string{"last_seen", "seconds_since_last_seen", "metrics"} {
		for _, r := range required {
			if r == name {
				t.Errorf("pointer field %q must not be required", name)
			}
		}
	}
}

func TestSecurity(t *testing.T) {
	doc, _ := generate(t)
	for _, r := range api.Routes() {
		op := dig(t, doc, "paths", r.Path, strings.ToLower(r.Method)).(map[string]any)
		sec, has := op["security"]
		if r.Auth == api.AuthUser {
			want := []any{map[string]any{"patToken": []any{}}}
			if !has {
				t.Errorf("%s %s: user route missing security", r.Method, r.Path)
			} else if wantJSON, _ := json.Marshal(want); string(mustJSON(t, sec)) != string(wantJSON) {
				t.Errorf("%s %s: security = %v", r.Method, r.Path, sec)
			}
		} else if has {
			t.Errorf("%s %s: unauthenticated route carries security %v", r.Method, r.Path, sec)
		}
	}
}

func mustJSON(t *testing.T, v any) []byte {
	t.Helper()
	b, err := json.Marshal(v)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	return b
}

func TestHostSchemaFields(t *testing.T) {
	doc, _ := generate(t)
	props := dig(t, doc, "components", "schemas", "Host", "properties")

	// Nested contract struct → $ref.
	if ref := dig(t, props, "capacity", "$ref").(string); !strings.HasSuffix(ref, "Capacity") {
		t.Errorf("capacity $ref = %q", ref)
	}

	// Pointer-to-struct field → anyOf [$ref, {type: null}].
	anyOf, ok := dig(t, props, "metrics", "anyOf").([]any)
	if !ok || len(anyOf) != 2 {
		t.Fatalf("metrics anyOf = %v", dig(t, props, "metrics"))
	}
	if ref, _ := anyOf[0].(map[string]any)["$ref"].(string); !strings.HasSuffix(ref, "Metrics") {
		t.Errorf("metrics anyOf[0] = %v, want $ref ...Metrics", anyOf[0])
	}
	if typ, _ := anyOf[1].(map[string]any)["type"].(string); typ != "null" {
		t.Errorf("metrics anyOf[1] = %v, want {type: null}", anyOf[1])
	}

	// Pointer-to-time field → type ["string","null"] with date-time format.
	lastSeen := dig(t, props, "last_seen").(map[string]any)
	typJSON := string(mustJSON(t, lastSeen["type"]))
	if typJSON != `["string","null"]` {
		t.Errorf("last_seen type = %s, want [\"string\",\"null\"]", typJSON)
	}
	if lastSeen["format"] != "date-time" {
		t.Errorf("last_seen format = %v, want date-time", lastSeen["format"])
	}
}

func TestSSEAndWSResponses(t *testing.T) {
	doc, _ := generate(t)

	// SSE stream: 200 with a text/event-stream body carrying StateSnapshot.
	ref := dig(t, doc, "paths", "/api/v1/events", "get", "responses", "200",
		"content", "text/event-stream", "schema", "$ref").(string)
	if !strings.HasSuffix(ref, "StateSnapshot") {
		t.Errorf("events stream $ref = %q, want ...StateSnapshot", ref)
	}

	// Console WebSocket: a 101 response with no content schema.
	ws := dig(t, doc, "paths", "/api/v1/vms/{id}/console/ws", "get", "responses", "101").(map[string]any)
	if _, has := ws["content"]; has {
		t.Errorf("console/ws 101 response carries content: %v", ws["content"])
	}
}

func TestDefaultErrorResponse(t *testing.T) {
	doc, _ := generate(t)
	for _, r := range api.Routes() {
		op := dig(t, doc, "paths", r.Path, strings.ToLower(r.Method))
		typ := dig(t, op, "responses", "default", "content", "text/plain", "schema", "type")
		if typ != "string" {
			t.Errorf("%s %s: default error schema type = %v, want string", r.Method, r.Path, typ)
		}
	}
}

func TestSecuritySchemes(t *testing.T) {
	doc, _ := generate(t)
	scheme := dig(t, doc, "components", "securitySchemes", "patToken").(map[string]any)
	if scheme["type"] != "http" || scheme["scheme"] != "bearer" {
		t.Errorf("patToken scheme = %v, want {type: http, scheme: bearer}", scheme)
	}
}

func TestDeterministicOutput(t *testing.T) {
	_, first := generate(t)
	_, second := generate(t)
	if !bytes.Equal(first, second) {
		t.Fatal("two Generate() calls differ")
	}
	if !bytes.HasSuffix(first, []byte("\n")) {
		t.Error("output missing trailing newline")
	}
}