Compare commits

...

2 Commits

Author SHA1 Message Date
19a9e2a3df Fix test parallelism in e2e tests
Some checks failed
CI / build (1.21.x) (push) Has been cancelled
CI / build (1.22.x) (push) Has been cancelled
CI / security-scan (push) Has been cancelled
- Removed t.Parallel() from e2e tests that share global state
- Tests now run sequentially to avoid conflicts with cobra command initialization
- Ensures reliable test execution without race conditions
2026-05-04 02:08:02 -04:00
d53b8ec8bc FRE-4694: Add CLI command end-to-end tests
Add comprehensive e2e tests for all CLI commands with mocked API
responses. Fix test infrastructure to handle global state (os.Stdout
capture, HOME env var) and broken test parallelism in stdout-capturing
tests.

- Add testutil_test.go with runFreshCommand, setupE2E, mockAPIServer
- Add e2e_full_test.go with ~40 tests covering auth, mail, contacts,
  attachments, folders, labels, drafts, help output
- Add newRootCmdBase() for fresh command trees per test
- Remove t.Parallel() from stdout-capturing and HOME-dependent tests
- Fix SessionWithMockSession to use runFreshCommand (stdout capture)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 01:08:26 -04:00
5 changed files with 2051 additions and 1 deletions

152
cmd/auth_test.go Normal file
View File

@@ -0,0 +1,152 @@
package cmd
import (
"bytes"
"io"
"os"
"testing"
)
// TestLoginCommand tests the login CLI command
func TestLoginCommand(t *testing.T) {
t.Parallel()
// Create a temporary config file
tmpDir := t.TempDir()
configPath := tmpDir + "/config.yaml"
configContent := `
api:
base_url: "http://localhost:8080"
timeout: 30s
`
err := os.WriteFile(configPath, []byte(configContent), 0644)
if err != nil {
t.Fatalf("Failed to write config: %v", err)
}
// Set config path environment variable
os.Setenv("POP_CONFIG_PATH", configPath)
defer os.Unsetenv("POP_CONFIG_PATH")
// Create root command with login subcommand
rootCmd := NewRootCmd()
rootCmd.SetArgs([]string{"login"})
// Capture output
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
// Execute command
err = rootCmd.Execute()
if err != nil {
// Login requires interactive input, so error is expected in non-interactive mode
t.Logf("Login command executed with error (expected in non-interactive mode): %v", err)
}
// Verify command ran
output := buf.String()
t.Logf("Command output: %s", output)
}
// TestLogoutCommand tests the logout CLI command
func TestLogoutCommand(t *testing.T) {
t.Parallel()
// Create a temporary config file
tmpDir := t.TempDir()
configPath := tmpDir + "/config.yaml"
err := os.WriteFile(configPath, []byte("{}"), 0644)
if err != nil {
t.Fatalf("Failed to write config: %v", err)
}
os.Setenv("POP_CONFIG_PATH", configPath)
defer os.Unsetenv("POP_CONFIG_PATH")
// Create root command with logout subcommand
rootCmd := NewRootCmd()
rootCmd.SetArgs([]string{"logout"})
// Capture output
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
// Execute command
err = rootCmd.Execute()
// Logout may fail if no session exists, which is expected
if err != nil {
t.Logf("Logout command executed with error (expected if no session): %v", err)
}
}
// TestSessionCommand tests the session CLI command
func TestSessionCommand(t *testing.T) {
t.Parallel()
// Create a temporary config file
tmpDir := t.TempDir()
configPath := tmpDir + "/config.yaml"
err := os.WriteFile(configPath, []byte("{}"), 0644)
if err != nil {
t.Fatalf("Failed to write config: %v", err)
}
os.Setenv("POP_CONFIG_PATH", configPath)
defer os.Unsetenv("POP_CONFIG_PATH")
// Create root command with session subcommand
rootCmd := NewRootCmd()
rootCmd.SetArgs([]string{"session"})
// Capture output
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
// Execute command
err = rootCmd.Execute()
// Session may fail if no active session, which is expected
if err != nil {
t.Logf("Session command executed with error (expected if no session): %v", err)
}
}
// TestRootCommandHelp tests the help output
func TestRootCommandHelp(t *testing.T) {
t.Parallel()
rootCmd := NewRootCmd()
rootCmd.SetArgs([]string{"--help"})
var buf bytes.Buffer
rootCmd.SetOut(&buf)
err := rootCmd.Execute()
if err != nil {
t.Fatalf("Help command failed: %v", err)
}
output, _ := io.ReadAll(&buf)
if len(output) == 0 {
t.Error("Help output is empty")
}
// Verify help contains expected commands
helpText := string(output)
expectedCommands := []string{"login", "logout", "session", "mail", "contact", "attachment", "folder", "draft"}
for _, cmd := range expectedCommands {
if !contains(helpText, cmd) {
t.Errorf("Help output missing command: %s", cmd)
}
}
}
// Helper function to check if string contains substring
func contains(s, substr string) bool {
return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || contains(s[1:], substr)))
}

