Skip to content

Commit 25233bf

Browse files
feat(api): agent-native contract + funnel recovery + cli login + observability (#283)
* feat(api): agent-native contract + funnel recovery + cli login + observability - D1: 401 agent_action steers headless agents to the device-flow (POST /auth/cli) / INSTANT_TOKEN, not the browser /login; INSTANT_TOKEN canonicalized - D2: POST /auth/cli/:id/complete wires CompleteCLISession so 'instant login' completes - D6: openapi ErrorResponse documents error_code - D7: provision responses echo ignored_fields for unrecognized body keys - F1: recycle-gate claim_url carries a short-lived ?t= claim JWT (kills the dead-end) - F7: instant_recycle_claim_recovery_total metric - S4: instant_razorpay_webhook_sig_fail_total counter on the webhook bad-signature path - unit + e2e (prod cohort) tests; Makefile wires E2E_ACCOUNT_TOKEN Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(lint): use reflect.Pointer (not deprecated reflect.Ptr) in ignored_fields walk Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(api): 100% patch coverage on cli-complete + ignored_fields/recycle branches Closes the 100%-patch-coverage CI gate on #283. The e2e tests that exercised these lines are build-tagged (excluded from the -short coverage run), so the new handler error-arms showed as uncovered. Added UNIT tests + two minimal test seams. cli_auth.go (CompleteCLISessionHandler, D2): white-box tests in cli_auth_coverage_test.go drive every error arm — missing/blank session id, Redis-read failure, corrupt session blob, idempotent already-complete, non-UUID team/user locals, team-not-found + team-lookup DB error, keygen failure, CreateAPIKey failure, best-effort resource-list failure, happy-path session flip (incl. the token-gathering loop), and the final session-write failure (injected via a go-redis SET-failing hook). Handler now 100%. provision_helper.go: - recycleClaimURL (F1): the existing mint-failed test could not force the failure (HMAC over any []byte key signs), so issueOnboardingJWT is now reached through the issueOnboardingJWTFn seam; rewrote the test to force the bare-URL fallback and added the mint-success (embeds-JWT) arm. 100%. - knownJSONFields / collectJSONFields (D7): added reflection-edge tests — typed nil pointer, non-struct input, pointer-to-pointer, anonymous *struct embed, unexported non-embedded field, non-struct in collectJSONFields, non-object body, empty object. Both 100%. - parseProvisionBody content-type charset-strip + 415 arm covered directly. Test seams generateAPIKeyPlaintextFn (cli_auth.go) and issueOnboardingJWTFn (provision_helper.go) both default to the real func/method — zero prod behavior change — mirroring the existing promoteDeploymentTTLsForTeamFn pattern in billing.go. diff-cover vs origin/master: 213 changed lines, 0 missing, 100%. golangci-lint ./internal/handlers/ → 0 issues. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0edf54e commit 25233bf

23 files changed

Lines changed: 2167 additions & 59 deletions

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,17 @@ test-e2e:
116116
# without it every test can hit the recycle gate.
117117
#
118118
# Requires: kubectl access to the `instant` namespace.
119+
# E2E_ACCOUNT_TOKEN — guard secret for POST/DELETE /internal/e2e/account;
120+
# authed cohort-flow tests (CLI device-flow complete,
121+
# live 401 error_code shape) mint+reap a real
122+
# is_test_cohort account against prod with it. Absent
123+
# → those tests SKIP cleanly.
119124
test-e2e-full:
120125
E2E_JWT_SECRET=$(shell kubectl get secret instant-secrets -n instant -o jsonpath='{.data.JWT_SECRET}' 2>/dev/null | base64 -d) \
121126
E2E_RAZORPAY_WEBHOOK_SECRET=$(shell kubectl get secret instant-secrets -n instant -o jsonpath='{.data.RAZORPAY_WEBHOOK_SECRET}' 2>/dev/null | base64 -d) \
122127
E2E_RAZORPAY_PLAN_ID_PRO=$(shell kubectl get secret instant-secrets -n instant -o jsonpath='{.data.RAZORPAY_PLAN_ID_PRO}' 2>/dev/null | base64 -d) \
123128
E2E_TEST_TOKEN=$(shell kubectl get secret instant-secrets -n instant -o jsonpath='{.data.E2E_TEST_TOKEN}' 2>/dev/null | base64 -d) \
129+
E2E_ACCOUNT_TOKEN=$(shell kubectl get secret instant-secrets -n instant -o jsonpath='{.data.E2E_ACCOUNT_TOKEN}' 2>/dev/null | base64 -d) \
124130
go test ./e2e/... -v -tags e2e -timeout 90s
125131

126132
test-e2e-docker:

e2e/agent_steering_e2e_test.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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+
}

