@tanagram/cli 0.4.18 → 0.4.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/config.go +21 -10
- package/commands/config_test.go +10 -9
- package/commands/list.go +14 -1
- package/commands/login.go +57 -1
- package/commands/login_test.go +132 -0
- package/commands/run.go +194 -156
- package/commands/snapshot.go +11 -1
- package/commands/snapshot_test.go +2 -1
- package/commands/sync.go +65 -29
- package/commands/sync_policies.go +14 -1
- package/dist/npm/darwin-arm64/tanagram +0 -0
- package/dist/npm/darwin-x64/tanagram +0 -0
- package/dist/npm/linux-arm64/tanagram +0 -0
- package/dist/npm/linux-x64/tanagram +0 -0
- package/dist/npm/tanagram_0.4.20_darwin_amd64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.20_darwin_arm64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.20_linux_amd64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.20_linux_arm64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.20_windows_amd64.zip +0 -0
- package/dist/npm/win32-x64/tanagram.exe +0 -0
- package/go.mod +6 -3
- package/go.sum +18 -2
- package/main.go +116 -22
- package/package.json +1 -1
- package/utils/sentry.go +44 -0
- package/dist/npm/tanagram_0.4.18_darwin_amd64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.18_darwin_arm64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.18_linux_amd64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.18_linux_arm64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.18_windows_amd64.zip +0 -0
package/commands/config.go
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
package commands
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"context"
|
|
4
5
|
"encoding/json"
|
|
5
6
|
"fmt"
|
|
6
7
|
"os"
|
|
7
8
|
"path/filepath"
|
|
8
9
|
"strings"
|
|
10
|
+
|
|
11
|
+
"github.com/getsentry/sentry-go"
|
|
9
12
|
)
|
|
10
13
|
|
|
11
14
|
// ConfigClaude sets up the Claude Code hook in the specified settings file
|
|
12
|
-
func ConfigClaude(settingsPath string) error {
|
|
15
|
+
func ConfigClaude(ctx context.Context, settingsPath string) error {
|
|
16
|
+
span := sentry.StartSpan(ctx, "command.config_claude")
|
|
17
|
+
defer span.Finish()
|
|
18
|
+
|
|
13
19
|
settings, err := loadConfig(settingsPath)
|
|
14
20
|
if err != nil {
|
|
15
21
|
return fmt.Errorf("failed to load Claude settings: %w", err)
|
|
@@ -127,7 +133,9 @@ func ConfigClaude(settingsPath string) error {
|
|
|
127
133
|
}
|
|
128
134
|
|
|
129
135
|
// ConfigCursor sets up the Cursor hook in the specified settings file
|
|
130
|
-
func ConfigCursor(hooksPath string) error {
|
|
136
|
+
func ConfigCursor(ctx context.Context, hooksPath string) error {
|
|
137
|
+
span := sentry.StartSpan(ctx, "command.config_cursor")
|
|
138
|
+
defer span.Finish()
|
|
131
139
|
|
|
132
140
|
hooksConfig, err := loadConfig(hooksPath)
|
|
133
141
|
if err != nil {
|
|
@@ -222,7 +230,10 @@ func ConfigCursor(hooksPath string) error {
|
|
|
222
230
|
}
|
|
223
231
|
|
|
224
232
|
// ConfigList shows where Tanagram hooks are installed
|
|
225
|
-
func ConfigList() error {
|
|
233
|
+
func ConfigList(ctx context.Context) error {
|
|
234
|
+
span := sentry.StartSpan(ctx, "command.config_list")
|
|
235
|
+
defer span.Finish()
|
|
236
|
+
|
|
226
237
|
// Check user settings (~/.claude/settings.json)
|
|
227
238
|
userSettingsPath, err := GetHomeConfigPath(".claude", "settings.json")
|
|
228
239
|
if err != nil {
|
|
@@ -526,7 +537,7 @@ func printCursorHookStatus(status CursorHookStatus, path string) {
|
|
|
526
537
|
// ensureClaudeConfigured checks if Claude Code hooks are configured,
|
|
527
538
|
// and automatically sets them up if not. This is called on first run
|
|
528
539
|
// of commands that need Claude Code integration.
|
|
529
|
-
func ensureClaudeConfigured() error {
|
|
540
|
+
func ensureClaudeConfigured(ctx context.Context) error {
|
|
530
541
|
settingsPath, err := GetHomeConfigPath(".claude", "settings.json")
|
|
531
542
|
if err != nil {
|
|
532
543
|
// Silently skip if we can't get home dir - not critical
|
|
@@ -543,7 +554,7 @@ func ensureClaudeConfigured() error {
|
|
|
543
554
|
// If file doesn't exist or hooks aren't configured, set them up
|
|
544
555
|
if !status.FileExists || !status.SessionStartHookExists || !status.StopHookExists {
|
|
545
556
|
fmt.Println("Setting up Claude Code integration...")
|
|
546
|
-
if err := ConfigClaude(settingsPath); err != nil {
|
|
557
|
+
if err := ConfigClaude(ctx, settingsPath); err != nil {
|
|
547
558
|
// Don't fail the command if hook setup fails - just warn
|
|
548
559
|
fmt.Fprintf(os.Stderr, "Warning: Failed to setup Claude Code hooks: %v\n", err)
|
|
549
560
|
fmt.Fprintf(os.Stderr, "You can manually setup hooks later with: tanagram config claude\n\n")
|
|
@@ -558,7 +569,7 @@ func ensureClaudeConfigured() error {
|
|
|
558
569
|
// ensureCursorConfigured checks if Cursor hooks are configured,
|
|
559
570
|
// and automatically sets them up if not. This is called on first run
|
|
560
571
|
// of commands that need Cursor integration.
|
|
561
|
-
func ensureCursorConfigured() error {
|
|
572
|
+
func ensureCursorConfigured(ctx context.Context) error {
|
|
562
573
|
hooksPath, err := GetHomeConfigPath(".cursor", "hooks.json")
|
|
563
574
|
if err != nil {
|
|
564
575
|
// Silently skip if we can't get home dir - not critical
|
|
@@ -575,7 +586,7 @@ func ensureCursorConfigured() error {
|
|
|
575
586
|
// If file doesn't exist or hooks aren't configured, set them up
|
|
576
587
|
if !status.FileExists || !status.BeforeSubmitExists || !status.StopExists {
|
|
577
588
|
fmt.Println("Setting up Cursor integration...")
|
|
578
|
-
if err := ConfigCursor(hooksPath); err != nil {
|
|
589
|
+
if err := ConfigCursor(ctx, hooksPath); err != nil {
|
|
579
590
|
// Don't fail the command if hook setup fails - just warn
|
|
580
591
|
fmt.Fprintf(os.Stderr, "Warning: Failed to setup Cursor hooks: %v\n", err)
|
|
581
592
|
fmt.Fprintf(os.Stderr, "You can manually setup hooks later with: tanagram config cursor\n\n")
|
|
@@ -589,11 +600,11 @@ func ensureCursorConfigured() error {
|
|
|
589
600
|
|
|
590
601
|
// EnsureHooksConfigured ensures that both Claude Code and Cursor hooks are configured.
|
|
591
602
|
// This is a convenience function for commands that need both integrations.
|
|
592
|
-
func EnsureHooksConfigured() error {
|
|
593
|
-
if err := ensureClaudeConfigured(); err != nil {
|
|
603
|
+
func EnsureHooksConfigured(ctx context.Context) error {
|
|
604
|
+
if err := ensureClaudeConfigured(ctx); err != nil {
|
|
594
605
|
return err
|
|
595
606
|
}
|
|
596
|
-
if err := ensureCursorConfigured(); err != nil {
|
|
607
|
+
if err := ensureCursorConfigured(ctx); err != nil {
|
|
597
608
|
return err
|
|
598
609
|
}
|
|
599
610
|
return nil
|
package/commands/config_test.go
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package commands
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"context"
|
|
4
5
|
"encoding/json"
|
|
5
6
|
"os"
|
|
6
7
|
"path/filepath"
|
|
@@ -27,7 +28,7 @@ func TestConfigClaude(t *testing.T) {
|
|
|
27
28
|
}()
|
|
28
29
|
|
|
29
30
|
t.Run("Create new config", func(t *testing.T) {
|
|
30
|
-
if err := ConfigClaude(settingsPath); err != nil {
|
|
31
|
+
if err := ConfigClaude(context.Background(), settingsPath); err != nil {
|
|
31
32
|
t.Fatalf("ConfigClaude() failed: %v", err)
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -43,7 +44,7 @@ func TestConfigClaude(t *testing.T) {
|
|
|
43
44
|
|
|
44
45
|
t.Run("Idempotency", func(t *testing.T) {
|
|
45
46
|
// Run it again (first time was in "Create new config")
|
|
46
|
-
if err := ConfigClaude(settingsPath); err != nil {
|
|
47
|
+
if err := ConfigClaude(context.Background(), settingsPath); err != nil {
|
|
47
48
|
t.Fatalf("ConfigClaude() failed on second run: %v", err)
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -96,7 +97,7 @@ func TestConfigClaude(t *testing.T) {
|
|
|
96
97
|
t.Fatalf("Failed to write settings: %v", err)
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
if err := ConfigClaude(settingsPath); err != nil {
|
|
100
|
+
if err := ConfigClaude(context.Background(), settingsPath); err != nil {
|
|
100
101
|
t.Fatalf("ConfigClaude() failed: %v", err)
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -174,7 +175,7 @@ func TestConfigClaude(t *testing.T) {
|
|
|
174
175
|
t.Fatalf("Failed to write settings: %v", err)
|
|
175
176
|
}
|
|
176
177
|
|
|
177
|
-
if err := ConfigClaude(settingsPath); err != nil {
|
|
178
|
+
if err := ConfigClaude(context.Background(), settingsPath); err != nil {
|
|
178
179
|
t.Fatalf("ConfigClaude() failed: %v", err)
|
|
179
180
|
}
|
|
180
181
|
|
|
@@ -234,7 +235,7 @@ func TestConfigCursor(t *testing.T) {
|
|
|
234
235
|
}()
|
|
235
236
|
|
|
236
237
|
t.Run("Create new config", func(t *testing.T) {
|
|
237
|
-
if err := ConfigCursor(hooksPath); err != nil {
|
|
238
|
+
if err := ConfigCursor(context.Background(), hooksPath); err != nil {
|
|
238
239
|
t.Fatalf("ConfigCursor() failed: %v", err)
|
|
239
240
|
}
|
|
240
241
|
|
|
@@ -250,7 +251,7 @@ func TestConfigCursor(t *testing.T) {
|
|
|
250
251
|
|
|
251
252
|
t.Run("Idempotency", func(t *testing.T) {
|
|
252
253
|
// Run it again
|
|
253
|
-
if err := ConfigCursor(hooksPath); err != nil {
|
|
254
|
+
if err := ConfigCursor(context.Background(), hooksPath); err != nil {
|
|
254
255
|
t.Fatalf("ConfigCursor() failed on second run: %v", err)
|
|
255
256
|
}
|
|
256
257
|
|
|
@@ -297,7 +298,7 @@ func TestConfigCursor(t *testing.T) {
|
|
|
297
298
|
t.Fatalf("Failed to write settings: %v", err)
|
|
298
299
|
}
|
|
299
300
|
|
|
300
|
-
if err := ConfigCursor(hooksPath); err != nil {
|
|
301
|
+
if err := ConfigCursor(context.Background(), hooksPath); err != nil {
|
|
301
302
|
t.Fatalf("ConfigCursor() failed: %v", err)
|
|
302
303
|
}
|
|
303
304
|
|
|
@@ -351,7 +352,7 @@ func TestConfigCursor(t *testing.T) {
|
|
|
351
352
|
t.Fatalf("Failed to write settings: %v", err)
|
|
352
353
|
}
|
|
353
354
|
|
|
354
|
-
if err := ConfigCursor(hooksPath); err != nil {
|
|
355
|
+
if err := ConfigCursor(context.Background(), hooksPath); err != nil {
|
|
355
356
|
t.Fatalf("ConfigCursor() failed: %v", err)
|
|
356
357
|
}
|
|
357
358
|
|
|
@@ -413,7 +414,7 @@ func TestEnsureHooksConfigured(t *testing.T) {
|
|
|
413
414
|
}()
|
|
414
415
|
|
|
415
416
|
t.Run("Fresh configuration", func(t *testing.T) {
|
|
416
|
-
if err := EnsureHooksConfigured(); err != nil {
|
|
417
|
+
if err := EnsureHooksConfigured(context.Background()); err != nil {
|
|
417
418
|
t.Fatalf("EnsureHooksConfigured() failed: %v", err)
|
|
418
419
|
}
|
|
419
420
|
|
package/commands/list.go
CHANGED
|
@@ -1,33 +1,45 @@
|
|
|
1
1
|
package commands
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"context"
|
|
4
5
|
"fmt"
|
|
5
6
|
|
|
7
|
+
"github.com/getsentry/sentry-go"
|
|
6
8
|
gitpkg "github.com/tanagram/cli/git"
|
|
7
9
|
"github.com/tanagram/cli/storage"
|
|
8
10
|
)
|
|
9
11
|
|
|
10
12
|
// List displays all cached policies (both local and cloud)
|
|
11
|
-
func List() error {
|
|
13
|
+
func List(ctx context.Context) error {
|
|
14
|
+
span := sentry.StartSpan(ctx, "command.list")
|
|
15
|
+
defer span.Finish()
|
|
16
|
+
|
|
12
17
|
// Find git root
|
|
18
|
+
findGitRootSpan := sentry.StartSpan(span.Context(), "storage.find_git_root")
|
|
13
19
|
gitRoot, err := storage.FindGitRoot()
|
|
20
|
+
findGitRootSpan.Finish()
|
|
14
21
|
if err != nil {
|
|
15
22
|
return err
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
// Load local cache
|
|
26
|
+
loadCacheSpan := sentry.StartSpan(span.Context(), "storage.load_cache")
|
|
19
27
|
cache, err := storage.LoadCache(gitRoot)
|
|
28
|
+
loadCacheSpan.Finish()
|
|
20
29
|
if err != nil {
|
|
21
30
|
return fmt.Errorf("failed to load cache: %w", err)
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
// Load local policies from cache
|
|
34
|
+
getPoliciesSpan := sentry.StartSpan(span.Context(), "cache.get_all_policies")
|
|
25
35
|
localPolicies, err := cache.GetAllPolicies()
|
|
36
|
+
getPoliciesSpan.Finish()
|
|
26
37
|
if err != nil {
|
|
27
38
|
return fmt.Errorf("failed to load local policies: %w", err)
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
// Try to load cloud policies for current repo
|
|
42
|
+
cloudPoliciesSpan := sentry.StartSpan(span.Context(), "storage.load_cloud_policies")
|
|
31
43
|
cloudPolicies := []storage.SimplifiedPolicy{}
|
|
32
44
|
var repoInfo *gitpkg.RepoInfo
|
|
33
45
|
|
|
@@ -40,6 +52,7 @@ func List() error {
|
|
|
40
52
|
cloudPolicies = repoFile.Policies
|
|
41
53
|
}
|
|
42
54
|
}
|
|
55
|
+
cloudPoliciesSpan.Finish()
|
|
43
56
|
|
|
44
57
|
// Check if we have any policies to display
|
|
45
58
|
if len(localPolicies) == 0 && len(cloudPolicies) == 0 {
|
package/commands/login.go
CHANGED
|
@@ -12,8 +12,11 @@ import (
|
|
|
12
12
|
"os"
|
|
13
13
|
"time"
|
|
14
14
|
|
|
15
|
+
"github.com/getsentry/sentry-go"
|
|
16
|
+
"github.com/golang-jwt/jwt/v5"
|
|
15
17
|
"github.com/pkg/browser"
|
|
16
18
|
"github.com/tanagram/cli/auth"
|
|
19
|
+
"github.com/tanagram/cli/utils"
|
|
17
20
|
)
|
|
18
21
|
|
|
19
22
|
// getWebAppURL returns the web app URL based on environment
|
|
@@ -45,7 +48,12 @@ func generateCodeChallenge(verifier string) string {
|
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
// Login implements the OAuth PKCE flow for CLI authentication
|
|
48
|
-
func Login() error {
|
|
51
|
+
func Login(ctx context.Context) error {
|
|
52
|
+
span := sentry.StartSpan(ctx, "command.login")
|
|
53
|
+
defer span.Finish()
|
|
54
|
+
|
|
55
|
+
utils.AddBreadcrumb("auth", "Starting OAuth PKCE login flow", sentry.LevelInfo, nil)
|
|
56
|
+
|
|
49
57
|
// Generate PKCE parameters
|
|
50
58
|
codeVerifier, err := generateCodeVerifier()
|
|
51
59
|
if err != nil {
|
|
@@ -301,15 +309,19 @@ func Login() error {
|
|
|
301
309
|
fmt.Println("Waiting for authentication...")
|
|
302
310
|
|
|
303
311
|
// Wait for callback or timeout
|
|
312
|
+
waitSpan := sentry.StartSpan(span.Context(), "auth.wait_for_callback")
|
|
304
313
|
var authCode string
|
|
305
314
|
select {
|
|
306
315
|
case authCode = <-codeChan:
|
|
307
316
|
// Success
|
|
308
317
|
case err := <-errChan:
|
|
318
|
+
waitSpan.Finish()
|
|
309
319
|
return err
|
|
310
320
|
case <-time.After(5 * time.Minute):
|
|
321
|
+
waitSpan.Finish()
|
|
311
322
|
return fmt.Errorf("authentication timeout - please try again")
|
|
312
323
|
}
|
|
324
|
+
waitSpan.Finish()
|
|
313
325
|
|
|
314
326
|
// Shutdown the callback server
|
|
315
327
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
@@ -328,9 +340,15 @@ func Login() error {
|
|
|
328
340
|
// Store session JWT in OS keyring
|
|
329
341
|
fmt.Println("Storing session token securely...")
|
|
330
342
|
|
|
343
|
+
storeTokenSpan := sentry.StartSpan(span.Context(), "auth.store_token")
|
|
331
344
|
if err := auth.SetAccessToken(sessionJWT); err != nil {
|
|
345
|
+
storeTokenSpan.Finish()
|
|
332
346
|
return fmt.Errorf("failed to store session token: %w", err)
|
|
333
347
|
}
|
|
348
|
+
storeTokenSpan.Finish()
|
|
349
|
+
|
|
350
|
+
// Set user context in Sentry from JWT claims
|
|
351
|
+
SetUserContextFromJWT(sessionJWT)
|
|
334
352
|
|
|
335
353
|
fmt.Println("\n✓ Successfully logged in to Tanagram")
|
|
336
354
|
fmt.Printf("Session token stored securely in OS keyring\n")
|
|
@@ -338,4 +356,42 @@ func Login() error {
|
|
|
338
356
|
return nil
|
|
339
357
|
}
|
|
340
358
|
|
|
359
|
+
type jwtClaims struct {
|
|
360
|
+
Subject string
|
|
361
|
+
Email string
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// SetUserContextFromJWT parses JWT claims and sets Sentry user context
|
|
365
|
+
func SetUserContextFromJWT(sessionJWT string) {
|
|
366
|
+
if claims := parseJWTClaims(sessionJWT); claims != nil {
|
|
367
|
+
utils.SetUserContext(claims.Subject, claims.Email)
|
|
368
|
+
utils.AddBreadcrumb("auth", "Login successful", sentry.LevelInfo, map[string]interface{}{
|
|
369
|
+
"user_id": claims.Subject,
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
func parseJWTClaims(tokenString string) *jwtClaims {
|
|
375
|
+
parser := jwt.NewParser()
|
|
376
|
+
token, _, err := parser.ParseUnverified(tokenString, jwt.MapClaims{})
|
|
377
|
+
if err != nil {
|
|
378
|
+
return nil
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
claims, ok := token.Claims.(jwt.MapClaims)
|
|
382
|
+
if !ok {
|
|
383
|
+
return nil
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
result := &jwtClaims{}
|
|
387
|
+
if sub, ok := claims["sub"].(string); ok {
|
|
388
|
+
result.Subject = sub
|
|
389
|
+
}
|
|
390
|
+
if email, ok := claims["email"].(string); ok {
|
|
391
|
+
result.Email = email
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return result
|
|
395
|
+
}
|
|
396
|
+
|
|
341
397
|
// Removed exchangeCodeForTokens - we receive the session JWT directly from the web app
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
package commands
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/getsentry/sentry-go"
|
|
8
|
+
"github.com/golang-jwt/jwt/v5"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestParseJWTClaims(t *testing.T) {
|
|
12
|
+
t.Run("valid JWT with sub and email", func(t *testing.T) {
|
|
13
|
+
// Create a real JWT token
|
|
14
|
+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
15
|
+
"sub": "user_12345",
|
|
16
|
+
"email": "test@example.com",
|
|
17
|
+
"iat": time.Now().Unix(),
|
|
18
|
+
"exp": time.Now().Add(time.Hour).Unix(),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// Sign with a test secret
|
|
22
|
+
tokenString, err := token.SignedString([]byte("test-secret"))
|
|
23
|
+
if err != nil {
|
|
24
|
+
t.Fatalf("Failed to sign token: %v", err)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
claims := parseJWTClaims(tokenString)
|
|
28
|
+
if claims == nil {
|
|
29
|
+
t.Fatal("Expected claims, got nil")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if claims.Subject != "user_12345" {
|
|
33
|
+
t.Errorf("Expected Subject 'user_12345', got '%s'", claims.Subject)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if claims.Email != "test@example.com" {
|
|
37
|
+
t.Errorf("Expected Email 'test@example.com', got '%s'", claims.Email)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
t.Run("valid JWT with only sub", func(t *testing.T) {
|
|
42
|
+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
43
|
+
"sub": "user_67890",
|
|
44
|
+
"iat": time.Now().Unix(),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
tokenString, err := token.SignedString([]byte("test-secret"))
|
|
48
|
+
if err != nil {
|
|
49
|
+
t.Fatalf("Failed to sign token: %v", err)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
claims := parseJWTClaims(tokenString)
|
|
53
|
+
if claims == nil {
|
|
54
|
+
t.Fatal("Expected claims, got nil")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if claims.Subject != "user_67890" {
|
|
58
|
+
t.Errorf("Expected Subject 'user_67890', got '%s'", claims.Subject)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if claims.Email != "" {
|
|
62
|
+
t.Errorf("Expected empty Email, got '%s'", claims.Email)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
t.Run("invalid JWT string", func(t *testing.T) {
|
|
67
|
+
claims := parseJWTClaims("not-a-valid-jwt")
|
|
68
|
+
if claims != nil {
|
|
69
|
+
t.Error("Expected nil for invalid JWT, got claims")
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
t.Run("empty JWT string", func(t *testing.T) {
|
|
74
|
+
claims := parseJWTClaims("")
|
|
75
|
+
if claims != nil {
|
|
76
|
+
t.Error("Expected nil for empty string, got claims")
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func TestSetUserContextFromJWT(t *testing.T) {
|
|
82
|
+
t.Run("sets user context from valid JWT", func(t *testing.T) {
|
|
83
|
+
// Channel to capture the event
|
|
84
|
+
var capturedEvent *sentry.Event
|
|
85
|
+
|
|
86
|
+
// Initialize Sentry with a transport that captures events
|
|
87
|
+
err := sentry.Init(sentry.ClientOptions{
|
|
88
|
+
Dsn: "https://test@test.ingest.sentry.io/123",
|
|
89
|
+
Environment: "test",
|
|
90
|
+
BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
|
|
91
|
+
capturedEvent = event
|
|
92
|
+
return nil // Don't actually send
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
if err != nil {
|
|
96
|
+
t.Fatalf("Failed to initialize Sentry: %v", err)
|
|
97
|
+
}
|
|
98
|
+
defer sentry.Flush(time.Second)
|
|
99
|
+
|
|
100
|
+
// Create a real JWT with user claims
|
|
101
|
+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
102
|
+
"sub": "user_abc123",
|
|
103
|
+
"email": "alice@tanagram.ai",
|
|
104
|
+
"iat": time.Now().Unix(),
|
|
105
|
+
"exp": time.Now().Add(time.Hour).Unix(),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
tokenString, err := token.SignedString([]byte("test-secret"))
|
|
109
|
+
if err != nil {
|
|
110
|
+
t.Fatalf("Failed to sign token: %v", err)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Call the function under test
|
|
114
|
+
SetUserContextFromJWT(tokenString)
|
|
115
|
+
|
|
116
|
+
// Capture an event to verify the user context was set
|
|
117
|
+
sentry.CaptureMessage("test event")
|
|
118
|
+
sentry.Flush(time.Second)
|
|
119
|
+
|
|
120
|
+
if capturedEvent == nil {
|
|
121
|
+
t.Fatal("Expected event to be captured")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if capturedEvent.User.ID != "user_abc123" {
|
|
125
|
+
t.Errorf("Expected user ID 'user_abc123', got '%s'", capturedEvent.User.ID)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if capturedEvent.User.Email != "alice@tanagram.ai" {
|
|
129
|
+
t.Errorf("Expected email 'alice@tanagram.ai', got '%s'", capturedEvent.User.Email)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
}
|