1475
cmd/e2e_full_test.go Normal file

File diff suppressed because it is too large Load Diff

190
cmd/e2e_test.go Normal file
View File

@@ -0,0 +1,190 @@
package cmd
import (
"bytes"
"io"
"testing"
)
// TestMailCommand tests the mail CLI command structure
func TestMailCommand(t *testing.T) {
rootCmd := newRootCmdBase()
rootCmd.SetArgs([]string{"mail", "--help"})
var buf bytes.Buffer
rootCmd.SetOut(&buf)
err := rootCmd.Execute()
if err != nil {
t.Fatalf("Mail help command failed: %v", err)
}
output, _ := io.ReadAll(&buf)
helpText := string(output)
// Verify mail subcommands are present
expectedSubcommands := []string{"list", "read", "send", "delete", "trash", "draft", "search"}
for _, subcmd := range expectedSubcommands {
if !contains(helpText, subcmd) {
t.Errorf("Mail help missing subcommand: %s", subcmd)
}
}
}
// TestMailListCommand tests the mail list subcommand
func TestMailListCommand(t *testing.T) {
rootCmd := newRootCmdBase()
rootCmd.SetArgs([]string{"mail", "list", "--help"})
var buf bytes.Buffer
rootCmd.SetOut(&buf)
err := rootCmd.Execute()
if err != nil {
t.Fatalf("Mail list help command failed: %v", err)
}
output, _ := io.ReadAll(&buf)
if len(output) == 0 {
t.Error("Mail list help output is empty")
}
}
// TestContactCommand tests the contact CLI command structure
func TestContactCommand(t *testing.T) {
rootCmd := newRootCmdBase()
rootCmd.SetArgs([]string{"contact", "--help"})
var buf bytes.Buffer
rootCmd.SetOut(&buf)
err := rootCmd.Execute()
if err != nil {
t.Fatalf("Contact help command failed: %v", err)
}
output, _ := io.ReadAll(&buf)
helpText := string(output)
// Verify contact subcommands are present
expectedSubcommands := []string{"list", "add", "edit", "delete"}
for _, subcmd := range expectedSubcommands {
if !contains(helpText, subcmd) {
t.Errorf("Contact help missing subcommand: %s", subcmd)
}
}
}
// TestAttachmentCommand tests the attachment CLI command structure
func TestAttachmentCommand(t *testing.T) {
rootCmd := newRootCmdBase()
rootCmd.SetArgs([]string{"attachment", "--help"})
var buf bytes.Buffer
rootCmd.SetOut(&buf)
err := rootCmd.Execute()
if err != nil {
t.Fatalf("Attachment help command failed: %v", err)
}
output, _ := io.ReadAll(&buf)
helpText := string(output)
// Verify attachment subcommands are present
expectedSubcommands := []string{"upload", "download", "list"}
for _, subcmd := range expectedSubcommands {
if !contains(helpText, subcmd) {
t.Errorf("Attachment help missing subcommand: %s", subcmd)
}
}
}
// TestFolderCommand tests the folder CLI command structure
func TestFolderCommand(t *testing.T) {
rootCmd := newRootCmdBase()
rootCmd.SetArgs([]string{"folder", "--help"})
var buf bytes.Buffer
rootCmd.SetOut(&buf)
err := rootCmd.Execute()
if err != nil {
t.Fatalf("Folder help command failed: %v", err)
}
output, _ := io.ReadAll(&buf)
helpText := string(output)
// Verify folder subcommands are present
expectedSubcommands := []string{"list", "create", "update", "delete"}
for _, subcmd := range expectedSubcommands {
if !contains(helpText, subcmd) {
t.Errorf("Folder help missing subcommand: %s", subcmd)
}
}
}
// TestLabelCommand tests the label CLI command structure
func TestLabelCommand(t *testing.T) {
rootCmd := newRootCmdBase()
rootCmd.SetArgs([]string{"label", "--help"})
var buf bytes.Buffer
rootCmd.SetOut(&buf)
err := rootCmd.Execute()
if err != nil {
t.Fatalf("Label help command failed: %v", err)
}
output, _ := io.ReadAll(&buf)
helpText := string(output)
// Verify label subcommands are present
expectedSubcommands := []string{"list", "create", "update", "delete", "apply", "remove"}
for _, subcmd := range expectedSubcommands {
if !contains(helpText, subcmd) {
t.Errorf("Label help missing subcommand: %s", subcmd)
}
}
}
// TestDraftCommand tests the draft CLI command structure
func TestDraftCommand(t *testing.T) {
rootCmd := newRootCmdBase()
rootCmd.SetArgs([]string{"draft", "--help"})
var buf bytes.Buffer
rootCmd.SetOut(&buf)
err := rootCmd.Execute()
if err != nil {
t.Fatalf("Draft help command failed: %v", err)
}
output, _ := io.ReadAll(&buf)
helpText := string(output)
// Verify draft subcommands are present
expectedSubcommands := []string{"list", "save", "edit", "send"}
for _, subcmd := range expectedSubcommands {
if !contains(helpText, subcmd) {
t.Errorf("Draft help missing subcommand: %s", subcmd)
}
}
}

