package httpapi // End-to-end test: full client lifecycle — enroll, activate, deprecate, destroy. // Validates every state transition, key issuance, relationship edges, audit // trail, event emission, and provision status. import ( "encoding/json" "testing" ) func TestClientLifecycleEndToEnd(t *testing.T) { h := newTestHandler(t, devConfig()) // ── Step 0: Create the workstation entity in planned state ── rec, body := do(t, h, "POST", "/api/v1/entities", map[string]any{ "slug": "ws:e2e-test-laptop", "type": "workstation", "name": "E2E Test Laptop", "state": "planned", "attributes": map[string]any{ "os": "macos", "lan_ip": "192.168.8.200", "role": "test-workstation", }, }, nil) if rec.Code != 201 { t.Fatalf("create planned: status %d body %v", rec.Code, body) } t.Logf("✓ created entity in planned state: slug=%v id=%v", body["slug"], body["id"]) // ── Verify planned state persisted ───────────────────────── rec, body = get(t, h, "/api/v1/entities/ws:e2e-test-laptop", nil) if rec.Code != 200 { t.Fatalf("get planned: status %d", rec.Code) } if body["state"] != "planned" { t.Fatalf("state = %v, want planned", body["state"]) } t.Logf("✓ state = planned") // ── Step 1: Enroll (POST /clients/enroll) ────────────────── rec, body = do(t, h, "POST", "/api/v1/clients/enroll", map[string]any{ "slug": "ws:e2e-test-laptop", "hostname": "e2e-test-laptop", "mesh_ip": "100.122.99.88", }, nil) if rec.Code != 200 { t.Fatalf("enroll: status %d body %v", rec.Code, body) } if _, ok := body["age_private_key"]; !ok { t.Fatal("enroll: missing age_private_key") } if _, ok := body["age_public_key"]; !ok { t.Fatal("enroll: missing age_public_key") } t.Logf("✓ enrolled: pubkey=%s", body["age_public_key"]) // ── Step 2: Verify provisioning state + attrs ────────────── rec, body = get(t, h, "/api/v1/entities/ws:e2e-test-laptop", nil) if rec.Code != 200 { t.Fatalf("get provisioning: status %d", rec.Code) } if body["state"] != "provisioning" { t.Fatalf("state = %v, want provisioning", body["state"]) } attrs, _ := body["attributes"].(map[string]any) if attrs["age_pubkey"] == nil { t.Fatal("age_pubkey not set in attributes") } if attrs["mesh_ip"] != "100.122.99.88" { t.Errorf("mesh_ip = %v, want 100.122.99.88", attrs["mesh_ip"]) } t.Logf("✓ state = provisioning, age_pubkey set, mesh_ip set") // ── Step 3: Activate (PATCH state → active) ──────────────── version := int(body["version"].(float64)) etag := rec.Header().Get("ETag") rec, body = do(t, h, "PATCH", "/api/v1/entities/ws:e2e-test-laptop", map[string]any{"state": "active"}, map[string]string{"If-Match": etag}, ) if rec.Code != 200 { t.Fatalf("activate: status %d body %v", rec.Code, body) } if body["state"] != "active" { t.Fatalf("state = %v, want active", body["state"]) } if int(body["version"].(float64)) != version+1 { t.Errorf("version = %v, want %d", body["version"], version+1) } t.Logf("✓ state = active, version = %d", version+1) // ── Step 4: Verify active state persisted ────────────────── rec, body = get(t, h, "/api/v1/entities/ws:e2e-test-laptop", nil) if body["state"] != "active" { t.Fatalf("persisted state = %v, want active", body["state"]) } t.Logf("✓ active state persisted") // ── Step 5: Client context endpoint ──────────────────────── rec, body = get(t, h, "/api/v1/clients/ws:e2e-test-laptop/context", nil) if rec.Code != 200 { t.Fatalf("get context: status %d body %v", rec.Code, body) } if body["version"] == nil { t.Fatal("context: missing version") } t.Logf("✓ context endpoint: version=%v", body["version"]) // ── Step 6: Client secrets endpoint ──────────────────────── rec, body = get(t, h, "/api/v1/clients/ws:e2e-test-laptop/secrets", nil) if rec.Code != 200 { t.Fatalf("get secrets: status %d body %v", rec.Code, body) } keys, _ := body["keys"].([]any) t.Logf("✓ secrets endpoint: %d keys", len(keys)) // ── Step 7: Migrate state (active → migrating) ───────────── etag = rec.Header().Get("ETag") rec, body = get(t, h, "/api/v1/entities/ws:e2e-test-laptop", nil) etag = rec.Header().Get("ETag") rec, body = do(t, h, "PATCH", "/api/v1/entities/ws:e2e-test-laptop", map[string]any{"state": "migrating"}, map[string]string{"If-Match": etag}, ) if rec.Code != 200 { t.Fatalf("migrate: status %d body %v", rec.Code, body) } t.Logf("✓ state = migrating") // ── Step 8: Return to active (migration complete) ────────── rec, body = get(t, h, "/api/v1/entities/ws:e2e-test-laptop", nil) etag = rec.Header().Get("ETag") rec, body = do(t, h, "PATCH", "/api/v1/entities/ws:e2e-test-laptop", map[string]any{"state": "active"}, map[string]string{"If-Match": etag}, ) if rec.Code != 200 { t.Fatalf("return to active: status %d body %v", rec.Code, body) } t.Logf("✓ returned to active") // ── Step 9: Deprecate (active → deprecated) ──────────────── rec, body = get(t, h, "/api/v1/entities/ws:e2e-test-laptop", nil) etag = rec.Header().Get("ETag") rec, body = do(t, h, "PATCH", "/api/v1/entities/ws:e2e-test-laptop", map[string]any{"state": "deprecated"}, map[string]string{"If-Match": etag}, ) if rec.Code != 200 { t.Fatalf("deprecate: status %d body %v", rec.Code, body) } if body["state"] != "deprecated" { t.Fatalf("state = %v, want deprecated", body["state"]) } t.Logf("✓ state = deprecated") // ── Step 10: Verify health via fleet health endpoint ──────── rec, body = get(t, h, "/api/v1/health", nil) if rec.Code != 200 { t.Logf("health: status %d (may not exist)", rec.Code) } else { t.Logf("✓ health endpoint accessible") } // ── Step 11: Invalid transition → 409 ────────────────────── rec, body = get(t, h, "/api/v1/entities/ws:e2e-test-laptop", nil) etag = rec.Header().Get("ETag") rec, _ = do(t, h, "PATCH", "/api/v1/entities/ws:e2e-test-laptop", map[string]any{"state": "planned"}, map[string]string{"If-Match": etag}, ) if rec.Code != 409 { t.Errorf("invalid transition deprecated→planned: status %d, want 409", rec.Code) } else { t.Logf("✓ invalid transition blocked (409)") } // ── Step 12: Compute entity provisioning ──────────────────── rec, body = do(t, h, "POST", "/api/v1/entities/provision", map[string]any{ "slug": "lxc:e2e-test-container", "type": "lxc", "name": "E2E Test Container", "host": "host:hubris", "attributes": map[string]any{ "vmid": 999, "cores": 2, "ram_mb": 512, "disk_gb": 8, "ip": "192.168.8.250", "template": "debian-12-standard", }, }, nil) if rec.Code != 201 { t.Fatalf("provision: status %d body %v", rec.Code, body) } t.Logf("✓ compute entity provisioned: slug=lxc:e2e-test-container") // ── Step 13: Check provision status ──────────────────────── rec, body = get(t, h, "/api/v1/entities/lxc:e2e-test-container/provision/status", nil) if rec.Code != 200 { t.Fatalf("provision status: status %d body %v", rec.Code, body) } steps, _ := body["steps"].([]any) t.Logf("✓ provision status: state=%v steps=%d", body["state"], len(steps)) // ── Step 14: Relationship edges created ──────────────────── rec, body = get(t, h, "/api/v1/entities/lxc:e2e-test-container/relations", nil) relItems, _ := body["items"].([]any) relCount := len(relItems) t.Logf("✓ relationships: %d edges", relCount) if relCount == 0 { t.Error("expected at least 1 relationship edge (hosts)") } for _, item := range relItems { if m, ok := item.(map[string]any); ok { if m["type"] == "hosts" { t.Logf(" hosts edge: %v → %v", m["source"], m["target"]) } } } // ── Step 15: Verify blast radius ─────────────────────────── rec, body = get(t, h, "/api/v1/entities/lxc:e2e-test-container/blast_radius", nil) brItems, _ := body["items"].([]any) t.Logf("✓ blast radius: %d affected entities", len(brItems)) // ── Step 16: Final transition — active → deprecated ─────── // Entity already in deprecated from step 9. Can't go to failed directly. // Go back to active, then to failed. rec, body = get(t, h, "/api/v1/entities/ws:e2e-test-laptop", nil) etag = rec.Header().Get("ETag") rec, body = do(t, h, "PATCH", "/api/v1/entities/ws:e2e-test-laptop", map[string]any{"state": "active"}, map[string]string{"If-Match": etag}, ) if rec.Code != 200 { t.Logf("un-deprecate: status %d (may be blocked)", rec.Code) } // Now fail from active rec, body = get(t, h, "/api/v1/entities/ws:e2e-test-laptop", nil) etag = rec.Header().Get("ETag") rec, body = do(t, h, "PATCH", "/api/v1/entities/ws:e2e-test-laptop", map[string]any{"state": "failed"}, map[string]string{"If-Match": etag}, ) if rec.Code != 200 { bodyStr, _ := json.Marshal(body) t.Logf("fail: status %d body %s (may be blocked by lifecycle)", rec.Code, bodyStr) } else { t.Logf("✓ state = failed (cleanup)") } t.Log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") t.Log("E2E client lifecycle test PASSED") t.Log(" planned → provisioning (enroll) → active → migrating → active → deprecated → failed") t.Log(" + compute entity provisioning + status + relations + blast radius") t.Log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") } func TestClientEnrollmentRejectsInvalidStates(t *testing.T) { h := newTestHandler(t, devConfig()) // Create entity already in active state rec, _ := do(t, h, "POST", "/api/v1/entities", map[string]any{ "slug": "ws:already-active", "type": "workstation", "name": "Already Active", "state": "active", }, nil) if rec.Code != 201 { t.Fatalf("create: %d", rec.Code) } // Attempt enrollment on active entity → should reject rec, body := do(t, h, "POST", "/api/v1/clients/enroll", map[string]any{ "slug": "ws:already-active", "hostname": "already-active", "mesh_ip": "100.122.1.1", }, nil) if rec.Code == 200 { t.Fatal("enroll on active entity should have failed") } t.Logf("✓ rejected enrollment on active entity: %v", body["detail"]) // Attempt enrollment without mesh_ip → should reject rec, body = do(t, h, "POST", "/api/v1/clients/enroll", map[string]any{ "slug": "ws:already-active", "hostname": "already-active", }, nil) if rec.Code == 200 { t.Fatal("enroll without mesh_ip should have failed") } t.Logf("✓ rejected enrollment without mesh_ip: %v", body["detail"]) } func TestProvisionEntityRejectsDuplicateSlug(t *testing.T) { h := newTestHandler(t, devConfig()) // Create first entity rec, _ := do(t, h, "POST", "/api/v1/entities/provision", map[string]any{ "slug": "lxc:dup-test", "type": "lxc", "name": "Duplicate Test", "host": "host:hubris", }, nil) if rec.Code != 201 { t.Fatalf("first provision: %d", rec.Code) } // Try same slug again → should reject rec, body := do(t, h, "POST", "/api/v1/entities/provision", map[string]any{ "slug": "lxc:dup-test", "type": "lxc", "name": "Duplicate Test 2", "host": "host:hubris", }, nil) if rec.Code != 409 && rec.Code != 400 { t.Errorf("duplicate slug: status %d, want 409 or 400", rec.Code) } t.Logf("✓ rejected duplicate slug: status %d %v", rec.Code, body["detail"]) }