Compare commits
2 Commits
a78c564e23
...
19a9e2a3df
| Author | SHA1 | Date | |
|---|---|---|---|
| 19a9e2a3df | |||
| d53b8ec8bc |
152
cmd/auth_test.go
Normal file
152
cmd/auth_test.go
Normal 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
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
190
cmd/e2e_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
22
cmd/root.go
22
cmd/root.go
@@ -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
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