View File

@@ -16,6 +16,27 @@ It provides commands for managing emails, contacts, and attachments
with full PGP encryption support.`,
}
func newRootCmdBase() *cobra.Command {
cmd := &cobra.Command{
Use: "pop",
Short: "ProtonMail CLI tool",
Long: `pop is a CLI tool for interacting with ProtonMail.
It provides commands for managing emails, contacts, and attachments
with full PGP encryption support.`,
}
cmd.AddCommand(loginCmd())
cmd.AddCommand(logoutCmd())
cmd.AddCommand(sessionCmd())
cmd.AddCommand(mailCmd())
cmd.AddCommand(mailDraftCmd())
cmd.AddCommand(contactCmd())
cmd.AddCommand(attachmentCmd())
cmd.AddCommand(folderCmd())
cmd.AddCommand(labelCmd())
return cmd
}
func NewRootCmd() *cobra.Command {
rootCmd.AddCommand(loginCmd())
rootCmd.AddCommand(logoutCmd())
@@ -26,7 +47,6 @@ func NewRootCmd() *cobra.Command {
rootCmd.AddCommand(attachmentCmd())
rootCmd.AddCommand(folderCmd())
rootCmd.AddCommand(labelCmd())
return rootCmd
}

213
cmd/testutil_test.go Normal file
View File

@@ -0,0 +1,213 @@
package cmd
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"github.com/spf13/cobra"
)
var stdoutMu sync.Mutex
// e2eTestEnv provides a self-contained test environment with a temp config dir
// and a mock API server. All CLI commands that use config.NewConfigManager() or
// auth.NewSessionManager() will resolve to the temp directory.
type e2eTestEnv struct {
t *testing.T
tempDir string
mockServer *mockAPIServer
origHome string
}
// mockAPIServer wraps httptest.Server with dynamic handler registration.
type mockAPIServer struct {
mux *http.ServeMux
server *httptest.Server
handlers sync.Map
}
func newMockAPIServer(t *testing.T) *mockAPIServer {
t.Helper()
mux := http.NewServeMux()
srv := httptest.NewServer(mux)
ms := &mockAPIServer{mux: mux, server: srv}
// Register catch-all patterns for all API endpoints used by the CLI
mux.HandleFunc("POST /auth", ms.resolve)
mux.HandleFunc("POST /auth/verify", ms.resolve)
mux.HandleFunc("POST /api/messages", ms.resolve)
mux.HandleFunc("POST /api/messages/search", ms.resolve)
mux.HandleFunc("POST /api/messages/{id}", ms.resolve)
mux.HandleFunc("POST /api/messages/{id}/movetotrash", ms.resolve)
mux.HandleFunc("POST /api/messages/{id}/delete", ms.resolve)
mux.HandleFunc("POST /api/messages/{id}/send", ms.resolve)
mux.HandleFunc("GET /api/folders", ms.resolve)
mux.HandleFunc("POST /api/folders", ms.resolve)
mux.HandleFunc("POST /api/folders/{id}", ms.resolve)
mux.HandleFunc("GET /api/folders/{id}", ms.resolve)
mux.HandleFunc("GET /api/labels", ms.resolve)
mux.HandleFunc("POST /api/labels", ms.resolve)
mux.HandleFunc("POST /api/labels/{id}", ms.resolve)
mux.HandleFunc("POST /api/messages/{id}/setlabel", ms.resolve)
mux.HandleFunc("POST /api/messages/{id}/clearlabel", ms.resolve)
return ms
}
func (ms *mockAPIServer) URL() string { return ms.server.URL }
func (ms *mockAPIServer) Close() { ms.server.Close() }
func (ms *mockAPIServer) Handle(key string, handler http.HandlerFunc) {
ms.handlers.Store(key, handler)
}
func (ms *mockAPIServer) resolve(w http.ResponseWriter, r *http.Request) {
key := r.Method + " " + r.URL.Path
if h, loaded := ms.handlers.Load(key); loaded {
h.(http.HandlerFunc)(w, r)
return
}
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"Code":404,"Message":"not found"}`)
}
// setupE2E creates a fresh test environment. Returns a cleanup func.
func setupE2E(t *testing.T) *e2eTestEnv {
t.Helper()
tempDir := t.TempDir()
// Create Pop config directory structure
popDir := filepath.Join(tempDir, ".config", "pop")
if err := os.MkdirAll(popDir, 0700); err != nil {
t.Fatalf("create config dir: %v", err)
}
keyringDir := filepath.Join(popDir, "keyring")
if err := os.MkdirAll(keyringDir, 0700); err != nil {
t.Fatalf("create keyring dir: %v", err)
}
// Override HOME so config.NewConfigManager() resolves to our temp dir
origHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
t.Cleanup(func() { os.Setenv("HOME", origHome) })
srv := newMockAPIServer(t)
t.Cleanup(srv.Close)
return &e2eTestEnv{
t: t,
tempDir: tempDir,
mockServer: srv,
origHome: origHome,
}
}
// writeEncryptedSession writes an encrypted session to both the keyring file
// and session.json, simulating a successful login.
func (env *e2eTestEnv) writeEncryptedSession(uid, accessToken, refreshToken string, expiresAt int64) {
sessionData, _ := json.Marshal(map[string]interface{}{
"uid": uid,
"access_token": accessToken,
"refresh_token": refreshToken,
"expires_at": expiresAt,
"two_factor_enabled": false,
"mail_passphrase": "test-passphrase",
})
// Encrypt with AES-256-GCM (same scheme as auth.encryptSession)
key := make([]byte, 32)
for i := range key {
key[i] = byte('k' + i%16)
}
nonce := make([]byte, 12)
for i := range nonce {
nonce[i] = byte('n' + i%8)
}
block, _ := aes.NewCipher(key)
aead, _ := cipher.NewGCM(block)
sealed := aead.Seal(nil, nonce, sessionData, nil)
encrypted := fmt.Sprintf("%s|%s|%s",
base64.StdEncoding.EncodeToString(key),
base64.StdEncoding.EncodeToString(nonce),
base64.StdEncoding.EncodeToString(sealed),
)
// Write to session.json
sessionFile := filepath.Join(env.tempDir, ".config", "pop", "session.json")
os.WriteFile(sessionFile, []byte(encrypted), 0600)
// Write to keyring (file-based keyring stores in keyring/ directory)
keyringFile := filepath.Join(env.tempDir, ".config", "pop", "keyring", "session")
os.WriteFile(keyringFile, []byte(encrypted), 0600)
}
// jsonResp writes a JSON response with the given status code.
func jsonResp(t *testing.T, w http.ResponseWriter, code int, v interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
// runCommand executes a cobra command with the given args, capturing stdout/stderr.
func runCommand(root *cobra.Command, args []string) (string, string, error) {
bufOut, bufErr := &bytes.Buffer{}, &bytes.Buffer{}
root.SetOut(bufOut)
root.SetErr(bufErr)
root.SetArgs(args)
// Disable os.Exit on error by setting SilenceErrors
root.SilenceErrors = true
err := root.Execute()
return bufOut.String(), bufErr.String(), err
}
// runFreshCommand creates a fresh root command tree and executes the given args.
// Captures both cobra output and os.Stdout (since CLI commands use fmt.Printf).
func runFreshCommand(args []string) (string, string, error) {
stdoutMu.Lock()
defer stdoutMu.Unlock()
root := newRootCmdBase()
root.SetArgs(args)
root.SilenceErrors = true
// Capture os.Stdout since CLI commands use fmt.Printf directly
origStdout := os.Stdout
origStderr := os.Stderr
rOut, wOut, _ := os.Pipe()
os.Stdout = wOut
rErr, wErr, _ := os.Pipe()
os.Stderr = wErr
err := root.Execute()
wOut.Close()
wErr.Close()
os.Stdout = origStdout
os.Stderr = origStderr
outBytes, _ := io.ReadAll(rOut)
errBytes, _ := io.ReadAll(rErr)
rOut.Close()
rErr.Close()
return string(outBytes), string(errBytes), err
}