1package webaccount
2
3import (
4 "archive/tar"
5 "archive/zip"
6 "bytes"
7 "compress/gzip"
8 "context"
9 "encoding/json"
10 "fmt"
11 "io"
12 "mime/multipart"
13 "net/http"
14 "net/http/httptest"
15 "net/url"
16 "os"
17 "path"
18 "path/filepath"
19 "runtime/debug"
20 "sort"
21 "strings"
22 "testing"
23
24 "github.com/mjl-/bstore"
25 "github.com/mjl-/sherpa"
26
27 "github.com/mjl-/mox/mlog"
28 "github.com/mjl-/mox/mox-"
29 "github.com/mjl-/mox/store"
30 "github.com/mjl-/mox/webauth"
31)
32
33var ctxbg = context.Background()
34
35func init() {
36 mox.LimitersInit()
37 webauth.BadAuthDelay = 0
38}
39
40func tcheck(t *testing.T, err error, msg string) {
41 t.Helper()
42 if err != nil {
43 t.Fatalf("%s: %s", msg, err)
44 }
45}
46
47func readBody(r io.Reader) string {
48 buf, err := io.ReadAll(r)
49 if err != nil {
50 return fmt.Sprintf("read error: %s", err)
51 }
52 return fmt.Sprintf("data: %q", buf)
53}
54
55func tneedErrorCode(t *testing.T, code string, fn func()) {
56 t.Helper()
57 defer func() {
58 t.Helper()
59 x := recover()
60 if x == nil {
61 debug.PrintStack()
62 t.Fatalf("expected sherpa user error, saw success")
63 }
64 if err, ok := x.(*sherpa.Error); !ok {
65 debug.PrintStack()
66 t.Fatalf("expected sherpa error, saw %#v", x)
67 } else if err.Code != code {
68 debug.PrintStack()
69 t.Fatalf("expected sherpa error code %q, saw other sherpa error %#v", code, err)
70 }
71 }()
72
73 fn()
74}
75
76func TestAccount(t *testing.T) {
77 os.RemoveAll("../testdata/httpaccount/data")
78 mox.ConfigStaticPath = filepath.FromSlash("../testdata/httpaccount/mox.conf")
79 mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
80 mox.MustLoadConfig(true, false)
81 log := mlog.New("webaccount", nil)
82 acc, err := store.OpenAccount(log, "mjl")
83 tcheck(t, err, "open account")
84 err = acc.SetPassword(log, "test1234")
85 tcheck(t, err, "set password")
86 defer func() {
87 err = acc.Close()
88 tcheck(t, err, "closing account")
89 }()
90 defer store.Switchboard()()
91
92 api := Account{cookiePath: "/account/"}
93 apiHandler, err := makeSherpaHandler(api.cookiePath, false)
94 tcheck(t, err, "sherpa handler")
95
96 // Record HTTP response to get session cookie for login.
97 respRec := httptest.NewRecorder()
98 reqInfo := requestInfo{"", "", "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
99 ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
100
101 // Missing login token.
102 tneedErrorCode(t, "user:error", func() { api.Login(ctx, "", "mjl@mox.example", "test1234") })
103
104 // Login with loginToken.
105 loginCookie := &http.Cookie{Name: "webaccountlogin"}
106 loginCookie.Value = api.LoginPrep(ctx)
107 reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
108
109 csrfToken := api.Login(ctx, loginCookie.Value, "mjl@mox.example", "test1234")
110 var sessionCookie *http.Cookie
111 for _, c := range respRec.Result().Cookies() {
112 if c.Name == "webaccountsession" {
113 sessionCookie = c
114 break
115 }
116 }
117 if sessionCookie == nil {
118 t.Fatalf("missing session cookie")
119 }
120
121 // Valid loginToken, but bad credentials.
122 loginCookie.Value = api.LoginPrep(ctx)
123 reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
124 tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "mjl@mox.example", "badauth") })
125 tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@mox.example", "badauth") })
126 tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@baddomain.example", "badauth") })
127
128 type httpHeaders [][2]string
129 ctJSON := [2]string{"Content-Type", "application/json; charset=utf-8"}
130
131 cookieOK := &http.Cookie{Name: "webaccountsession", Value: sessionCookie.Value}
132 cookieBad := &http.Cookie{Name: "webaccountsession", Value: "AAAAAAAAAAAAAAAAAAAAAA mjl"}
133 hdrSessionOK := [2]string{"Cookie", cookieOK.String()}
134 hdrSessionBad := [2]string{"Cookie", cookieBad.String()}
135 hdrCSRFOK := [2]string{"x-mox-csrf", string(csrfToken)}
136 hdrCSRFBad := [2]string{"x-mox-csrf", "AAAAAAAAAAAAAAAAAAAAAA"}
137
138 testHTTP := func(method, path string, headers httpHeaders, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
139 t.Helper()
140
141 req := httptest.NewRequest(method, path, nil)
142 for _, kv := range headers {
143 req.Header.Add(kv[0], kv[1])
144 }
145 rr := httptest.NewRecorder()
146 rr.Body = &bytes.Buffer{}
147 handle(apiHandler, false, rr, req)
148 if rr.Code != expStatusCode {
149 t.Fatalf("got status %d, expected %d (%s)", rr.Code, expStatusCode, readBody(rr.Body))
150 }
151
152 resp := rr.Result()
153 for _, h := range expHeaders {
154 if resp.Header.Get(h[0]) != h[1] {
155 t.Fatalf("for header %q got value %q, expected %q", h[0], resp.Header.Get(h[0]), h[1])
156 }
157 }
158
159 if check != nil {
160 check(resp)
161 }
162 }
163 testHTTPAuthAPI := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
164 t.Helper()
165 testHTTP(method, path, httpHeaders{hdrCSRFOK, hdrSessionOK}, expStatusCode, expHeaders, check)
166 }
167
168 userAuthError := func(resp *http.Response, expCode string) {
169 t.Helper()
170
171 var response struct {
172 Error *sherpa.Error `json:"error"`
173 }
174 err := json.NewDecoder(resp.Body).Decode(&response)
175 tcheck(t, err, "parsing response as json")
176 if response.Error == nil {
177 t.Fatalf("expected sherpa error with code %s, no error", expCode)
178 }
179 if response.Error.Code != expCode {
180 t.Fatalf("got sherpa error code %q, expected %s", response.Error.Code, expCode)
181 }
182 }
183 badAuth := func(resp *http.Response) {
184 t.Helper()
185 userAuthError(resp, "user:badAuth")
186 }
187 noAuth := func(resp *http.Response) {
188 t.Helper()
189 userAuthError(resp, "user:noAuth")
190 }
191
192 testHTTP("POST", "/api/Bogus", httpHeaders{}, http.StatusOK, nil, noAuth)
193 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad}, http.StatusOK, nil, noAuth)
194 testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionBad}, http.StatusOK, nil, noAuth)
195 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionBad}, http.StatusOK, nil, badAuth)
196 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK}, http.StatusOK, nil, noAuth)
197 testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionOK}, http.StatusOK, nil, noAuth)
198 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionOK}, http.StatusOK, nil, badAuth)
199 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK, hdrSessionBad}, http.StatusOK, nil, badAuth)
200 testHTTPAuthAPI("GET", "/api/Types", http.StatusMethodNotAllowed, nil, nil)
201 testHTTPAuthAPI("POST", "/api/Types", http.StatusOK, httpHeaders{ctJSON}, nil)
202
203 testHTTP("POST", "/import", httpHeaders{}, http.StatusForbidden, nil, nil)
204 testHTTP("POST", "/import", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
205 testHTTP("GET", "/export/mail-export-maildir.tgz", httpHeaders{}, http.StatusForbidden, nil, nil)
206 testHTTP("GET", "/export/mail-export-maildir.tgz", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
207 testHTTP("GET", "/export/mail-export-maildir.tgz", httpHeaders{hdrSessionOK}, http.StatusForbidden, nil, nil)
208 testHTTP("GET", "/export/mail-export-maildir.zip", httpHeaders{}, http.StatusForbidden, nil, nil)
209 testHTTP("GET", "/export/mail-export-mbox.tgz", httpHeaders{}, http.StatusForbidden, nil, nil)
210 testHTTP("GET", "/export/mail-export-mbox.zip", httpHeaders{}, http.StatusForbidden, nil, nil)
211
212 // SetPassword needs the token.
213 sessionToken := store.SessionToken(strings.SplitN(sessionCookie.Value, " ", 2)[0])
214 reqInfo = requestInfo{"mjl@mox.example", "mjl", sessionToken, respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
215 ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
216
217 api.SetPassword(ctx, "test1234")
218
219 fullName, _, dests := api.Account(ctx)
220 api.DestinationSave(ctx, "mjl@mox.example", dests["mjl@mox.example"], dests["mjl@mox.example"]) // todo: save modified value and compare it afterwards
221
222 api.AccountSaveFullName(ctx, fullName+" changed") // todo: check if value was changed
223 api.AccountSaveFullName(ctx, fullName)
224
225 go ImportManage()
226
227 // Import mbox/maildir tgz/zip.
228 testImport := func(filename string, expect int) {
229 t.Helper()
230
231 var reqBody bytes.Buffer
232 mpw := multipart.NewWriter(&reqBody)
233 part, err := mpw.CreateFormFile("file", path.Base(filename))
234 tcheck(t, err, "creating form file")
235 buf, err := os.ReadFile(filename)
236 tcheck(t, err, "reading file")
237 _, err = part.Write(buf)
238 tcheck(t, err, "write part")
239 err = mpw.Close()
240 tcheck(t, err, "close multipart writer")
241
242 r := httptest.NewRequest("POST", "/import", &reqBody)
243 r.Header.Add("Content-Type", mpw.FormDataContentType())
244 r.Header.Add("x-mox-csrf", string(csrfToken))
245 r.Header.Add("Cookie", cookieOK.String())
246 w := httptest.NewRecorder()
247 handle(apiHandler, false, w, r)
248 if w.Code != http.StatusOK {
249 t.Fatalf("import, got status code %d, expected 200: %s", w.Code, w.Body.Bytes())
250 }
251 var m ImportProgress
252 if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
253 t.Fatalf("parsing import response: %v", err)
254 }
255
256 l := importListener{m.Token, make(chan importEvent, 100), make(chan bool)}
257 importers.Register <- &l
258 if !<-l.Register {
259 t.Fatalf("register failed")
260 }
261 defer func() {
262 importers.Unregister <- &l
263 }()
264 count := 0
265 loop:
266 for {
267 e := <-l.Events
268 if e.Event == nil {
269 continue
270 }
271 switch x := e.Event.(type) {
272 case importCount:
273 count += x.Count
274 case importProblem:
275 t.Fatalf("unexpected problem: %q", x.Message)
276 case importStep:
277 case importDone:
278 break loop
279 case importAborted:
280 t.Fatalf("unexpected aborted import")
281 default:
282 panic(fmt.Sprintf("missing case for Event %#v", e))
283 }
284 }
285 if count != expect {
286 t.Fatalf("imported %d messages, expected %d", count, expect)
287 }
288 }
289 testImport(filepath.FromSlash("../testdata/importtest.mbox.zip"), 2)
290 testImport(filepath.FromSlash("../testdata/importtest.maildir.tgz"), 2)
291
292 // Check there are messages, with the right flags.
293 acc.DB.Read(ctxbg, func(tx *bstore.Tx) error {
294 _, err = bstore.QueryTx[store.Message](tx).FilterEqual("Expunged", false).FilterIn("Keywords", "other").FilterIn("Keywords", "test").Get()
295 tcheck(t, err, `fetching message with keywords "other" and "test"`)
296
297 mb, err := acc.MailboxFind(tx, "importtest")
298 tcheck(t, err, "looking up mailbox importtest")
299 if mb == nil {
300 t.Fatalf("missing mailbox importtest")
301 }
302 sort.Strings(mb.Keywords)
303 if strings.Join(mb.Keywords, " ") != "other test" {
304 t.Fatalf(`expected mailbox keywords "other" and "test", got %v`, mb.Keywords)
305 }
306
307 n, err := bstore.QueryTx[store.Message](tx).FilterEqual("Expunged", false).FilterIn("Keywords", "custom").Count()
308 tcheck(t, err, `fetching message with keyword "custom"`)
309 if n != 2 {
310 t.Fatalf(`got %d messages with keyword "custom", expected 2`, n)
311 }
312
313 mb, err = acc.MailboxFind(tx, "maildir")
314 tcheck(t, err, "looking up mailbox maildir")
315 if mb == nil {
316 t.Fatalf("missing mailbox maildir")
317 }
318 if strings.Join(mb.Keywords, " ") != "custom" {
319 t.Fatalf(`expected mailbox keywords "custom", got %v`, mb.Keywords)
320 }
321
322 return nil
323 })
324
325 testExport := func(httppath string, iszip bool, expectFiles int) {
326 t.Helper()
327
328 fields := url.Values{"csrf": []string{string(csrfToken)}}
329 r := httptest.NewRequest("POST", httppath, strings.NewReader(fields.Encode()))
330 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
331 r.Header.Add("Cookie", cookieOK.String())
332 w := httptest.NewRecorder()
333 handle(apiHandler, false, w, r)
334 if w.Code != http.StatusOK {
335 t.Fatalf("export, got status code %d, expected 200: %s", w.Code, w.Body.Bytes())
336 }
337 var count int
338 if iszip {
339 buf := w.Body.Bytes()
340 zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
341 tcheck(t, err, "reading zip")
342 for _, f := range zr.File {
343 if !strings.HasSuffix(f.Name, "/") {
344 count++
345 }
346 }
347 } else {
348 gzr, err := gzip.NewReader(w.Body)
349 tcheck(t, err, "gzip reader")
350 tr := tar.NewReader(gzr)
351 for {
352 h, err := tr.Next()
353 if err == io.EOF {
354 break
355 }
356 tcheck(t, err, "next file in tar")
357 if !strings.HasSuffix(h.Name, "/") {
358 count++
359 }
360 _, err = io.Copy(io.Discard, tr)
361 tcheck(t, err, "reading from tar")
362 }
363 }
364 if count != expectFiles {
365 t.Fatalf("export, has %d files, expected %d", count, expectFiles)
366 }
367 }
368
369 testExport("/export/mail-export-maildir.tgz", false, 6) // 2 mailboxes, each with 2 messages and a dovecot-keyword file
370 testExport("/export/mail-export-maildir.zip", true, 6)
371 testExport("/export/mail-export-mbox.tgz", false, 2)
372 testExport("/export/mail-export-mbox.zip", true, 2)
373
374 api.Logout(ctx)
375 tneedErrorCode(t, "server:error", func() { api.Logout(ctx) })
376}
377