gogcli/internal/cmd/gmail_watch_redact_test.go
Ryota Ikezawa b49d9d4b92
fix(security): redact webhook bearer token in watch status output (#136)
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:11:32 +00:00

124 lines
3.7 KiB
Go

package cmd
import (
"context"
"encoding/json"
"io"
"os"
"strings"
"testing"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
func TestWriteWatchState_TokenRedaction(t *testing.T) {
makeState := func(token string) gmailWatchState {
return gmailWatchState{
Account: "a@b.com",
Topic: "projects/p/topics/t",
HistoryID: "1",
Hook: &gmailWatchHook{
URL: "http://example.com/hook",
Token: token,
},
}
}
run := func(t *testing.T, state gmailWatchState, showSecrets bool) string {
t.Helper()
return captureStdout(t, func() {
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := ui.WithUI(context.Background(), u)
if err := writeWatchState(ctx, state, showSecrets); err != nil {
t.Fatalf("writeWatchState: %v", err)
}
})
}
t.Run("long token is redacted by default", func(t *testing.T) {
out := run(t, makeState("supersecrettoken123"), false)
if strings.Contains(out, "supersecrettoken123") {
t.Fatal("token should be redacted but was shown in full")
}
if !strings.Contains(out, "supe...(19 chars)") {
t.Fatalf("expected masked token, got: %s", out)
}
})
t.Run("short token is fully redacted", func(t *testing.T) {
out := run(t, makeState("ab"), false)
if strings.Contains(out, "hook_token\tab") {
t.Fatal("short token should be fully redacted")
}
if !strings.Contains(out, "[REDACTED]") {
t.Fatalf("expected [REDACTED], got: %s", out)
}
})
t.Run("4-char token is fully redacted", func(t *testing.T) {
out := run(t, makeState("abcd"), false)
if !strings.Contains(out, "[REDACTED]") {
t.Fatalf("expected [REDACTED] for 4-char token, got: %s", out)
}
})
t.Run("show-secrets reveals full token", func(t *testing.T) {
out := run(t, makeState("supersecrettoken123"), true)
if !strings.Contains(out, "hook_token\tsupersecrettoken123") {
t.Fatalf("expected full token with --show-secrets, got: %s", out)
}
})
t.Run("empty token not shown", func(t *testing.T) {
out := run(t, makeState(""), false)
if strings.Contains(out, "hook_token") {
t.Fatal("empty token should not appear in output")
}
})
t.Run("json output redacts token by default", func(t *testing.T) {
out := captureStdout(t, func() {
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
if err := writeWatchState(ctx, makeState("supersecrettoken123"), false); err != nil {
t.Fatalf("writeWatchState json: %v", err)
}
})
if strings.Contains(out, "supersecrettoken123") {
t.Fatal("JSON output should not contain plaintext token")
}
var parsed map[string]json.RawMessage
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v", err)
}
if !strings.Contains(out, `"[REDACTED]"`) {
t.Fatalf("expected [REDACTED] in JSON, got: %s", out)
}
})
t.Run("json output shows token with show-secrets", func(t *testing.T) {
out := captureStdout(t, func() {
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
if err := writeWatchState(ctx, makeState("supersecrettoken123"), true); err != nil {
t.Fatalf("writeWatchState json: %v", err)
}
})
if !strings.Contains(out, "supersecrettoken123") {
t.Fatalf("JSON with --show-secrets should contain token, got: %s", out)
}
})
}