all repos — postern

@ c3d755b5d212b0d99538e0143bde5801275e4cd0

Modern mail management

clone
git clone https:///postern.git

internal/cli/admin_test.go (view raw)

  1package cli
  2
  3import (
  4	"bytes"
  5	"crypto/rand"
  6	"os"
  7	"path"
  8	"strings"
  9	"testing"
 10	"time"
 11
 12	"postern/internal/db"
 13	"postern/internal/persistence"
 14
 15	"github.com/spf13/cobra"
 16)
 17
 18// executeCommand helps invoke a cobra command with specific arguments and captures its stdout/stderr.
 19func executeCommand(cmd *cobra.Command, args ...string) (string, error) {
 20	buf := new(bytes.Buffer)
 21	cmd.SetOut(buf)
 22	cmd.SetErr(buf)
 23	cmd.SetArgs(args)
 24
 25	err := cmd.Execute()
 26	return buf.String(), err
 27}
 28
 29// executeCommandWithInput is like executeCommand but also injects stdin, used
 30// for commands that require an interactive confirmation.
 31func executeCommandWithInput(cmd *cobra.Command, input string, args ...string) (string, error) {
 32	buf := new(bytes.Buffer)
 33	cmd.SetOut(buf)
 34	cmd.SetErr(buf)
 35	cmd.SetIn(strings.NewReader(input))
 36	cmd.SetArgs(args)
 37
 38	err := cmd.Execute()
 39	return buf.String(), err
 40}
 41
 42// setupTestCommand builds a *Command backed by a real DB and a running HTTP
 43// service on a temp unix socket, for exercising the client/CLI path end-to-end.
 44func setupTestCommand(t *testing.T) (*Command, *HTTPService) {
 45	t.Helper()
 46	f := t.TempDir()
 47
 48	mkeyPath := path.Join(f, "masterkey")
 49	secretKey := make([]byte, 32)
 50	_, _ = rand.Read(secretKey)
 51	if err := os.WriteFile(mkeyPath, secretKey, 0666); err != nil {
 52		t.Fatal(err)
 53	}
 54
 55	p, err := persistence.New(persistence.Config{
 56		BlobRoot:      path.Join(f, "blobs"),
 57		MasterkeyFile: mkeyPath,
 58	})
 59	if err != nil {
 60		t.Fatal(err)
 61	}
 62
 63	posternDB, err := db.OpenDB(&db.Config{
 64		DBPath: path.Join(f, "meta.db"),
 65	})
 66	if err != nil {
 67		t.Fatal(err)
 68	}
 69
 70	c, err := New(&Config{
 71		DB:          posternDB,
 72		Persistence: p,
 73	})
 74	if err != nil {
 75		t.Fatal(err)
 76	}
 77
 78	tempSocket := path.Join(f, "test.sock")
 79	c.socketPath = tempSocket
 80	c.httpClient = newUnixHTTPClient(tempSocket)
 81
 82	service := NewHTTPService(tempSocket, c)
 83	go func() {
 84		_ = service.Start()
 85	}()
 86
 87	timeout := time.After(10 * time.Second)
 88	ticker := time.NewTicker(100 * time.Millisecond)
 89	defer ticker.Stop()
 90
 91	for service.Status() != "running" {
 92		select {
 93		case <-timeout:
 94			t.Fatal("timed out waiting for http service to start")
 95		case <-ticker.C:
 96		}
 97	}
 98
 99	t.Cleanup(func() { _ = service.Stop() })
100	return c, service
101}
102
103// TestAddUserCommand checks the loop of creating a new user, finding it in the db and checking that the password works.
104func TestAddRemoveUserCommand(t *testing.T) {
105	c, service := setupTestCommand(t)
106
107	// Run the cobra command
108	output, err := executeCommand(c.rootCmd(), "user", "add", "user1")
109	if err != nil {
110		t.Fatalf("expected no error, got: %v", err)
111	}
112
113	_, after, found := strings.Cut(output, "Password: ")
114	password := strings.TrimSpace(after)
115	if !found {
116		t.Fatal("expected to find a password in output. Found none")
117	}
118
119	// Test that the user was created by directly accessing the DB
120	u, err := c.db.GetUser(t.Context(), "user1")
121	if err != nil {
122		t.Fatalf("expected no error, got: %v", err)
123	}
124
125	err = u.VerifyPassword([]byte(password))
126	if err != nil {
127		t.Fatalf("password mismatch, got: %v", err)
128	}
129
130	// Try removing user
131	output, err = executeCommand(c.rootCmd(), "user", "remove", "user1")
132	if err != nil {
133		t.Fatalf("expected no error, got: %v", err)
134	}
135	if !strings.Contains(output, "removed") {
136		t.Fatal("expected to find 'removed' in output. Found none")
137	}
138
139	// Try removing user again (failure)
140	_, err = executeCommand(c.rootCmd(), "user", "remove", "user1")
141	if err == nil {
142		t.Errorf("expected error when deleting non-existing user, got none")
143	}
144
145	// Clean up
146	_ = service.Stop()
147}
148
149// TestRotateUserPasswordCommand checks that the password is rotated, the new
150// password verifies, the old one no longer does, and that declining aborts.
151func TestRotateUserPasswordCommand(t *testing.T) {
152	c, _ := setupTestCommand(t)
153
154	// Create a user first.
155	output, err := executeCommand(c.rootCmd(), "user", "add", "user1")
156	if err != nil {
157		t.Fatalf("expected no error, got: %v", err)
158	}
159	_, oldPassword, found := strings.Cut(output, "Password: ")
160	if !found {
161		t.Fatal("expected to find a password in output. Found none")
162	}
163	oldPassword = strings.TrimSpace(oldPassword)
164
165	// Decline the confirmation: nothing should change.
166	out, err := executeCommandWithInput(c.rootCmd(), "no\n", "user", "rotate-password", "user1")
167	if err != nil {
168		t.Fatalf("expected no error on decline, got: %v", err)
169	}
170	if !strings.Contains(out, "Aborted") {
171		t.Fatalf("expected 'Aborted' in output, got: %q", out)
172	}
173	u, err := c.db.GetUser(t.Context(), "user1")
174	if err != nil {
175		t.Fatalf("expected no error, got: %v", err)
176	}
177	if err := u.VerifyPassword([]byte(oldPassword)); err != nil {
178		t.Fatalf("old password should still verify after decline, got: %v", err)
179	}
180
181	// Confirm the rotation.
182	out, err = executeCommandWithInput(c.rootCmd(), "yes\n", "user", "rotate-password", "user1")
183	if err != nil {
184		t.Fatalf("expected no error, got: %v", err)
185	}
186	_, newPassword, found := strings.Cut(out, "Password: ")
187	if !found {
188		t.Fatal("expected to find a new password in output. Found none")
189	}
190	newPassword = strings.TrimSpace(newPassword)
191
192	if newPassword == oldPassword {
193		t.Fatal("expected a different password after rotation")
194	}
195
196	u, err = c.db.GetUser(t.Context(), "user1")
197	if err != nil {
198		t.Fatalf("expected no error, got: %v", err)
199	}
200	if err := u.VerifyPassword([]byte(newPassword)); err != nil {
201		t.Fatalf("new password mismatch, got: %v", err)
202	}
203	if err := u.VerifyPassword([]byte(oldPassword)); err == nil {
204		t.Fatal("old password should no longer verify after rotation")
205	}
206
207	// Rotating a non-existent user should error.
208	_, err = executeCommandWithInput(c.rootCmd(), "yes\n", "user", "rotate-password", "ghost")
209	if err == nil {
210		t.Error("expected error when rotating non-existing user, got none")
211	}
212}