Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 8 additions & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ Quick reference for AI assistants working on this codebase.
## ⛔ CRITICAL RULES - YOU MUST FOLLOW THESE

### NEVER DO (IMPORTANT - violations will break the workflow):
- **NEVER run `git commit`** - User commits manually
- **NEVER run `git push`** - User pushes manually
- **NEVER commit secrets** - No API keys, tokens, passwords, .env files
- **NEVER skip tests** - All changes require passing tests
- **NEVER skip security scans** - Run `make security` before commits
Expand Down Expand Up @@ -139,31 +137,17 @@ Credentials stored in system keyring (service: `"nylas"`) via `nylas auth config

## Testing

**Command:** `make ci-full` (complete CI: quality + tests + cleanup)

**Quick checks:** `make ci` (no integration tests)

**Details:** `.claude/rules/testing.md`

---

## Hooks & Commands

**Hooks:** Auto-enforce quality (blocks bad code, auto-formats). See `.claude/HOOKS-CONFIG.md`

**Skills:** `/session-start`, `/run-tests`, `/add-command`, `/generate-tests`, `/security-scan`

**Agents:** See `.claude/agents/README.md` for parallelization guide

---

## Context & Session
## Hooks, Skills & Agents

**Token tips:** Use `/compact` mid-session, `/clear` for new tasks, `/mcp` to disable unused servers
**Hooks:** Auto-enforce quality. See `.claude/HOOKS-CONFIG.md`

**On-demand docs:** `docs/COMMANDS.md`, `docs/ARCHITECTURE.md`, `.claude/shared/patterns/*.md`
**Skills:** `/run-tests`, `/add-command`, `/generate-tests`, `/security-scan`

**Session handoff:** Use `/diary` skill to record progress after major tasks
**Agents:** See `.claude/agents/README.md`

---

Expand Down Expand Up @@ -206,6 +190,10 @@ This section captures lessons learned from mistakes. Claude updates this section
- AI clients: Use shared helpers in `adapters/ai/base_client.go` (ConvertMessagesToMaps, ConvertToolsOpenAIFormat, FallbackStreamChat)
- Output formatting: Use `common.GetOutputWriter(cmd)` for JSON/YAML/quiet support, NEVER create custom --format flags
- Client helpers: Use `common.WithClient()` and `WithClientNoGrant()` to reduce boilerplate, NEVER duplicate setup code
- Status colors: Use `common.StatusColor()`/`StatusIcon()`/`ColorSprint()`, NEVER create package-local status color functions
- Pagination: Use `common.SetupPagination()` for limit/maxItems resolution, NEVER duplicate auto-pagination logic in list commands
- Pagination helpers: Use `common.NormalizePageSize()` and `FetchCursorPages()` for cursor-based pagination boilerplate
- Test JSON responses: Use `testutil.WriteJSONResponse()` in httptest handlers, NEVER repeat the Content-Type/WriteHeader/Encode triplet

### Non-Obvious Workflows
- Progressive disclosure: Keep main skill files under 100 lines, use references/ for details
Expand Down
20 changes: 12 additions & 8 deletions internal/adapters/dashboard/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ func (c *AccountClient) doPost(ctx context.Context, path string, body any, extra
return nil
}

// setDPoPProof generates and sets the DPoP proof header on the request.
func (c *AccountClient) setDPoPProof(req *http.Request, method, fullURL, accessToken string) error {
proof, err := c.dpop.GenerateProof(method, fullURL, accessToken)
if err != nil {
return err
}
req.Header.Set("DPoP", proof)
return nil
}

// doPostRaw sends a JSON POST request and returns the raw response body.
func (c *AccountClient) doPostRaw(ctx context.Context, path string, body any, extraHeaders map[string]string, accessToken string) ([]byte, error) {
fullURL := c.baseURL + path
Expand All @@ -68,12 +78,9 @@ func (c *AccountClient) doPostRaw(ctx context.Context, path string, body any, ex
req.Header.Set("Content-Type", "application/json")
}

// Add DPoP proof
proof, err := c.dpop.GenerateProof(http.MethodPost, fullURL, accessToken)
if err != nil {
if err := c.setDPoPProof(req, http.MethodPost, fullURL, accessToken); err != nil {
return nil, err
}
req.Header.Set("DPoP", proof)

// Add extra headers (Authorization, X-Nylas-Org)
for k, v := range extraHeaders {
Expand Down Expand Up @@ -135,12 +142,9 @@ func (c *AccountClient) doGetRaw(ctx context.Context, path string, extraHeaders
return nil, fmt.Errorf("failed to create request: %w", err)
}

// Add DPoP proof
proof, err := c.dpop.GenerateProof(http.MethodGet, fullURL, accessToken)
if err != nil {
if err := c.setDPoPProof(req, http.MethodGet, fullURL, accessToken); err != nil {
return nil, err
}
req.Header.Set("DPoP", proof)

for k, v := range extraHeaders {
req.Header.Set(k, v)
Expand Down
248 changes: 248 additions & 0 deletions internal/adapters/dashboard/http_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
package dashboard

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)

// mockDPoP implements ports.DPoP for testing.
type mockDPoP struct {
proof string
err error
}

func (m *mockDPoP) GenerateProof(method, url, accessToken string) (string, error) {
return m.proof, m.err
}

func (m *mockDPoP) Thumbprint() string {
return "test-thumbprint"
}

func TestSetDPoPProof(t *testing.T) {
tests := []struct {
name string
proof string
proofErr error
wantHeader string
wantErr bool
}{
{
name: "sets DPoP header on success",
proof: "test-proof-jwt",
wantHeader: "test-proof-jwt",
},
{
name: "returns error on failure",
proofErr: errTestDPoP,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &AccountClient{
dpop: &mockDPoP{proof: tt.proof, err: tt.proofErr},
}

req := httptest.NewRequest(http.MethodPost, "https://example.com/test", nil)
err := client.setDPoPProof(req, http.MethodPost, "https://example.com/test", "token")

if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}

if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if got := req.Header.Get("DPoP"); got != tt.wantHeader {
t.Errorf("DPoP header = %q, want %q", got, tt.wantHeader)
}
})
}
}

var errTestDPoP = &testError{msg: "dpop generation failed"}

type testError struct{ msg string }

func (e *testError) Error() string { return e.msg }

func TestParseErrorResponse(t *testing.T) {
tests := []struct {
name string
statusCode int
body string
wantMsg string
}{
{
name: "parses error with code and message",
statusCode: 400,
body: `{"error":{"code":"invalid_request","message":"bad input"}}`,
wantMsg: "invalid_request: bad input",
},
{
name: "parses error with message only",
statusCode: 500,
body: `{"error":{"message":"internal error"}}`,
wantMsg: "internal error",
},
{
name: "falls back to raw body",
statusCode: 502,
body: "Bad Gateway",
wantMsg: "Bad Gateway",
},
{
name: "truncates long body",
statusCode: 500,
body: string(make([]byte, 300)),
wantMsg: "", // truncated to 200 chars
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := parseErrorResponse(tt.statusCode, []byte(tt.body))
dashErr, ok := err.(*DashboardAPIError)
if !ok {
t.Fatalf("expected *DashboardAPIError, got %T", err)
}
if dashErr.StatusCode != tt.statusCode {
t.Errorf("StatusCode = %d, want %d", dashErr.StatusCode, tt.statusCode)
}
if tt.wantMsg != "" && dashErr.ServerMsg != tt.wantMsg {
t.Errorf("ServerMsg = %q, want %q", dashErr.ServerMsg, tt.wantMsg)
}
})
}
}

func TestUnwrapEnvelope(t *testing.T) {
tests := []struct {
name string
body string
wantKey string
wantErr bool
}{
{
name: "unwraps data field",
body: `{"request_id":"abc","success":true,"data":{"name":"test"}}`,
wantKey: "name",
},
{
name: "returns body as-is when no data field",
body: `{"name":"test"}`,
wantKey: "name",
},
{
name: "returns error on invalid JSON",
body: "not json",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := unwrapEnvelope([]byte(tt.body))
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

var parsed map[string]any
if jsonErr := json.Unmarshal(result, &parsed); jsonErr != nil {
t.Fatalf("result is not valid JSON: %v", jsonErr)
}
if _, ok := parsed[tt.wantKey]; !ok {
t.Errorf("result missing key %q: %s", tt.wantKey, string(result))
}
})
}
}

func TestDashboardAPIError_Error(t *testing.T) {
tests := []struct {
name string
err DashboardAPIError
wantStr string
}{
{
name: "with message",
err: DashboardAPIError{StatusCode: 400, ServerMsg: "bad request"},
wantStr: "dashboard API error (HTTP 400): bad request",
},
{
name: "without message",
err: DashboardAPIError{StatusCode: 500},
wantStr: "dashboard API error (HTTP 500)",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.err.Error(); got != tt.wantStr {
t.Errorf("Error() = %q, want %q", got, tt.wantStr)
}
})
}
}

func TestDoPostAndGet_Integration(t *testing.T) {
// Set up a test server that returns a valid envelope response
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify DPoP header was set
if dpop := r.Header.Get("DPoP"); dpop == "" {
t.Error("DPoP header not set")
}

w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"request_id": "test-123",
"success": true,
"data": map[string]string{"id": "app-1", "name": "Test App"},
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()

client := &AccountClient{
baseURL: server.URL,
httpClient: server.Client(),
dpop: &mockDPoP{proof: "test-proof"},
}

t.Run("doPost decodes response", func(t *testing.T) {
var result map[string]string
err := client.doPost(context.Background(), "/test", nil, nil, "token", &result)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result["id"] != "app-1" {
t.Errorf("result[id] = %q, want %q", result["id"], "app-1")
}
})

t.Run("doGet decodes response", func(t *testing.T) {
var result map[string]string
err := client.doGet(context.Background(), "/test", nil, "token", &result)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result["name"] != "Test App" {
t.Errorf("result[name] = %q, want %q", result["name"], "Test App")
}
})
}
Loading
Loading