Files
pop/cmd/testutil_test.go
Michael Freno 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

214 lines
6.0 KiB
Go

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
}