gogcli/internal/cmd/gmail_watch_helpers_test.go
Ryota Ikezawa 754d75ea07 fix(security): redact webhook bearer token in watch status output
The hook_token was printed in plaintext via `gmail watch status`, which
could leak the bearer token through terminal scrollback, CI logs, or
screen sharing.  Token is now masked by default (first 4 chars + length)
in both text and JSON output.  A new `--show-secrets` flag on the status
subcommand reveals the full value when explicitly requested.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-08 03:06:18 +00:00

191 lines
4.9 KiB
Go

package cmd
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"strings"
"testing"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
func TestWriteWatchState_TextAndJSON(t *testing.T) {
state := gmailWatchState{
Account: "a@b.com",
Topic: "projects/p/topics/t",
Labels: []string{"INBOX", "Label_1"},
HistoryID: "123",
ExpirationMs: 1,
ProviderExpirationMs: 2,
RenewAfterMs: 3,
UpdatedAtMs: 4,
Hook: &gmailWatchHook{
URL: "http://example.com/hook",
IncludeBody: true,
MaxBytes: 12,
Token: "tok",
},
LastDeliveryStatus: "ok",
LastDeliveryAtMs: 5,
LastDeliveryStatusNote: "note",
}
textOut := captureStdout(t, func() {
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
if err := writeWatchState(ctx, state, false); err != nil {
t.Fatalf("writeWatchState: %v", err)
}
})
if !strings.Contains(textOut, "account\ta@b.com") {
t.Fatalf("expected account output")
}
if !strings.Contains(textOut, "hook_url\thttp://example.com/hook") {
t.Fatalf("expected hook output")
}
jsonOut := captureStdout(t, func() {
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
if err := writeWatchState(ctx, state, false); err != nil {
t.Fatalf("writeWatchState json: %v", err)
}
})
var parsed struct {
Watch gmailWatchState `json:"watch"`
}
if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil {
t.Fatalf("json parse: %v", err)
}
if parsed.Watch.Hook == nil || parsed.Watch.Hook.URL == "" {
t.Fatalf("expected hook in json")
}
}
func TestHookFromFlags(t *testing.T) {
t.Run("missing url with token", func(t *testing.T) {
if _, err := hookFromFlags("", "tok", false, 0, false, false); err == nil {
t.Fatalf("expected error")
}
})
t.Run("missing url with hook opts", func(t *testing.T) {
if _, err := hookFromFlags("", "", true, 0, true, false); err == nil {
t.Fatalf("expected error")
}
})
t.Run("allow no hook", func(t *testing.T) {
hook, err := hookFromFlags("", "", false, 0, false, true)
if err == nil || !errors.Is(err, errNoHookConfigured) {
t.Fatalf("expected no hook error, got: %v", err)
}
if hook != nil {
t.Fatalf("expected nil hook")
}
})
t.Run("defaults max bytes", func(t *testing.T) {
hook, err := hookFromFlags("http://example.com", "", true, 0, false, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hook.MaxBytes != defaultHookMaxBytes {
t.Fatalf("expected default max bytes")
}
})
t.Run("invalid max bytes", func(t *testing.T) {
if _, err := hookFromFlags("http://example.com", "", false, 0, true, false); err == nil {
t.Fatalf("expected error")
}
})
}
func TestIsLoopbackHost(t *testing.T) {
cases := map[string]bool{
"": true,
"localhost": true,
"127.0.0.1": true,
"[::1]": true,
"example.com": false,
}
for host, want := range cases {
if got := isLoopbackHost(host); got != want {
t.Fatalf("isLoopbackHost(%q)=%v want %v", host, got, want)
}
}
}
func TestGmailWatchStore_StateHelpers(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
store, err := newGmailWatchStore("User+X@Example.COM")
if err != nil {
t.Fatalf("store: %v", err)
}
if !strings.Contains(store.path, "user_x_example_com.json") {
t.Fatalf("unexpected path: %s", store.path)
}
id, startErr := store.StartHistoryID("101")
if startErr != nil {
t.Fatalf("start history: %v", startErr)
}
if id != 101 {
t.Fatalf("expected history id 101, got %d", id)
}
if store.state.HistoryID != "101" {
t.Fatalf("expected history set")
}
id, startErr = store.StartHistoryID("")
if startErr != nil {
t.Fatalf("start history existing: %v", startErr)
}
if id != 101 {
t.Fatalf("expected history id 101, got %d", id)
}
id, startErr = store.StartHistoryID("100")
if startErr != nil {
t.Fatalf("start history stale: %v", startErr)
}
if id != 0 {
t.Fatalf("expected stale history ignored, got %d", id)
}
if store.state.HistoryID != "101" {
t.Fatalf("expected history unchanged")
}
id, startErr = store.StartHistoryID("bad")
if startErr != nil {
t.Fatalf("start history invalid push: %v", startErr)
}
if id != 101 {
t.Fatalf("expected history id 101, got %d", id)
}
if _, err := parseHistoryID(""); err == nil {
t.Fatalf("expected parse error")
}
if got := formatHistoryID(0); got != "" {
t.Fatalf("expected empty format")
}
}
func TestGmailWatchStore_SaveMissingPath(t *testing.T) {
store := &gmailWatchStore{}
if err := store.Save(); err == nil {
t.Fatalf("expected error")
}
}