|
| 1 | +//go:build e2e |
| 2 | + |
| 3 | +package e2e |
| 4 | + |
| 5 | +// agent_steering_e2e_test.go — live e2e coverage for the 2026-06-10 agent-DX |
| 6 | +// fixes against the LIVE api (api.instanode.dev by default): |
| 7 | +// |
| 8 | +// D1/D8 — a 401 agent_action steers a headless agent at the CLI device-flow + |
| 9 | +// INSTANT_TOKEN, NOT the browser /login. |
| 10 | +// D6 — a live 401 body carries error_code. |
| 11 | +// D7 — an unknown provision field is echoed back under ignored_fields. |
| 12 | +// F1 — a recycle-gate 402 returns a claim_url carrying ?t=<jwt>. |
| 13 | +// D2 — `instant login` works end-to-end: mint cohort session → POST |
| 14 | +// /auth/cli → POST /auth/cli/{id}/complete → poll returns api_token. |
| 15 | + |
| 16 | +import ( |
| 17 | + "encoding/json" |
| 18 | + "net/http" |
| 19 | + "strings" |
| 20 | + "testing" |
| 21 | +) |
| 22 | + |
| 23 | +// TestE2E_AgentSteering_Unauthorized401_SteersAtDeviceFlow (D1/D6/D8). An |
| 24 | +// unauthenticated call to a RequireAuth-gated route returns 401 whose |
| 25 | +// agent_action points at the CLI device-flow / INSTANT_TOKEN (not /login) and |
| 26 | +// whose body carries error_code. |
| 27 | +func TestE2E_AgentSteering_Unauthorized401_SteersAtDeviceFlow(t *testing.T) { |
| 28 | + resp := get(t, "/api/v1/resources") // RequireAuth, no Bearer → 401 |
| 29 | + if resp.StatusCode != http.StatusUnauthorized { |
| 30 | + t.Fatalf("GET /api/v1/resources without auth: want 401, got %d\n%s", |
| 31 | + resp.StatusCode, readBody(t, resp)) |
| 32 | + } |
| 33 | + var body map[string]any |
| 34 | + decodeJSON(t, resp, &body) |
| 35 | + |
| 36 | + if body["error"] != "unauthorized" { |
| 37 | + t.Errorf("error must stay 'unauthorized' for back-compat; got %v", body["error"]) |
| 38 | + } |
| 39 | + // D6: error_code present. |
| 40 | + ec, _ := body["error_code"].(string) |
| 41 | + if ec == "" { |
| 42 | + t.Errorf("D6: live 401 body must carry a non-empty error_code; got %v", body["error_code"]) |
| 43 | + } |
| 44 | + // D1/D8: agent_action steers at the device-flow + INSTANT_TOKEN, not /login. |
| 45 | + action, _ := body["agent_action"].(string) |
| 46 | + if action == "" { |
| 47 | + t.Fatalf("agent_action must be present on a 401") |
| 48 | + } |
| 49 | + if !strings.Contains(action, "INSTANT_TOKEN") { |
| 50 | + t.Errorf("D8: agent_action must name INSTANT_TOKEN; got %q", action) |
| 51 | + } |
| 52 | + if strings.Contains(action, "INSTANODE_TOKEN") { |
| 53 | + t.Errorf("D8: agent_action must NOT name the old INSTANODE_TOKEN; got %q", action) |
| 54 | + } |
| 55 | + if !strings.Contains(action, "/auth/cli") { |
| 56 | + t.Errorf("D1: agent_action must steer at the CLI device-flow (/auth/cli); got %q", action) |
| 57 | + } |
| 58 | + if strings.Contains(action, "/login") { |
| 59 | + t.Errorf("D1: agent_action must NOT push a headless agent at /login; got %q", action) |
| 60 | + } |
| 61 | +} |
| 62 | + |
| 63 | +// TestE2E_AgentSteering_UnknownProvisionField_EchoedAsIgnored (D7). A provision |
| 64 | +// body carrying an unrecognized key ("region") succeeds (201) and echoes the |
| 65 | +// key under ignored_fields. Uses the anonymous /cache/new path (Redis — live in |
| 66 | +// prod, unlike /db/new which is Phase-2-gated) with a unique fingerprint so it |
| 67 | +// doesn't collide with the recycle gate. |
| 68 | +func TestE2E_AgentSteering_UnknownProvisionField_EchoedAsIgnored(t *testing.T) { |
| 69 | + ip := uniqueIP(t) |
| 70 | + // Explicit name (so the test owns it) + an unknown "region" key. |
| 71 | + resp := post(t, "/cache/new", |
| 72 | + map[string]any{"name": "ignored-fields-probe", "region": "mars"}, |
| 73 | + "X-Forwarded-For", ip) |
| 74 | + if resp.StatusCode == http.StatusServiceUnavailable { |
| 75 | + t.Skip("POST /cache/new: service not enabled (503) — skipping D7 live check") |
| 76 | + } |
| 77 | + if resp.StatusCode != http.StatusCreated { |
| 78 | + t.Fatalf("POST /cache/new with unknown field: want 201, got %d\n%s", |
| 79 | + resp.StatusCode, readBody(t, resp)) |
| 80 | + } |
| 81 | + var body map[string]any |
| 82 | + decodeJSON(t, resp, &body) |
| 83 | + |
| 84 | + raw, ok := body["ignored_fields"] |
| 85 | + if !ok { |
| 86 | + t.Fatalf("D7: 201 response must echo ignored_fields for an unknown key; body=%v", body) |
| 87 | + } |
| 88 | + arr, ok := raw.([]any) |
| 89 | + if !ok { |
| 90 | + t.Fatalf("ignored_fields must be an array; got %T (%v)", raw, raw) |
| 91 | + } |
| 92 | + found := false |
| 93 | + for _, v := range arr { |
| 94 | + if s, _ := v.(string); s == "region" { |
| 95 | + found = true |
| 96 | + } |
| 97 | + } |
| 98 | + if !found { |
| 99 | + t.Errorf("D7: ignored_fields must contain 'region'; got %v", arr) |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +// TestE2E_AgentSteering_RecycleGate402_ClaimURLHasToken (F1). When the free-tier |
| 104 | +// recycle gate fires (402 free_tier_recycle_requires_claim), the claim_url must |
| 105 | +// embed a minted claim JWT (?t=). Driving the gate deterministically against a |
| 106 | +// live cluster is timing-dependent (it needs a prior provision to have aged |
| 107 | +// out), so this test only ASSERTS the contract IF it observes the gate — it |
| 108 | +// never forces a sleep/aging loop (would violate rate-limit discipline). It is |
| 109 | +// a no-op (skip) when the gate doesn't fire in this run. |
| 110 | +func TestE2E_AgentSteering_RecycleGate402_ClaimURLHasToken(t *testing.T) { |
| 111 | + if e2eTestToken() == "" { |
| 112 | + t.Skip("E2E_TEST_TOKEN unset — cannot isolate a fingerprint to drive the recycle gate; skipping F1 live check") |
| 113 | + } |
| 114 | + // A single anonymous provision on a fresh fingerprint sets the |
| 115 | + // recycle_seen marker but won't itself gate (there's an active row). The |
| 116 | + // deterministic gate path is exercised by the unit test |
| 117 | + // (TestRecycleGate_FiresWith402_WhenMarkerExistsAndNoActiveRow); here we |
| 118 | + // only validate the live contract opportunistically. |
| 119 | + ip := uniqueIP(t) |
| 120 | + resp := post(t, "/cache/new", nil, "X-Forwarded-For", ip) |
| 121 | + defer resp.Body.Close() |
| 122 | + if resp.StatusCode != http.StatusPaymentRequired { |
| 123 | + t.Skipf("recycle gate did not fire on this run (got %d) — F1 contract proven by the unit test; skipping live assert", resp.StatusCode) |
| 124 | + } |
| 125 | + var body map[string]any |
| 126 | + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { |
| 127 | + t.Fatalf("decode 402 body: %v", err) |
| 128 | + } |
| 129 | + if body["error"] != "free_tier_recycle_requires_claim" { |
| 130 | + t.Fatalf("unexpected 402 error code: %v", body["error"]) |
| 131 | + } |
| 132 | + claimURL, _ := body["claim_url"].(string) |
| 133 | + if !strings.Contains(claimURL, "?t=") { |
| 134 | + t.Errorf("F1: recycle-gate claim_url must embed a minted claim JWT (?t=); got %q", claimURL) |
| 135 | + } |
| 136 | +} |
| 137 | + |
| 138 | +// TestE2E_CLIDeviceFlow_Complete_FlipsSessionLive (D2). The full `instant login` |
| 139 | +// round-trip against the live api: mint a cohort session, create a CLI session, |
| 140 | +// complete it with the cohort Bearer, and poll for the api_token. Cohort is |
| 141 | +// reaped on teardown. |
| 142 | +func TestE2E_CLIDeviceFlow_Complete_FlipsSessionLive(t *testing.T) { |
| 143 | + c, reap := mintCohort(t, "free") |
| 144 | + defer reap() |
| 145 | + |
| 146 | + // 1. Create a pending CLI session. |
| 147 | + createResp := post(t, "/auth/cli", map[string]any{}) |
| 148 | + if createResp.StatusCode != http.StatusCreated { |
| 149 | + t.Fatalf("POST /auth/cli: want 201, got %d\n%s", createResp.StatusCode, readBody(t, createResp)) |
| 150 | + } |
| 151 | + var created struct { |
| 152 | + SessionID string `json:"session_id"` |
| 153 | + } |
| 154 | + decodeJSON(t, createResp, &created) |
| 155 | + if created.SessionID == "" { |
| 156 | + t.Fatalf("POST /auth/cli returned no session_id") |
| 157 | + } |
| 158 | + |
| 159 | + // 2. Complete it with the cohort's session Bearer. |
| 160 | + completeResp := post(t, "/auth/cli/"+created.SessionID+"/complete", nil, |
| 161 | + "Authorization", "Bearer "+c.SessionJWT) |
| 162 | + if completeResp.StatusCode != http.StatusOK { |
| 163 | + t.Fatalf("POST /auth/cli/{id}/complete: want 200, got %d\n%s", |
| 164 | + completeResp.StatusCode, readBody(t, completeResp)) |
| 165 | + } |
| 166 | + var done struct { |
| 167 | + OK bool `json:"ok"` |
| 168 | + } |
| 169 | + decodeJSON(t, completeResp, &done) |
| 170 | + if !done.OK { |
| 171 | + t.Fatalf("complete response must be {ok:true}") |
| 172 | + } |
| 173 | + |
| 174 | + // 3. Poll — must now return 200 + status:"complete" + a real api_token. |
| 175 | + pollResp := get(t, "/auth/cli/"+created.SessionID) |
| 176 | + if pollResp.StatusCode != http.StatusOK { |
| 177 | + t.Fatalf("GET /auth/cli/{id} after complete: want 200, got %d\n%s", |
| 178 | + pollResp.StatusCode, readBody(t, pollResp)) |
| 179 | + } |
| 180 | + var poll map[string]any |
| 181 | + decodeJSON(t, pollResp, &poll) |
| 182 | + if poll["status"] != "complete" { |
| 183 | + t.Errorf("D2: completed poll must carry status='complete'; got %v", poll["status"]) |
| 184 | + } |
| 185 | + apiToken, _ := poll["api_token"].(string) |
| 186 | + if apiToken == "" || !strings.HasPrefix(apiToken, "ink_") { |
| 187 | + t.Errorf("D2: completed poll must return a real api_token (ink_...); got %q", apiToken) |
| 188 | + } |
| 189 | +} |
0 commit comments