e2e/cohort_helpers_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//go:build e2e
2+
3+
package e2e
4+
5+
// cohort_helpers_test.go — shared helper for the authed e2e flows added
6+
// 2026-06-10 (D1/D2/D6). Mints a throwaway is_test_cohort account against the
7+
// LIVE api via POST /internal/e2e/account (guarded by the X-E2E-Token header =
8+
// E2E_ACCOUNT_TOKEN) and ALWAYS reaps it via DELETE /internal/e2e/account/:id.
9+
// The mint/reap surface is INERT in prod unless the operator wired the secret,
10+
// so tests that need it skip cleanly when E2E_ACCOUNT_TOKEN is unset.
11+
12+
import (
13+
"bytes"
14+
"encoding/json"
15+
"net/http"
16+
"os"
17+
"testing"
18+
)
19+
20+
// e2eAccountToken returns the guard secret for the /internal/e2e/account
21+
// surface, or "" when unset (tests then skip).
22+
func e2eAccountToken() string { return os.Getenv("E2E_ACCOUNT_TOKEN") }
23+
24+
// cohort is a minted ephemeral test account.
25+
type cohort struct {
26+
TeamID string `json:"team_id"`
27+
UserID string `json:"user_id"`
28+
Email string `json:"email"`
29+
Tier string `json:"tier"`
30+
SessionJWT string `json:"session_jwt"`
31+
}
32+
33+
// mintCohort creates a real is_test_cohort account on the live api. It SKIPS the
34+
// test when E2E_ACCOUNT_TOKEN is unset (the surface is inert without it). The
35+
// returned reap func DELETEs the account; always defer it.
36+
func mintCohort(t *testing.T, tier string) (cohort, func()) {
37+
t.Helper()
38+
tok := e2eAccountToken()
39+
if tok == "" {
40+
t.Skip("E2E_ACCOUNT_TOKEN unset — cohort-minting e2e flow skipped (surface is inert without it)")
41+
}
42+
43+
reqBody, _ := json.Marshal(map[string]string{"tier": tier, "env": "production"})
44+
req, err := http.NewRequest(http.MethodPost, baseURL()+"/internal/e2e/account", bytes.NewReader(reqBody))
45+
if err != nil {
46+
t.Fatalf("mintCohort: NewRequest: %v", err)
47+
}
48+
req.Header.Set("Content-Type", "application/json")
49+
req.Header.Set("X-E2E-Token", tok)
50+
resp, err := client.Do(req)
51+
if err != nil {
52+
t.Fatalf("mintCohort: POST /internal/e2e/account: %v", err)
53+
}
54+
if resp.StatusCode == http.StatusNotFound {
55+
// Inert-by-default: token wrong or surface not armed on this deploy.
56+
resp.Body.Close()
57+
t.Skip("POST /internal/e2e/account returned 404 — surface inert / token mismatch; skipping")
58+
}
59+
if resp.StatusCode != http.StatusOK {
60+
t.Fatalf("mintCohort: want 200, got %d\n%s", resp.StatusCode, readBody(t, resp))
61+
}
62+
var c cohort
63+
decodeJSON(t, resp, &c)
64+
if c.TeamID == "" || c.SessionJWT == "" {
65+
t.Fatalf("mintCohort: response missing team_id/session_jwt: %+v", c)
66+
}
67+
68+
reap := func() {
69+
dreq, derr := http.NewRequest(http.MethodDelete,
70+
baseURL()+"/internal/e2e/account/"+c.TeamID, nil)
71+
if derr != nil {
72+
t.Logf("mintCohort reap: NewRequest: %v", derr)
73+
return
74+
}
75+
dreq.Header.Set("X-E2E-Token", tok)
76+
dresp, derr := client.Do(dreq)
77+
if derr != nil {
78+
t.Logf("mintCohort reap: DELETE failed (account may linger): %v", derr)
79+
return
80+
}
81+
dresp.Body.Close()
82+
if dresp.StatusCode != http.StatusOK {
83+
t.Logf("mintCohort reap: DELETE returned %d for team %s", dresp.StatusCode, c.TeamID)
84+
}
85+
}
86+
return c, reap
87+
}

