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>
214 lines
6.0 KiB
Go
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
|
|
}
|