Compare commits
8 Commits
6cc520e221
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b8051efb1 | |||
|
|
5dc4a1b742 | ||
| 691a2acdad | |||
| 19a9e2a3df | |||
| d53b8ec8bc | |||
| a78c564e23 | |||
| ced8204ef8 | |||
|
|
90bee9119e |
39
.github/workflows/ci.yml
vendored
39
.github/workflows/ci.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: [1.21.x, 1.22.x]
|
||||
go-version: [1.23.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -21,14 +21,45 @@ jobs:
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ matrix.go-version }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ matrix.go-version }}-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Test
|
||||
run: go test -v -race ./...
|
||||
- name: Test with coverage
|
||||
run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
|
||||
|
||||
- name: Calculate coverage
|
||||
run: |
|
||||
TOTAL=$(go test -cover ./... 2>&1 | awk '/^ok /{for(i=1;i<=NF;i++) if($i~/%$/) print $i}' | head -1 | tr -d '%')
|
||||
echo "Coverage: ${TOTAL}"
|
||||
if [ -z "$TOTAL" ]; then
|
||||
echo "No coverage data found"
|
||||
exit 1
|
||||
fi
|
||||
if (( $(echo "$TOTAL < 80" | bc -l) )); then
|
||||
echo "Coverage ${TOTAL}% is below 80% threshold"
|
||||
exit 1
|
||||
fi
|
||||
echo "Coverage ${TOTAL}% meets 80% threshold"
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./coverage.out
|
||||
flags: unittests
|
||||
name: codecov-pop
|
||||
|
||||
- name: Lint
|
||||
run: |
|
||||
@@ -43,7 +74,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.21.x
|
||||
go-version: 1.23.x
|
||||
|
||||
- name: Run GoSec
|
||||
uses: securego/gosec@v2
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
@@ -26,7 +27,7 @@ func loginCmd() *cobra.Command {
|
||||
return fmt.Errorf("failed to create session manager: %w", err)
|
||||
}
|
||||
|
||||
return manager.LoginInteractive(cfg.APIBaseURL)
|
||||
return manager.LoginInteractive(context.Background(), cfg.APIBaseURL)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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)))
|
||||
}
|
||||
16
cmd/draft.go
16
cmd/draft.go
@@ -64,12 +64,12 @@ func draftSaveCmd() *cobra.Command {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
session, err := checkAuthenticated()
|
||||
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not authenticated: %w", err)
|
||||
}
|
||||
|
||||
client := api.NewProtonMailClient(cfg)
|
||||
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||
client.SetAuthHeader(session.AccessToken)
|
||||
mailClient := internalmail.NewClient(client)
|
||||
|
||||
@@ -125,12 +125,12 @@ func draftListCmd() *cobra.Command {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
session, err := checkAuthenticated()
|
||||
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not authenticated: %w", err)
|
||||
}
|
||||
|
||||
client := api.NewProtonMailClient(cfg)
|
||||
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||
client.SetAuthHeader(session.AccessToken)
|
||||
mailClient := internalmail.NewClient(client)
|
||||
|
||||
@@ -190,12 +190,12 @@ func draftEditCmd() *cobra.Command {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
session, err := checkAuthenticated()
|
||||
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not authenticated: %w", err)
|
||||
}
|
||||
|
||||
client := api.NewProtonMailClient(cfg)
|
||||
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||
client.SetAuthHeader(session.AccessToken)
|
||||
mailClient := internalmail.NewClient(client)
|
||||
|
||||
@@ -241,12 +241,12 @@ func draftSendCmd() *cobra.Command {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
session, err := checkAuthenticated()
|
||||
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not authenticated: %w", err)
|
||||
}
|
||||
|
||||
client := api.NewProtonMailClient(cfg)
|
||||
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||
client.SetAuthHeader(session.AccessToken)
|
||||
mailClient := internalmail.NewClient(client)
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,7 +370,7 @@ func newLabelClient() (*labels.Client, error) {
|
||||
return nil, fmt.Errorf("not authenticated: %w", err)
|
||||
}
|
||||
|
||||
apiClient := api.NewProtonMailClient(cfg)
|
||||
apiClient := api.NewProtonMailClient(cfg, sessionMgr)
|
||||
apiClient.SetAuthHeader(session.AccessToken)
|
||||
|
||||
return labels.NewClient(apiClient), nil
|
||||
|
||||
40
cmd/mail.go
40
cmd/mail.go
@@ -31,6 +31,22 @@ func checkAuthenticated() (*auth.Session, error) {
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func checkAuthenticatedWithManager() (*auth.Session, *auth.SessionManager, error) {
|
||||
sessionMgr, err := auth.NewSessionManager()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create session manager: %w", err)
|
||||
}
|
||||
authenticated, err := sessionMgr.IsAuthenticated()
|
||||
if err != nil || !authenticated {
|
||||
return nil, nil, fmt.Errorf("not authenticated (run 'pop login' first): %w", err)
|
||||
}
|
||||
session, err := sessionMgr.GetSession()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("not authenticated: %w", err)
|
||||
}
|
||||
return session, sessionMgr, nil
|
||||
}
|
||||
|
||||
func mailCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "mail",
|
||||
@@ -64,12 +80,12 @@ func mailListCmd() *cobra.Command {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
session, err := checkAuthenticated()
|
||||
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := api.NewProtonMailClient(cfg)
|
||||
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||
client.SetAuthHeader(session.AccessToken)
|
||||
mailClient := internalmail.NewClient(client)
|
||||
|
||||
@@ -158,12 +174,12 @@ func mailReadCmd() *cobra.Command {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
session, err := checkAuthenticated()
|
||||
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := api.NewProtonMailClient(cfg)
|
||||
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||
client.SetAuthHeader(session.AccessToken)
|
||||
mailClient := internalmail.NewClient(client)
|
||||
|
||||
@@ -221,12 +237,12 @@ func mailSendCmd() *cobra.Command {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
session, err := checkAuthenticated()
|
||||
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := api.NewProtonMailClient(cfg)
|
||||
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||
client.SetAuthHeader(session.AccessToken)
|
||||
mailClient := internalmail.NewClient(client)
|
||||
|
||||
@@ -276,12 +292,12 @@ func mailDeleteCmd() *cobra.Command {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
session, err := checkAuthenticated()
|
||||
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := api.NewProtonMailClient(cfg)
|
||||
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||
client.SetAuthHeader(session.AccessToken)
|
||||
mailClient := internalmail.NewClient(client)
|
||||
|
||||
@@ -312,12 +328,12 @@ func mailTrashCmd() *cobra.Command {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
session, err := checkAuthenticated()
|
||||
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := api.NewProtonMailClient(cfg)
|
||||
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||
client.SetAuthHeader(session.AccessToken)
|
||||
mailClient := internalmail.NewClient(client)
|
||||
|
||||
@@ -456,12 +472,12 @@ func mailSearchCmd() *cobra.Command {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
session, err := checkAuthenticated()
|
||||
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := api.NewProtonMailClient(cfg)
|
||||
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||
client.SetAuthHeader(session.AccessToken)
|
||||
mailClient := internalmail.NewClient(client)
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/frenocorp/pop/internal/config"
|
||||
"github.com/frenocorp/pop/internal/auth"
|
||||
)
|
||||
|
||||
type ProtonMailClient struct {
|
||||
@@ -19,6 +21,7 @@ type ProtonMailClient struct {
|
||||
rateLimiter *RateLimiter
|
||||
authHeader string
|
||||
authMu sync.RWMutex
|
||||
sessionRefresher auth.SessionRefresher
|
||||
}
|
||||
|
||||
type RateLimiter struct {
|
||||
@@ -28,11 +31,12 @@ type RateLimiter struct {
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func NewProtonMailClient(cfg *config.Config) *ProtonMailClient {
|
||||
func NewProtonMailClient(cfg *config.Config, refresher auth.SessionRefresher) *ProtonMailClient {
|
||||
return &ProtonMailClient{
|
||||
baseURL: cfg.APIBaseURL,
|
||||
httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second},
|
||||
config: cfg,
|
||||
sessionRefresher: refresher,
|
||||
rateLimiter: &RateLimiter{
|
||||
requests: make([]time.Time, 0, cfg.RateLimitReq),
|
||||
limit: cfg.RateLimitReq,
|
||||
@@ -53,6 +57,13 @@ func (c *ProtonMailClient) getAuthHeader() string {
|
||||
return c.authHeader
|
||||
}
|
||||
|
||||
func (c *ProtonMailClient) refreshAuth() error {
|
||||
if c.sessionRefresher == nil {
|
||||
return fmt.Errorf("no session refresher configured")
|
||||
}
|
||||
return c.sessionRefresher.RefreshToken()
|
||||
}
|
||||
|
||||
func (c *ProtonMailClient) GetBaseURL() string {
|
||||
return c.baseURL
|
||||
}
|
||||
@@ -83,11 +94,20 @@ func (rl *RateLimiter) Wait() {
|
||||
}
|
||||
|
||||
func (c *ProtonMailClient) Do(req *http.Request) (*http.Response, error) {
|
||||
return c.DoWithContext(context.Background(), req)
|
||||
}
|
||||
|
||||
func (c *ProtonMailClient) DoWithContext(ctx context.Context, req *http.Request) (*http.Response, error) {
|
||||
c.rateLimiter.Wait()
|
||||
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.getAuthHeader()))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
// Check if request has its own context
|
||||
if ctx != context.Background() {
|
||||
req = req.WithContext(ctx)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -98,7 +118,33 @@ func (c *ProtonMailClient) Do(req *http.Request) (*http.Response, error) {
|
||||
c.rateLimiter.requests = append(c.rateLimiter.requests, time.Now())
|
||||
c.rateLimiter.mu.Unlock()
|
||||
|
||||
// Check for API errors
|
||||
// Check for 401 and attempt refresh
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
// Close the current response body
|
||||
resp.Body.Close()
|
||||
|
||||
// Attempt to refresh the token
|
||||
if err := c.refreshAuth(); err != nil {
|
||||
return resp, fmt.Errorf("401 received and refresh failed: %w", err)
|
||||
}
|
||||
|
||||
// Retry the request with new token
|
||||
// Clone the request to reset any body position
|
||||
retryReq := req.Clone(ctx)
|
||||
retryReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.getAuthHeader()))
|
||||
|
||||
resp, err = c.httpClient.Do(retryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Record the retry request
|
||||
c.rateLimiter.mu.Lock()
|
||||
c.rateLimiter.requests = append(c.rateLimiter.requests, time.Now())
|
||||
c.rateLimiter.mu.Unlock()
|
||||
}
|
||||
|
||||
// Check for other API errors
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var apiErr APIError
|
||||
@@ -120,3 +166,14 @@ type APIError struct {
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("API error %d: %s", e.HTTPStatus, e.Message)
|
||||
}
|
||||
|
||||
// Helper function to create a request with context
|
||||
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
11
internal/auth/interface.go
Normal file
11
internal/auth/interface.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package auth
|
||||
|
||||
import "context"
|
||||
|
||||
// SessionRefresher defines the interface for refreshing authentication tokens.
|
||||
// This allows the API client to automatically refresh tokens on 401 responses.
|
||||
type SessionRefresher interface {
|
||||
RefreshToken() error
|
||||
RefreshTokenWithContext(ctx context.Context) error
|
||||
GetSession() (*Session, error)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
@@ -54,7 +55,7 @@ func NewSessionManager() (*SessionManager, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) LoginWithCredentials(apiBaseURL, email, password, mailPassphrase string) error {
|
||||
func (m *SessionManager) LoginWithCredentials(ctx context.Context, apiBaseURL, email, password, mailPassphrase string) error {
|
||||
if err := os.MkdirAll(m.configDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create config dir: %w", err)
|
||||
}
|
||||
@@ -75,7 +76,7 @@ func (m *SessionManager) LoginWithCredentials(apiBaseURL, email, password, mailP
|
||||
return fmt.Errorf("failed to marshal auth payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", authURL, bytes.NewBuffer(jsonData))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", authURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create auth request: %w", err)
|
||||
}
|
||||
@@ -135,7 +136,7 @@ func (m *SessionManager) LoginWithCredentials(apiBaseURL, email, password, mailP
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) LoginInteractive(apiBaseURL string) error {
|
||||
func (m *SessionManager) LoginInteractive(ctx context.Context, apiBaseURL string) error {
|
||||
if err := os.MkdirAll(m.configDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create config dir: %w", err)
|
||||
}
|
||||
@@ -192,7 +193,7 @@ func (m *SessionManager) LoginInteractive(apiBaseURL string) error {
|
||||
return fmt.Errorf("failed to marshal auth payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", authURL, bytes.NewBuffer(jsonData))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", authURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create auth request: %w", err)
|
||||
}
|
||||
@@ -263,7 +264,7 @@ func (m *SessionManager) LoginInteractive(apiBaseURL string) error {
|
||||
return fmt.Errorf("failed to marshal TOTP payload: %w", err)
|
||||
}
|
||||
|
||||
totpReq, err := http.NewRequest("POST", totpURL, bytes.NewBuffer(totpJSON))
|
||||
totpReq, err := http.NewRequestWithContext(ctx, "POST", totpURL, bytes.NewBuffer(totpJSON))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create TOTP request: %w", err)
|
||||
}
|
||||
@@ -372,6 +373,10 @@ func (m *SessionManager) IsAuthenticated() (bool, error) {
|
||||
}
|
||||
|
||||
func (m *SessionManager) RefreshToken() error {
|
||||
return m.RefreshTokenWithContext(context.Background())
|
||||
}
|
||||
|
||||
func (m *SessionManager) RefreshTokenWithContext(ctx context.Context) error {
|
||||
session, err := m.GetSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get session: %w", err)
|
||||
@@ -394,7 +399,7 @@ func (m *SessionManager) RefreshToken() error {
|
||||
return fmt.Errorf("failed to marshal refresh payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", refreshURL, bytes.NewBuffer(jsonData))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", refreshURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create refresh request: %w", err)
|
||||
}
|
||||
|
||||
1389
internal/mail/client_test.go
Normal file
1389
internal/mail/client_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,15 +25,25 @@ func NewPGPService(privateKeyArmored string) (*PGPService, error) {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
publicKey, err := privateKey.GetPublicKey()
|
||||
pubKeyBytes, err := privateKey.GetPublicKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract public key: %w", err)
|
||||
}
|
||||
|
||||
pubKey, err := crypto.NewKey(pubKeyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse public key: %w", err)
|
||||
}
|
||||
|
||||
pubArmor, err := pubKey.Armor()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to armor public key: %w", err)
|
||||
}
|
||||
|
||||
return &PGPService{
|
||||
keyRing: &PGPKeyRing{
|
||||
PrivateKey: privateKey,
|
||||
PublicKey: publicKey,
|
||||
PublicKey: []byte(pubArmor),
|
||||
PrivateKeyData: []byte(privateKeyArmored),
|
||||
},
|
||||
}, nil
|
||||
@@ -68,7 +78,7 @@ func (s *PGPService) EncryptBody(plaintext string, passphrase string) (string, e
|
||||
return "", fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
|
||||
pubKey, err := crypto.NewKeyFromArmored(string(pubKeyBytes))
|
||||
pubKey, err := crypto.NewKey(pubKeyBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse public key: %w", err)
|
||||
}
|
||||
@@ -131,12 +141,18 @@ func (s *PGPService) getUnlockedKeyRing(passphrase string) (*crypto.KeyRing, err
|
||||
}
|
||||
|
||||
if passphrase != "" {
|
||||
isLocked, err := key.IsLocked()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check key lock status: %w", err)
|
||||
}
|
||||
if isLocked {
|
||||
unlockedKey, err := key.Unlock([]byte(passphrase))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unlock private key: %w", err)
|
||||
}
|
||||
key = unlockedKey
|
||||
}
|
||||
}
|
||||
|
||||
return crypto.NewKeyRing(key)
|
||||
}
|
||||
@@ -176,7 +192,15 @@ func (s *PGPService) GenerateKeyPair(email string, passphrase string) (privateKe
|
||||
return "", "", fmt.Errorf("failed to extract public key: %w", err)
|
||||
}
|
||||
|
||||
pubArmor := string(pubKeyBytes)
|
||||
pubKey, err := crypto.NewKey(pubKeyBytes)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to parse public key: %w", err)
|
||||
}
|
||||
|
||||
pubArmor, err := pubKey.Armor()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to armor public key: %w", err)
|
||||
}
|
||||
|
||||
return privateArmor, pubArmor, nil
|
||||
}
|
||||
@@ -229,7 +253,7 @@ func (s *PGPService) EncryptAttachment(data []byte, recipientPublicKey *crypto.K
|
||||
|
||||
pgpMessage := crypto.NewPlainMessage(data)
|
||||
|
||||
sk, err := crypto.NewSessionKeyFromToken(symKey, "AES256").Encrypt(pgpMessage)
|
||||
sk, err := crypto.NewSessionKeyFromToken(symKey, "aes256").Encrypt(pgpMessage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt attachment: %w", err)
|
||||
}
|
||||
@@ -241,7 +265,7 @@ func (s *PGPService) EncryptAttachment(data []byte, recipientPublicKey *crypto.K
|
||||
}
|
||||
|
||||
encryptedSymKey, err := recipientKeyRing.EncryptSessionKey(
|
||||
crypto.NewSessionKeyFromToken(symKey, "AES256"),
|
||||
crypto.NewSessionKeyFromToken(symKey, "aes256"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt symmetric key: %w", err)
|
||||
|
||||
557
internal/mail/pgp_test.go
Normal file
557
internal/mail/pgp_test.go
Normal file
@@ -0,0 +1,557 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
)
|
||||
|
||||
// testKey generates a fresh PGP key pair for tests.
|
||||
func testKey(t *testing.T) (privateKey, publicKey, passphrase string) {
|
||||
t.Helper()
|
||||
svc := &PGPService{}
|
||||
privateKey, publicKey, err := svc.GenerateKeyPair("test@example.com", "test-passphrase")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair: %v", err)
|
||||
}
|
||||
return privateKey, publicKey, "test-passphrase"
|
||||
}
|
||||
|
||||
// newTestService creates a PGPService from a freshly generated key.
|
||||
func newTestService(t *testing.T) (*PGPService, string, string) {
|
||||
t.Helper()
|
||||
priv, pub, pass := testKey(t)
|
||||
svc, err := NewPGPService(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPGPService: %v", err)
|
||||
}
|
||||
return svc, pub, pass
|
||||
}
|
||||
|
||||
// newLockedTestService creates a PGPService with a properly passphrase-locked key.
|
||||
// crypto.GenerateKey creates unlocked keys, so we explicitly lock the key after generation.
|
||||
func newLockedTestService(t *testing.T) (*PGPService, string, string) {
|
||||
t.Helper()
|
||||
key, err := crypto.GenerateKey("test@example.com", "", "RSA", 4096)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
lockedKey, err := key.Lock([]byte("test-passphrase"))
|
||||
if err != nil {
|
||||
t.Fatalf("Lock: %v", err)
|
||||
}
|
||||
privArmor, err := lockedKey.Armor()
|
||||
if err != nil {
|
||||
t.Fatalf("Armor: %v", err)
|
||||
}
|
||||
pubKeyBytes, err := lockedKey.GetPublicKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GetPublicKey: %v", err)
|
||||
}
|
||||
pubKey, err := crypto.NewKey(pubKeyBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("NewKey: %v", err)
|
||||
}
|
||||
pubArmor, err := pubKey.Armor()
|
||||
if err != nil {
|
||||
t.Fatalf("Armor public key: %v", err)
|
||||
}
|
||||
svc, err := NewPGPService(privArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPGPService: %v", err)
|
||||
}
|
||||
return svc, pubArmor, "test-passphrase"
|
||||
}
|
||||
|
||||
// ---------- NewPGPService ----------
|
||||
|
||||
func TestNewPGPService_ValidKey(t *testing.T) {
|
||||
priv, _, _ := testKey(t)
|
||||
svc, err := NewPGPService(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPGPService: %v", err)
|
||||
}
|
||||
if svc.keyRing == nil {
|
||||
t.Fatal("keyRing is nil")
|
||||
}
|
||||
if svc.keyRing.PrivateKey == nil {
|
||||
t.Fatal("PrivateKey is nil")
|
||||
}
|
||||
if len(svc.keyRing.PublicKey) == 0 {
|
||||
t.Fatal("PublicKey is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPGPService_EmptyKey(t *testing.T) {
|
||||
_, err := NewPGPService("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty key")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to parse private key") {
|
||||
t.Errorf("unexpected error message: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPGPService_InvalidKey(t *testing.T) {
|
||||
_, err := NewPGPService("NOT A PGP KEY")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid key")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- GenerateKeyPair ----------
|
||||
|
||||
func TestGenerateKeyPair_Success(t *testing.T) {
|
||||
svc := &PGPService{}
|
||||
priv, pub, err := svc.GenerateKeyPair("alice@example.com", "pass123")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair: %v", err)
|
||||
}
|
||||
if !strings.Contains(priv, "BEGIN PGP PRIVATE KEY BLOCK") {
|
||||
t.Error("private key missing armored header")
|
||||
}
|
||||
if !strings.Contains(pub, "BEGIN PGP PUBLIC KEY BLOCK") {
|
||||
t.Error("public key missing armored header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateKeyPair_EmptyEmail(t *testing.T) {
|
||||
svc := &PGPService{}
|
||||
priv, pub, err := svc.GenerateKeyPair("", "pass123")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair with empty email: %v", err)
|
||||
}
|
||||
if !strings.Contains(priv, "BEGIN PGP PRIVATE KEY BLOCK") {
|
||||
t.Error("private key missing armored header")
|
||||
}
|
||||
if !strings.Contains(pub, "BEGIN PGP PUBLIC KEY BLOCK") {
|
||||
t.Error("public key missing armored header")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- GetFingerprint ----------
|
||||
|
||||
func TestGetFingerprint_Success(t *testing.T) {
|
||||
svc, _, _ := newTestService(t)
|
||||
fp, err := svc.GetFingerprint()
|
||||
if err != nil {
|
||||
t.Fatalf("GetFingerprint: %v", err)
|
||||
}
|
||||
if len(fp) != 40 {
|
||||
t.Errorf("expected 40-char fingerprint, got %d", len(fp))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFingerprint_NoKeyRing(t *testing.T) {
|
||||
svc := &PGPService{}
|
||||
_, err := svc.GetFingerprint()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil keyRing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no key ring available") {
|
||||
t.Errorf("unexpected error: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ZeroPrivateKeyData ----------
|
||||
|
||||
func TestZeroPrivateKeyData_Success(t *testing.T) {
|
||||
svc, _, _ := newTestService(t)
|
||||
initialLen := len(svc.keyRing.PrivateKeyData)
|
||||
svc.ZeroPrivateKeyData()
|
||||
for i, b := range svc.keyRing.PrivateKeyData {
|
||||
if b != 0 {
|
||||
t.Errorf("byte %d not zeroed: %d", i, b)
|
||||
}
|
||||
}
|
||||
if len(svc.keyRing.PrivateKeyData) != initialLen {
|
||||
t.Error("PrivateKeyData length changed after zeroing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroPrivateKeyData_NilKeyRing(t *testing.T) {
|
||||
svc := &PGPService{}
|
||||
svc.ZeroPrivateKeyData() // should not panic
|
||||
}
|
||||
|
||||
// ---------- Encrypt / Decrypt roundtrip ----------
|
||||
|
||||
func TestEncryptDecrypt_Roundtrip(t *testing.T) {
|
||||
svc, pubArmor, pass := newTestService(t)
|
||||
|
||||
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("parse recipient key: %v", err)
|
||||
}
|
||||
|
||||
plaintext := "Hello, encrypted world!"
|
||||
encrypted, err := svc.Encrypt(plaintext, recipientKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
if !strings.Contains(encrypted, "BEGIN PGP MESSAGE") {
|
||||
t.Error("encrypted output missing PGP message header")
|
||||
}
|
||||
|
||||
decrypted, err := svc.Decrypt(encrypted, pass)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt: %v", err)
|
||||
}
|
||||
if decrypted != plaintext {
|
||||
t.Errorf("roundtrip mismatch: got %q, want %q", decrypted, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_LargePayload(t *testing.T) {
|
||||
svc, pubArmor, pass := newTestService(t)
|
||||
|
||||
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("parse recipient key: %v", err)
|
||||
}
|
||||
|
||||
payload := strings.Repeat("ABCDEFGHijklmnop12345678\n", 100)
|
||||
encrypted, err := svc.Encrypt(payload, recipientKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := svc.Decrypt(encrypted, pass)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt: %v", err)
|
||||
}
|
||||
if decrypted != payload {
|
||||
t.Errorf("large payload roundtrip mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecrypt_InvalidMessage(t *testing.T) {
|
||||
svc, _, _ := newTestService(t)
|
||||
_, err := svc.Decrypt("NOT A PGP MESSAGE", "test-passphrase")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecrypt_WrongPassphrase(t *testing.T) {
|
||||
svc, pubArmor, _ := newLockedTestService(t)
|
||||
|
||||
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("parse recipient key: %v", err)
|
||||
}
|
||||
|
||||
encrypted, err := svc.Encrypt("secret", recipientKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Decrypt(encrypted, "wrong-passphrase")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong passphrase")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- EncryptBody ----------
|
||||
|
||||
func TestEncryptBody_Success(t *testing.T) {
|
||||
svc, _, pass := newTestService(t)
|
||||
|
||||
plaintext := "Body content to encrypt"
|
||||
encrypted, err := svc.EncryptBody(plaintext, pass)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptBody: %v", err)
|
||||
}
|
||||
if !strings.Contains(encrypted, "BEGIN PGP MESSAGE") {
|
||||
t.Error("encrypted body missing PGP message header")
|
||||
}
|
||||
|
||||
decrypted, err := svc.Decrypt(encrypted, pass)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt: %v", err)
|
||||
}
|
||||
if decrypted != plaintext {
|
||||
t.Errorf("EncryptBody roundtrip mismatch: got %q, want %q", decrypted, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptBody_WrongPassphrase(t *testing.T) {
|
||||
svc, _, _ := newLockedTestService(t)
|
||||
|
||||
_, err := svc.EncryptBody("content", "wrong-passphrase")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong passphrase")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- EncryptAndSign ----------
|
||||
|
||||
func TestEncryptAndSign_Success(t *testing.T) {
|
||||
svc, pubArmor, pass := newTestService(t)
|
||||
|
||||
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("parse recipient key: %v", err)
|
||||
}
|
||||
|
||||
plaintext := "Signed and encrypted content"
|
||||
encrypted, err := svc.EncryptAndSign(plaintext, recipientKey, pass)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptAndSign: %v", err)
|
||||
}
|
||||
if !strings.Contains(encrypted, "BEGIN PGP MESSAGE") {
|
||||
t.Error("encrypted+signed output missing PGP message header")
|
||||
}
|
||||
|
||||
decrypted, err := svc.Decrypt(encrypted, pass)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt: %v", err)
|
||||
}
|
||||
if decrypted != plaintext {
|
||||
t.Errorf("EncryptAndSign roundtrip mismatch: got %q, want %q", decrypted, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptAndSign_WrongPassphrase(t *testing.T) {
|
||||
svc, pubArmor, _ := newLockedTestService(t)
|
||||
|
||||
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("parse recipient key: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.EncryptAndSign("content", recipientKey, "wrong-passphrase")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong passphrase")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- SignData ----------
|
||||
|
||||
func TestSignData_Success(t *testing.T) {
|
||||
svc, _, pass := newTestService(t)
|
||||
|
||||
data := []byte("Data to be signed")
|
||||
signed, err := svc.SignData(data, pass)
|
||||
if err != nil {
|
||||
t.Fatalf("SignData: %v", err)
|
||||
}
|
||||
if !strings.Contains(signed, "BEGIN PGP SIGNATURE") {
|
||||
t.Error("signed output missing PGP signature header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignData_WrongPassphrase(t *testing.T) {
|
||||
svc, _, _ := newLockedTestService(t)
|
||||
|
||||
_, err := svc.SignData([]byte("data"), "wrong-passphrase")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong passphrase")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignData_EmptyData(t *testing.T) {
|
||||
svc, _, pass := newTestService(t)
|
||||
|
||||
signed, err := svc.SignData([]byte(""), pass)
|
||||
if err != nil {
|
||||
t.Fatalf("SignData empty: %v", err)
|
||||
}
|
||||
if !strings.Contains(signed, "BEGIN PGP SIGNATURE") {
|
||||
t.Error("empty data signature missing PGP signature header")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- EncryptAttachment / DecryptAttachment ----------
|
||||
|
||||
func TestEncryptDecryptAttachment_Roundtrip(t *testing.T) {
|
||||
svc, pubArmor, pass := newTestService(t)
|
||||
|
||||
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("parse recipient key: %v", err)
|
||||
}
|
||||
|
||||
original := []byte("Attachment binary content")
|
||||
attachment, err := svc.EncryptAttachment(original, recipientKey)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptAttachment: %v", err)
|
||||
}
|
||||
if attachment == nil {
|
||||
t.Fatal("attachment is nil")
|
||||
}
|
||||
if attachment.DataEnc == "" {
|
||||
t.Error("DataEnc is empty")
|
||||
}
|
||||
if len(attachment.Keys) == 0 {
|
||||
t.Error("Keys slice is empty")
|
||||
}
|
||||
|
||||
decrypted, err := svc.DecryptAttachment(attachment, pass)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptAttachment: %v", err)
|
||||
}
|
||||
if string(decrypted) != string(original) {
|
||||
t.Errorf("attachment roundtrip mismatch: got %q, want %q", string(decrypted), string(original))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptAttachment_LargeData(t *testing.T) {
|
||||
svc, pubArmor, pass := newTestService(t)
|
||||
|
||||
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("parse recipient key: %v", err)
|
||||
}
|
||||
|
||||
original := make([]byte, 10240)
|
||||
for i := range original {
|
||||
original[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
attachment, err := svc.EncryptAttachment(original, recipientKey)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptAttachment: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := svc.DecryptAttachment(attachment, pass)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptAttachment: %v", err)
|
||||
}
|
||||
if len(decrypted) != len(original) {
|
||||
t.Errorf("size mismatch: got %d, want %d", len(decrypted), len(original))
|
||||
}
|
||||
for i := range original {
|
||||
if decrypted[i] != original[i] {
|
||||
t.Errorf("byte %d mismatch: got %d, want %d", i, decrypted[i], original[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptAttachment_NoKeys(t *testing.T) {
|
||||
svc, _, pass := newTestService(t)
|
||||
|
||||
attachment := &Attachment{DataEnc: "some-data"}
|
||||
_, err := svc.DecryptAttachment(attachment, pass)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for attachment with no keys")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no keys available") {
|
||||
t.Errorf("unexpected error: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptAttachment_WrongPassphrase(t *testing.T) {
|
||||
svc, pubArmor, _ := newLockedTestService(t)
|
||||
|
||||
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("parse recipient key: %v", err)
|
||||
}
|
||||
|
||||
attachment, err := svc.EncryptAttachment([]byte("content"), recipientKey)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptAttachment: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.DecryptAttachment(attachment, "wrong-passphrase")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong passphrase")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Cross-key Encrypt/Decrypt ----------
|
||||
|
||||
func TestEncryptDecrypt_CrossKey(t *testing.T) {
|
||||
sender, senderPub, senderPass := newTestService(t)
|
||||
_, _, _ = sender, senderPub, senderPass
|
||||
|
||||
recipientPriv, recipientPub, _ := testKey(t)
|
||||
recipientKey, err := crypto.NewKeyFromArmored(recipientPub)
|
||||
if err != nil {
|
||||
t.Fatalf("parse recipient pub key: %v", err)
|
||||
}
|
||||
|
||||
plaintext := "Cross-key encrypted message"
|
||||
encrypted, err := sender.Encrypt(plaintext, recipientKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
recipientSVC, err := NewPGPService(recipientPriv)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPGPService for recipient: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := recipientSVC.Decrypt(encrypted, "test-passphrase")
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt: %v", err)
|
||||
}
|
||||
if decrypted != plaintext {
|
||||
t.Errorf("cross-key roundtrip mismatch: got %q, want %q", decrypted, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- EncryptAndSign with cross-key ----------
|
||||
|
||||
func TestEncryptAndSign_CrossKey(t *testing.T) {
|
||||
sender, _, senderPass := newTestService(t)
|
||||
|
||||
recipientPriv, recipientPub, _ := testKey(t)
|
||||
recipientKey, err := crypto.NewKeyFromArmored(recipientPub)
|
||||
if err != nil {
|
||||
t.Fatalf("parse recipient pub key: %v", err)
|
||||
}
|
||||
|
||||
plaintext := "Cross-key signed+encrypted"
|
||||
encrypted, err := sender.EncryptAndSign(plaintext, recipientKey, senderPass)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptAndSign: %v", err)
|
||||
}
|
||||
|
||||
recipientSVC, err := NewPGPService(recipientPriv)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPGPService for recipient: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := recipientSVC.Decrypt(encrypted, "test-passphrase")
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt: %v", err)
|
||||
}
|
||||
if decrypted != plaintext {
|
||||
t.Errorf("cross-key EncryptAndSign mismatch: got %q, want %q", decrypted, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Attachment cross-key ----------
|
||||
|
||||
func TestEncryptDecryptAttachment_CrossKey(t *testing.T) {
|
||||
sender, _, _ := newTestService(t)
|
||||
|
||||
recipientPriv, recipientPub, _ := testKey(t)
|
||||
recipientKey, err := crypto.NewKeyFromArmored(recipientPub)
|
||||
if err != nil {
|
||||
t.Fatalf("parse recipient pub key: %v", err)
|
||||
}
|
||||
|
||||
original := []byte("Cross-key attachment data")
|
||||
attachment, err := sender.EncryptAttachment(original, recipientKey)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptAttachment: %v", err)
|
||||
}
|
||||
|
||||
recipientSVC, err := NewPGPService(recipientPriv)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPGPService for recipient: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := recipientSVC.DecryptAttachment(attachment, "test-passphrase")
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptAttachment: %v", err)
|
||||
}
|
||||
if string(decrypted) != string(original) {
|
||||
t.Errorf("cross-key attachment mismatch: got %q, want %q", string(decrypted), string(original))
|
||||
}
|
||||
}
|
||||
28
tests/README.md
Normal file
28
tests/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Test Utilities
|
||||
|
||||
This directory contains integration test utilities and helpers for the Pop CLI.
|
||||
|
||||
## Structure
|
||||
|
||||
- `integration_test.go` - Integration test suite
|
||||
- `fixtures/` - Test fixtures and test data
|
||||
- `helpers/` - Test helper functions
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests including integration tests
|
||||
go test -v ./...
|
||||
|
||||
# Run only integration tests
|
||||
go test -v ./tests/...
|
||||
|
||||
# Run with coverage
|
||||
go test -v -coverprofile=coverage.out ./tests/...
|
||||
```
|
||||
|
||||
## Coverage Requirements
|
||||
|
||||
- Minimum 80% coverage required for CI to pass
|
||||
- Integration tests should cover end-to-end workflows
|
||||
- Unit tests should cover individual components
|
||||
22
tests/fixtures/test-config.yaml
vendored
Normal file
22
tests/fixtures/test-config.yaml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# Test configuration for Pop CLI integration tests
|
||||
|
||||
app:
|
||||
name: "Pop Test"
|
||||
version: "1.0.0-test"
|
||||
|
||||
api:
|
||||
base_url: "http://localhost:8080"
|
||||
timeout: 30s
|
||||
retry_count: 3
|
||||
|
||||
database:
|
||||
driver: "sqlite"
|
||||
path: ":memory:"
|
||||
|
||||
mail:
|
||||
provider: "test"
|
||||
from_address: "test@frenocorp.com"
|
||||
|
||||
logging:
|
||||
level: "debug"
|
||||
format: "json"
|
||||
42
tests/integration_test.go
Normal file
42
tests/integration_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain is the entry point for integration tests
|
||||
func TestMain(m *testing.M) {
|
||||
// Setup integration test environment
|
||||
setupIntegrationEnv()
|
||||
|
||||
// Run tests
|
||||
code := m.Run()
|
||||
|
||||
// Teardown
|
||||
teardownIntegrationEnv()
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// setupIntegrationEnv prepares the test environment
|
||||
func setupIntegrationEnv() {
|
||||
// Set test environment variables
|
||||
os.Setenv("POP_TEST_MODE", "true")
|
||||
os.Setenv("POP_CONFIG_PATH", "./fixtures/test-config.yaml")
|
||||
}
|
||||
|
||||
// teardownIntegrationEnv cleans up the test environment
|
||||
func teardownIntegrationEnv() {
|
||||
os.Unsetenv("POP_TEST_MODE")
|
||||
os.Unsetenv("POP_CONFIG_PATH")
|
||||
}
|
||||
|
||||
// TestVersion verifies the CLI version command works
|
||||
func TestVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This is a placeholder test - actual implementation would invoke the CLI
|
||||
// and verify the version output
|
||||
t.Log("Integration test suite initialized")
|
||||
}
|
||||
Reference in New Issue
Block a user