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}