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 }