24 "github.com/mjl-/bstore"
25 "github.com/mjl-/sherpa"
27 "github.com/mjl-/mox/mlog"
28 "github.com/mjl-/mox/mox-"
29 "github.com/mjl-/mox/store"
30 "github.com/mjl-/mox/webauth"
33var ctxbg = context.Background()
37 webauth.BadAuthDelay = 0
40func tcheck(t *testing.T, err error, msg string) {
43 t.Fatalf("%s: %s", msg, err)
47func readBody(r io.Reader) string {
48 buf, err := io.ReadAll(r)
50 return fmt.Sprintf("read error: %s", err)
52 return fmt.Sprintf("data: %q", buf)
55func tneedErrorCode(t *testing.T, code string, fn func()) {
62 t.Fatalf("expected sherpa user error, saw success")
64 if err, ok := x.(*sherpa.Error); !ok {
66 t.Fatalf("expected sherpa error, saw %#v", x)
67 } else if err.Code != code {
69 t.Fatalf("expected sherpa error code %q, saw other sherpa error %#v", code, err)
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")
88 tcheck(t, err, "closing account")
90 defer store.Switchboard()()
92 api := Account{cookiePath: "/account/"}
93 apiHandler, err := makeSherpaHandler(api.cookiePath, false)
94 tcheck(t, err, "sherpa handler")
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)
101 // Missing login token.
102 tneedErrorCode(t, "user:error", func() { api.Login(ctx, "", "mjl@mox.example", "test1234") })
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()}}
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" {
117 if sessionCookie == nil {
118 t.Fatalf("missing session cookie")
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") })
128 type httpHeaders [][2]string
129 ctJSON := [2]string{"Content-Type", "application/json; charset=utf-8"}
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"}
138 testHTTP := func(method, path string, headers httpHeaders, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
141 req := httptest.NewRequest(method, path, nil)
142 for _, kv := range headers {
143 req.Header.Add(kv[0], kv[1])
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))
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])
163 testHTTPAuthAPI := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
165 testHTTP(method, path, httpHeaders{hdrCSRFOK, hdrSessionOK}, expStatusCode, expHeaders, check)
168 userAuthError := func(resp *http.Response, expCode string) {
171 var response struct {
172 Error *sherpa.Error `json:"error"`
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)
179 if response.Error.Code != expCode {
180 t.Fatalf("got sherpa error code %q, expected %s", response.Error.Code, expCode)
183 badAuth := func(resp *http.Response) {
185 userAuthError(resp, "user:badAuth")
187 noAuth := func(resp *http.Response) {
189 userAuthError(resp, "user:noAuth")
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)
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)
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)
217 api.SetPassword(ctx, "test1234")
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
222 api.AccountSaveFullName(ctx, fullName+" changed") // todo: check if value was changed
223 api.AccountSaveFullName(ctx, fullName)
227 // Import mbox/maildir tgz/zip.
228 testImport := func(filename string, expect int) {
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")
240 tcheck(t, err, "close multipart writer")
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())
252 if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
253 t.Fatalf("parsing import response: %v", err)
256 l := importListener{m.Token, make(chan importEvent, 100), make(chan bool)}
257 importers.Register <- &l
259 t.Fatalf("register failed")
262 importers.Unregister <- &l
271 switch x := e.Event.(type) {
275 t.Fatalf("unexpected problem: %q", x.Message)
280 t.Fatalf("unexpected aborted import")
282 panic(fmt.Sprintf("missing case for Event %#v", e))
286 t.Fatalf("imported %d messages, expected %d", count, expect)
289 testImport(filepath.FromSlash("../testdata/importtest.mbox.zip"), 2)
290 testImport(filepath.FromSlash("../testdata/importtest.maildir.tgz"), 2)
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"`)
297 mb, err := acc.MailboxFind(tx, "importtest")
298 tcheck(t, err, "looking up mailbox importtest")
300 t.Fatalf("missing mailbox importtest")
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)
307 n, err := bstore.QueryTx[store.Message](tx).FilterEqual("Expunged", false).FilterIn("Keywords", "custom").Count()
308 tcheck(t, err, `fetching message with keyword "custom"`)
310 t.Fatalf(`got %d messages with keyword "custom", expected 2`, n)
313 mb, err = acc.MailboxFind(tx, "maildir")
314 tcheck(t, err, "looking up mailbox maildir")
316 t.Fatalf("missing mailbox maildir")
318 if strings.Join(mb.Keywords, " ") != "custom" {
319 t.Fatalf(`expected mailbox keywords "custom", got %v`, mb.Keywords)
325 testExport := func(httppath string, iszip bool, expectFiles int) {
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())
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, "/") {
348 gzr, err := gzip.NewReader(w.Body)
349 tcheck(t, err, "gzip reader")
350 tr := tar.NewReader(gzr)
356 tcheck(t, err, "next file in tar")
357 if !strings.HasSuffix(h.Name, "/") {
360 _, err = io.Copy(io.Discard, tr)
361 tcheck(t, err, "reading from tar")
364 if count != expectFiles {
365 t.Fatalf("export, has %d files, expected %d", count, expectFiles)
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)
375 tneedErrorCode(t, "server:error", func() { api.Logout(ctx) })