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>
This commit is contained in:
213
cmd/testutil_test.go
Normal file
213
cmd/testutil_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user