Skip to content

Commit 3adcb59

Browse files
test(cli): assert claim-on-login surfaces claimed-token count (#26)
The CLI claims anonymous resources as a side effect of the login device-flow (POST /auth/cli -> poll GET /auth/cli/{id} -> claimed_tokens). Existing runLogin tests let the claim-print branch run but assert only err == nil and discard stdout, so they never prove the user-visible "N anonymous resource token(s) claimed" output is correct (CLAUDE.md rule 12: verification surface must match the failure surface). Adds claim_login_test.go covering the user-visible claim surface against an httptest device-flow stub: - multi-token claim: asserts the rendered count line + credential persistence - zero-token claim: asserts NO claim line is printed (avoids "0 ... claimed") - pending-then-success: claim count survives the 202-then-200 multi-poll path Mirrors the existing httptest stub + withCleanState/withTestAPI/ withShortPolls/captureStdout helpers; named constants, no inline strings. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d6da0b4 commit 3adcb59

1 file changed

Lines changed: 193 additions & 0 deletions

File tree

cmd/claim_login_test.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package cmd
2+
3+
// claim_login_test.go — integration coverage for the CLI "claim" path.
4+
//
5+
// The CLI has NO standalone `claim` command. Anonymous resources are claimed
6+
// as a side effect of the login device-flow: `runLogin` does
7+
// POST /auth/cli → {session_id, auth_url}
8+
// GET /auth/cli/{id} → 202 (pending) … then 200 {api_key, …, claimed_tokens}
9+
// and on success surfaces the count of newly-claimed anonymous tokens to the
10+
// user ("N anonymous resource token(s) claimed to your account.").
11+
//
12+
// The existing login tests (login_poll_test.go, login_timeout_test.go,
13+
// coverage_login_test.go, coverage_push95_test.go) already exercise the poll
14+
// HELPERS and the runLogin happy/error BRANCHES — but every runLogin test
15+
// asserts only `err == nil` and discards stdout. None of them prove the
16+
// user-visible claim output is correct. Per CLAUDE.md rule 12 ("Shipped ≠
17+
// Verified": the verification surface MUST match the failure surface), a green
18+
// `runLogin` is not proof the user sees their claimed-token count. This file
19+
// closes that gap: it captures stdout and asserts the rendered claim line for
20+
// the multi-token, zero-token, and pending-then-success (multi-poll) cases,
21+
// and verifies credentials are persisted to the local config.
22+
23+
import (
24+
"fmt"
25+
"net/http"
26+
"net/http/httptest"
27+
"strings"
28+
"sync/atomic"
29+
"testing"
30+
31+
"github.com/InstaNode-dev/cli/internal/cliconfig"
32+
)
33+
34+
const (
35+
// claimLineSuffix is the trailing, count-agnostic portion of the line
36+
// runLogin prints when one or more anonymous tokens are claimed (login.go
37+
// ~line 116). Asserting against this fragment keeps the test resilient to
38+
// count changes while still pinning the user-visible claim message.
39+
claimLineSuffix = "anonymous resource token(s) claimed to your account."
40+
// loggedInPrefix is the success banner runLogin always prints.
41+
loggedInPrefix = "Logged in as"
42+
// claimTestEmail / claimTestAPIKey are the synthetic identity the stub
43+
// returns on a completed login.
44+
claimTestEmail = "claimer@instanode.dev"
45+
claimTestAPIKey = "inst_claim_test_key"
46+
claimTestTier = "pro"
47+
claimTestTeam = "Claim Team"
48+
)
49+
50+
// claimStubBody returns the JSON body the stub /auth/cli/{id} endpoint serves
51+
// on completion, with the given claimed-token list. A nil/empty list models a
52+
// login that claimed nothing (the user had no local anonymous tokens, or they
53+
// were already associated).
54+
func claimStubBody(t *testing.T, claimed []string) string {
55+
t.Helper()
56+
tokensJSON := "[]"
57+
if len(claimed) > 0 {
58+
quoted := make([]string, len(claimed))
59+
for i, tok := range claimed {
60+
quoted[i] = fmt.Sprintf("%q", tok)
61+
}
62+
tokensJSON = "[" + strings.Join(quoted, ",") + "]"
63+
}
64+
return fmt.Sprintf(
65+
`{"api_key":%q,"email":%q,"tier":%q,"team_name":%q,"claimed_tokens":%s}`,
66+
claimTestAPIKey, claimTestEmail, claimTestTier, claimTestTeam, tokensJSON,
67+
)
68+
}
69+
70+
// claimLoginServer mounts a device-flow stub: POST /auth/cli yields a session,
71+
// and GET /auth/cli/{id} returns 202 `pendingPolls` times before serving the
72+
// completed body (with the supplied claimed tokens). pendingPolls=0 means the
73+
// very first poll succeeds.
74+
func claimLoginServer(t *testing.T, claimed []string, pendingPolls int32) *httptest.Server {
75+
t.Helper()
76+
var polls int32
77+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
78+
switch {
79+
case r.URL.Path == "/auth/cli" && r.Method == http.MethodPost:
80+
writeJSON(w, http.StatusOK, map[string]string{
81+
"session_id": "sess_claim_test",
82+
"auth_url": "https://instanode.dev/cli-auth?s=claim",
83+
})
84+
case strings.HasPrefix(r.URL.Path, "/auth/cli/") && r.Method == http.MethodGet:
85+
if atomic.AddInt32(&polls, 1) <= pendingPolls {
86+
w.WriteHeader(http.StatusAccepted)
87+
return
88+
}
89+
w.Header().Set("Content-Type", "application/json")
90+
w.WriteHeader(http.StatusOK)
91+
_, _ = w.Write([]byte(claimStubBody(t, claimed)))
92+
default:
93+
http.NotFound(w, r)
94+
}
95+
}))
96+
}
97+
98+
// TestClaimOnLogin_SurfacesClaimedCount is the headline case: a successful
99+
// device-flow login that claims two anonymous tokens MUST report the count to
100+
// the user AND persist the new credentials. This is the assertion the existing
101+
// runLogin tests are missing — they let the print branch run but never read it.
102+
func TestClaimOnLogin_SurfacesClaimedCount(t *testing.T) {
103+
withCleanState(t)
104+
srv := claimLoginServer(t, []string{"tok_a", "tok_b"}, 0)
105+
defer srv.Close()
106+
withTestAPI(t, srv.URL)
107+
108+
var runErr error
109+
stdout, _ := captureStdout(t, func() {
110+
runErr = runLogin(nil, nil)
111+
})
112+
if runErr != nil {
113+
t.Fatalf("runLogin: %v", runErr)
114+
}
115+
116+
// User-visible claim line must report the exact count of claimed tokens.
117+
wantClaim := fmt.Sprintf("2 %s", claimLineSuffix)
118+
if !strings.Contains(stdout, wantClaim) {
119+
t.Errorf("stdout missing claim line %q; got:\n%s", wantClaim, stdout)
120+
}
121+
if !strings.Contains(stdout, loggedInPrefix) {
122+
t.Errorf("stdout missing login banner %q; got:\n%s", loggedInPrefix, stdout)
123+
}
124+
125+
// Credentials from the claimed login must be persisted so subsequent
126+
// authenticated calls (and the claimed resources) are usable.
127+
cfg, err := cliconfig.Load()
128+
if err != nil {
129+
t.Fatalf("reload config: %v", err)
130+
}
131+
if cfg.APIKey != claimTestAPIKey {
132+
t.Errorf("APIKey not persisted: got %q want %q", cfg.APIKey, claimTestAPIKey)
133+
}
134+
if cfg.Email != claimTestEmail {
135+
t.Errorf("Email not persisted: got %q want %q", cfg.Email, claimTestEmail)
136+
}
137+
if cfg.Tier != claimTestTier {
138+
t.Errorf("Tier not persisted: got %q want %q", cfg.Tier, claimTestTier)
139+
}
140+
}
141+
142+
// TestClaimOnLogin_ZeroClaimedTokens pins the empty-claim contract: a login
143+
// that claims nothing (`claimed_tokens: []`) must NOT print a claim line.
144+
// Printing "0 ... claimed" would be a misleading regression — the existing
145+
// suite's anonymous case never asserts the absence of this line.
146+
func TestClaimOnLogin_ZeroClaimedTokens(t *testing.T) {
147+
withCleanState(t)
148+
srv := claimLoginServer(t, nil, 0)
149+
defer srv.Close()
150+
withTestAPI(t, srv.URL)
151+
152+
var runErr error
153+
stdout, _ := captureStdout(t, func() {
154+
runErr = runLogin(nil, nil)
155+
})
156+
if runErr != nil {
157+
t.Fatalf("runLogin: %v", runErr)
158+
}
159+
160+
if strings.Contains(stdout, claimLineSuffix) {
161+
t.Errorf("expected NO claim line for zero claimed tokens; got:\n%s", stdout)
162+
}
163+
// The login itself still succeeds and reports the account.
164+
if !strings.Contains(stdout, loggedInPrefix) {
165+
t.Errorf("stdout missing login banner %q; got:\n%s", loggedInPrefix, stdout)
166+
}
167+
}
168+
169+
// TestClaimOnLogin_PendingThenClaimed is the realistic edge case: the user
170+
// takes a few seconds to finish the browser flow, so the CLI sees several 202
171+
// "pending" polls before the 200 that carries claimed_tokens. The claim count
172+
// must survive the multi-poll path and still surface correctly. Uses the
173+
// var-overridable poll cadence so the 202s don't burn real seconds.
174+
func TestClaimOnLogin_PendingThenClaimed(t *testing.T) {
175+
withCleanState(t)
176+
withShortPolls(t)
177+
srv := claimLoginServer(t, []string{"tok_only"}, 3)
178+
defer srv.Close()
179+
withTestAPI(t, srv.URL)
180+
181+
var runErr error
182+
stdout, _ := captureStdout(t, func() {
183+
runErr = runLogin(nil, nil)
184+
})
185+
if runErr != nil {
186+
t.Fatalf("runLogin after pending polls: %v", runErr)
187+
}
188+
189+
wantClaim := fmt.Sprintf("1 %s", claimLineSuffix)
190+
if !strings.Contains(stdout, wantClaim) {
191+
t.Errorf("stdout missing single-token claim line %q; got:\n%s", wantClaim, stdout)
192+
}
193+
}

0 commit comments

Comments
 (0)