Files
pad/internal/server/claim_codes_test.go
xarmian aec67e202e feat(mcp): workspace.create + workspace.claim actions + claim-code mechanics (TASK-1521) (#582)
Phase B for PLAN-1519. Adds two MCP actions so agents can bring a
workspace into an OAuth connection without re-auth — the agent-first
onboarding story IDEA-1517 §1 set out to fix.

pad_workspace.action: create
- New action on the shared catalog (stdio + cloud both pick it up).
- POSTs to /api/v1/workspaces; handler auto-adds the new workspace to
  the calling OAuth connection's allow-list (added_by='agent-create')
  when the grant carries may_create_workspaces=true. Phase A wired the
  oauth_connection_workspaces table this writes to. PAT / CLI-session
  callers fall through silently (no request_id → no side effect).
- Backed by a new non-interactive `pad workspace create <name>` Cobra
  command for the stdio MCP shell-out path. `pad workspace init`
  remains the guided human flow.

pad_workspace.action: claim
- New POST /api/v1/oauth/claim endpoint redeems a 6-digit stateless
  HMAC code minted from (user_id, workspace_id, 5-min time bucket)
  with a sliding 5–10 minute lifetime. Constant-time compare. Code
  format derived per IDEA-1517 §4.
- Verifies workspace membership before code (privilege-escalation
  guard); uniform 404 envelope so the endpoint can't be used to probe
  existence vs. membership.
- Side effect inserts a row in oauth_connection_workspaces with
  added_by='claim'. Idempotent — re-claiming returns 200 with
  already_added=true.
- 412 connection_not_persisted when the OAuth grant predates Phase C
  (no oauth_connections row); 412 claim_disabled when the deployment
  hasn't wired SetClaimSecret.
- `pad workspace claim <code> --workspace <slug>` Cobra command backs
  the stdio MCP shell-out.

MCP server instructions
- Appended IDEA-1517 §5 paragraph teaching agents the claim flow as
  a peer top-level section. Same string lands universally on every
  MCP handshake response (stdio + cloud both read instructions.md).

Tests
- 10 claim-code unit tests: determinism, zero-pad, length-prefix
  collision guard, current/previous bucket accept, aged-out reject,
  wrong-everything rejects, short-secret fails closed.
- 7 handler tests: 412 when secret disabled, 400/404/401 vocabulary,
  PAT caller note path, 412 connection_not_persisted, idempotent
  insert.
- 5 MCP-catalog tests: actions registered, schema params advertised,
  description mentions both actions, route mappers produce correct
  HTTP shape, routeTable carries the entries.
- Bumped the existing read-only catalog bijection + fixture-input
  fixtures so the new actions resolve cleanly.

Parent: PLAN-1519.
2026-05-18 00:43:46 -04:00

168 lines
6.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"strings"
"testing"
"time"
)
// Tests for the stateless HMAC claim-code mechanics (PLAN-1519 /
// TASK-1521 / IDEA-1517 §4).
//
// What's covered:
//
// - DeriveClaimCode is deterministic for the same (secret, user,
// workspace, time bucket) — same call twice produces the same code.
// - Codes are six decimal digits, zero-padded.
// - Different (user) or (workspace) inputs produce different codes
// even with the same secret + time (no collisions by length-prefix
// boundary games — would catch a regression where we concatenate
// without length prefix).
// - VerifyClaimCode accepts a code from the current bucket.
// - VerifyClaimCode accepts a code from the PREVIOUS bucket (510 min
// sliding lifetime).
// - VerifyClaimCode REJECTS a code older than two buckets.
// - VerifyClaimCode rejects wrong code / wrong user / wrong workspace.
// - VerifyClaimCode rejects empty / wrong-length codes without
// hitting the HMAC (cheap fast path).
// - Short secret (< 16 bytes) makes VerifyClaimCode fail closed even
// against a derive that used the same short secret — the verifier
// enforces the minimum independently of the derive side, so a
// misconfigured deployment can't accept its own forgeable codes.
func TestDeriveClaimCode_Deterministic(t *testing.T) {
secret := []byte("a-very-deterministic-test-secret-32")
at := time.Date(2026, 5, 18, 4, 0, 0, 0, time.UTC)
a := DeriveClaimCode(secret, "user-1", "ws-1", at)
b := DeriveClaimCode(secret, "user-1", "ws-1", at)
if a != b {
t.Errorf("non-deterministic derive: %q vs %q", a, b)
}
if len(a) != 6 {
t.Errorf("code should be 6 digits, got %q (len=%d)", a, len(a))
}
for _, c := range a {
if c < '0' || c > '9' {
t.Errorf("code should be all decimal digits, got %q", a)
}
}
}
func TestDeriveClaimCode_ZeroPadded(t *testing.T) {
// Brute-force search for an input whose derive happens to be small —
// we want to verify zero-padding works. With 10^6 possible codes,
// roughly 1-in-10 derives lands under 100000; trying 200 different
// workspace IDs is overwhelmingly likely to surface one.
secret := []byte("padding-search-secret-bytes-here-32")
at := time.Date(2026, 5, 18, 4, 0, 0, 0, time.UTC)
for i := 0; i < 200; i++ {
ws := "ws-pad-" + string(rune('a'+i%26)) + string(rune('a'+i/26))
code := DeriveClaimCode(secret, "user", ws, at)
if strings.HasPrefix(code, "0") {
if len(code) != 6 {
t.Errorf("zero-padded code should still be 6 digits, got %q", code)
}
return // found one, assertion satisfied
}
}
t.Log("no leading-zero code found in 200 derivations — extremely unlikely; rerun with more iterations if hit")
}
func TestDeriveClaimCode_NoCollisionOnConcatBoundary(t *testing.T) {
// Without length-prefixing, ("abc", "def") and ("abcdef", "") would
// HMAC the same byte sequence and produce identical codes. The
// length-prefix in writeLenPrefixed prevents this. Regression
// guard — would fail if someone "simplifies" the derive to a bare
// concatenation.
secret := []byte("collision-test-secret-bytes-here-32")
at := time.Date(2026, 5, 18, 4, 0, 0, 0, time.UTC)
a := DeriveClaimCode(secret, "abc", "def", at)
b := DeriveClaimCode(secret, "abcdef", "", at)
if a == b {
t.Errorf("length-prefix regression: (abc,def) and (abcdef,'') derived to same code %q", a)
}
}
func TestVerifyClaimCode_CurrentBucketAccepted(t *testing.T) {
secret := []byte("verify-current-secret-bytes-here-32")
at := time.Date(2026, 5, 18, 4, 0, 0, 0, time.UTC)
code := DeriveClaimCode(secret, "u", "w", at)
if !VerifyClaimCode(secret, "u", "w", code, at) {
t.Errorf("current-bucket code %q should verify", code)
}
}
func TestVerifyClaimCode_PreviousBucketAccepted(t *testing.T) {
secret := []byte("verify-previous-secret-bytes-here-32")
at := time.Date(2026, 5, 18, 4, 0, 0, 0, time.UTC)
code := DeriveClaimCode(secret, "u", "w", at)
// 4 minutes later — same bucket. Still verifies.
if !VerifyClaimCode(secret, "u", "w", code, at.Add(4*time.Minute)) {
t.Errorf("code should still verify 4 minutes later (same bucket)")
}
// 6 minutes later — next bucket; the original code is now in the
// "previous bucket" position. Still verifies.
if !VerifyClaimCode(secret, "u", "w", code, at.Add(6*time.Minute)) {
t.Errorf("code should still verify 6 minutes later (previous bucket window)")
}
}
func TestVerifyClaimCode_AgedOutRejected(t *testing.T) {
secret := []byte("verify-aged-secret-bytes-here-3232")
at := time.Date(2026, 5, 18, 4, 0, 0, 0, time.UTC)
code := DeriveClaimCode(secret, "u", "w", at)
// 11 minutes later — more than two buckets gone, even with sliding
// alignment. Must NOT verify.
if VerifyClaimCode(secret, "u", "w", code, at.Add(11*time.Minute)) {
t.Errorf("aged-out code (>10 min) should not verify")
}
}
func TestVerifyClaimCode_WrongInputsRejected(t *testing.T) {
secret := []byte("verify-wrong-secret-bytes-here-3232")
at := time.Date(2026, 5, 18, 4, 0, 0, 0, time.UTC)
code := DeriveClaimCode(secret, "right-user", "right-ws", at)
cases := []struct {
name string
userID string
wsID string
code string
wantOK bool
comment string
}{
{"right code right user right ws", "right-user", "right-ws", code, true, "baseline"},
{"wrong user same ws", "wrong-user", "right-ws", code, false, "user-bound"},
{"right user wrong ws", "right-user", "wrong-ws", code, false, "ws-bound"},
{"wrong code", "right-user", "right-ws", "000000", false, "wrong digits — almost certainly"},
{"empty code fast-rejects", "right-user", "right-ws", "", false, "guard before HMAC"},
{"5-digit code rejected", "right-user", "right-ws", "12345", false, "wrong length, guard before HMAC"},
{"7-digit code rejected", "right-user", "right-ws", "1234567", false, "wrong length, guard before HMAC"},
{"empty user", "", "right-ws", code, false, "guard"},
{"empty workspace", "right-user", "", code, false, "guard"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := VerifyClaimCode(secret, tc.userID, tc.wsID, tc.code, at)
if got != tc.wantOK {
t.Errorf("%s: want %v got %v (%s)", tc.name, tc.wantOK, got, tc.comment)
}
})
}
}
func TestVerifyClaimCode_ShortSecretFailsClosed(t *testing.T) {
// Derive with a too-short secret — the derive path doesn't reject
// (it's a pure function; the SetClaimSecret setter is where the
// guard nominally lives). Verify with the same secret must still
// return false because VerifyClaimCode enforces the 16-byte
// minimum independently. This guards against a regression where
// VerifyClaimCode might trust whatever it's given.
tooShort := []byte("short")
at := time.Date(2026, 5, 18, 4, 0, 0, 0, time.UTC)
code := DeriveClaimCode(tooShort, "u", "w", at)
if VerifyClaimCode(tooShort, "u", "w", code, at) {
t.Errorf("verifier must reject when secret is shorter than 16 bytes, even if derive matches")
}
}