internal/handlers/agent_action_test.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,15 @@ func TestRespondError_KnownCode_PopulatesAgentAction(t *testing.T) {
126126
wantActionSubstr: "url path tokens",
127127
},
128128
{
129-
name: "unauthorized points at login",
129+
// D1/D8 (2026-06-10): the `unauthorized` agent_action steers a
130+
// HEADLESS agent at the CLI device-flow / INSTANT_TOKEN PAT, NOT the
131+
// browser /login page. Assert it names the device-flow endpoint and
132+
// the canonical bearer env var.
133+
name: "unauthorized steers headless agents at the device-flow",
130134
code: "unauthorized",
131135
status: fiber.StatusUnauthorized,
132136
wantUpgradeURL: false,
133-
wantActionSubstr: "log in at https://instanode.dev/login",
137+
wantActionSubstr: "/auth/cli",
134138
},
135139
{
136140
name: "auth_required points at login/signup",

internal/handlers/billing.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1427,6 +1427,11 @@ func (h *BillingHandler) RazorpayWebhook(c *fiber.Ctx) error {
14271427
}
14281428
if !sigOK {
14291429
slog.Error("billing.webhook.signature_failed")
1430+
// S4 (metric half): bump the inbound-signature-failure counter so the
1431+
// "N razorpay signature failures / hour" NR alert can fire without
1432+
// grepping the slog line above. Mirrors the GitHub webhook bad-signature
1433+
// counter; the Prom rule + NR alert JSON are the infra agent's job.
1434+
metrics.RazorpayWebhookSigFail.Inc()
14301435
// B18 wave-3 hardening (2026-05-21): emit an audit_log row on every
14311436
// signature-mismatch attempt so an operator dashboard can chart
14321437
// "N auth failures / hour" without grepping NR logs. Best-effort
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package handlers_test
2+
3+
// billing_webhook_sig_fail_metric_test.go — S4 (metric half, 2026-06-10).
4+
//
5+
// A forged-signature POST /razorpay/webhook must bump
6+
// instant_razorpay_webhook_sig_fail_total (mirroring the GitHub webhook
7+
// bad-signature counter) so an operator can chart "N signature failures / hour"
8+
// without grepping the billing.webhook.signature_failed slog line. Pre-fix the
9+
// only signal was the log line + a best-effort audit row.
10+
11+
import (
12+
"bytes"
13+
"net/http"
14+
"net/http/httptest"
15+
"testing"
16+
17+
"github.com/gofiber/fiber/v2"
18+
"github.com/prometheus/client_golang/prometheus/testutil"
19+
"github.com/stretchr/testify/assert"
20+
"github.com/stretchr/testify/require"
21+
22+
"instant.dev/internal/config"
23+
"instant.dev/internal/email"
24+
"instant.dev/internal/handlers"
25+
"instant.dev/internal/metrics"
26+
"instant.dev/internal/middleware"
27+
)
28+
29+
// TestRazorpayWebhook_ForgedSignature_IncrementsSigFailCounter posts a body with
30+
// a deliberately wrong X-Razorpay-Signature and asserts the sig-fail counter
31+
// rises by exactly one and the response is a 4xx (Razorpay's retry contract is
32+
// covered elsewhere; here we pin the metric). db is nil so the best-effort
33+
// audit-row block no-ops — the counter increment is independent of it.
34+
func TestRazorpayWebhook_ForgedSignature_IncrementsSigFailCounter(t *testing.T) {
35+
cfg := &config.Config{
36+
JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!",
37+
RazorpayWebhookSecret: "live_webhook_secret_for_this_test_only_xxxxxx",
38+
}
39+
billing := handlers.NewBillingHandler(nil, cfg, email.NewNoop())
40+
app := fiber.New()
41+
app.Use(middleware.RequestID())
42+
app.Post("/razorpay/webhook", billing.RazorpayWebhook)
43+
44+
before := testutil.ToFloat64(metrics.RazorpayWebhookSigFail)
45+
46+
payload := []byte(`{"event":"subscription.charged","payload":{}}`)
47+
req := httptest.NewRequest(http.MethodPost, "/razorpay/webhook", bytes.NewReader(payload))
48+
req.Header.Set("Content-Type", "application/json")
49+
// A signature that cannot verify against the configured secret.
50+
req.Header.Set("X-Razorpay-Signature", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
51+
52+
resp, err := app.Test(req, 5000)
53+
require.NoError(t, err)
54+
defer resp.Body.Close()
55+
56+
assert.GreaterOrEqual(t, resp.StatusCode, 400,
57+
"a forged-signature webhook must be rejected with a 4xx")
58+
59+
after := testutil.ToFloat64(metrics.RazorpayWebhookSigFail)
60+
assert.Equal(t, before+1, after,
61+
"instant_razorpay_webhook_sig_fail_total must increment by exactly 1 on a signature failure (S4)")
62+
}

0 commit comments

Comments
 (0)