commit c6eb8eaf7d12eea7457147d2234e279da6a49834 from: Oliver Lowe date: Mon Apr 25 05:55:12 2022 UTC new web package for all HTTP API related stuff commit - f4dc4985003737550d70f56d782cecc2c0e3b192 commit + c6eb8eaf7d12eea7457147d2234e279da6a49834 blob - /dev/null blob + 0c4760d8c945ca00d0ac24c30f73a8d04ad7755f (mode 644) --- /dev/null +++ .gitignore @@ -0,0 +1,2 @@ +# macOS junk +.DS_Store blob - 472454650f5c9083a8eceaee4c9fc1c41b0a1f5a blob + 8c734a57ded5058303addc5eba09334d439a07cc --- alias.go +++ alias.go @@ -26,6 +26,23 @@ type Alias struct { Note string } +// Equal reports whether a and b represent the same Alias. +// This is required since an alias' expiry is a time.Time value which has pitfalls +// when doing comparisons using reflect.DeepEqual or the == operator. +func (a Alias) Equal(b Alias) bool { + switch { + case a.Recipient != b.Recipient: + return false + case a.Destination != b.Destination: + return false + case !a.Expiry.Equal(b.Expiry): + return false + case a.Note != b.Note: + return false + } + return true +} + type AliasStore interface { Create(dest string) (Alias, error) Put(alias Alias) error blob - 6900c57608e3939ff6ad832989e9042986ddf98b (mode 644) blob + /dev/null --- http.go +++ /dev/null @@ -1,199 +0,0 @@ -package mailmux - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "path" - "strings" - "time" -) - -func jerror(w http.ResponseWriter, msg string, status int) { - w.WriteHeader(status) - m := map[string]string{"error": msg} - json.NewEncoder(w).Encode(m) -} - -func NewWebServer(aliases AliasStore, users UserStore) http.Handler { - mux := http.NewServeMux() - aliaseshandler := &aliasesHandler{aliases} - aliashandler := &aliasHandler{aliases} - - authhandler := &authHandler{users} - // 8KB is definitely large enough for any reasonable registration - // request and response. - limitedAuthHandler := http.MaxBytesHandler(authhandler, 8*1024) - - mux.Handle("/register", limitedAuthHandler) - mux.Handle("/aliases", authhandler.basicAuth(aliaseshandler)) - mux.Handle("/aliases/", authhandler.basicAuth(aliashandler)) - return mux -} - -type aliasesHandler struct { - AliasStore -} - -func (h *aliasesHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { - username, _, ok := req.BasicAuth() - if !ok || len(username) == 0 { - jerror(w, "empty username", http.StatusForbidden) - return - } - - switch req.Method { - case http.MethodGet: - aliases, err := h.Aliases(username) - if err != nil { - jerror(w, err.Error(), http.StatusInternalServerError) - return - } - json.NewEncoder(w).Encode(aliases) - - case http.MethodPost: - alias, err := h.Create(username) - if err != nil { - jerror(w, err.Error(), http.StatusInternalServerError) - return - } - json.NewEncoder(w).Encode(alias) - default: - jerror(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented) - } -} - -type aliasHandler struct { - AliasStore -} - -func (h *aliasHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { - username, _, ok := req.BasicAuth() - if !ok || len(username) == 0 { - jerror(w, "empty username", http.StatusForbidden) - return - } - - recipient := path.Base(req.URL.Path) - aliases, err := h.Aliases(username) - if err != nil { - jerror(w, fmt.Sprintf("aliases for %s: %v", recipient, err), http.StatusInternalServerError) - return - } - var alias Alias - var found bool - for _, a := range aliases { - if a.Recipient == recipient { - alias = a - found = true - } - } - if !found { - jerror(w, "no such alias", http.StatusNotFound) - return - } - - switch req.Method { - case http.MethodDelete: - if err := h.Delete(recipient); err != nil { - jerror(w, fmt.Sprintf("delete %s: %v", recipient, err), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusNoContent) - - case http.MethodGet: - json.NewEncoder(w).Encode(alias) - - case http.MethodPost: - if err := req.ParseForm(); err != nil { - jerror(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest) - return - } - for param := range req.PostForm { - switch param { - case "expiry": - alias.Expiry, err = time.Parse(time.RFC3339, req.PostForm.Get(param)) - if err != nil { - jerror(w, fmt.Sprintf("parse expiry: %v", err), http.StatusBadRequest) - return - } - case "note": - alias.Note = req.PostForm.Get(param) - default: - jerror(w, fmt.Sprintf("bad alias parameter %s", param), http.StatusBadRequest) - return - } - } - if err := h.Put(alias); err != nil { - jerror(w, fmt.Sprintf("update alias %s: %v", alias.Recipient, err), http.StatusInternalServerError) - return - } - json.NewEncoder(w).Encode(alias) - - default: - code := http.StatusMethodNotAllowed - jerror(w, http.StatusText(code), code) - } -} - -type authHandler struct { - UserStore -} - -func (h *authHandler) basicAuth(next http.Handler) http.Handler { - fn := func(w http.ResponseWriter, req *http.Request) { - username, password, ok := req.BasicAuth() - if !ok || len(username) == 0 || len(password) == 0 { - jerror(w, "unauthorised", http.StatusUnauthorized) - return - } - err := h.Authenticate(username, Password(password)) - if err != nil { - jerror(w, "unauthorised", http.StatusUnauthorized) - return - } - next.ServeHTTP(w, req) - } - return http.HandlerFunc(fn) -} - -func (h *authHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - code := http.StatusMethodNotAllowed - http.Error(w, http.StatusText(code), code) - return - } - - if err := req.ParseForm(); err != nil { - jerror(w, err.Error(), http.StatusBadRequest) - return - } - username := req.PostForm.Get("username") - if username == "" { - jerror(w, "empty username", http.StatusBadRequest) - return - } - password := req.PostForm.Get("password") - if password == "" { - jerror(w, "empty password", http.StatusBadRequest) - return - } - - _, err := h.Lookup(username) - if err == nil { - jerror(w, "user already exists", http.StatusBadRequest) - return - } else if !errors.Is(err, ErrUnknownUser) { - jerror(w, fmt.Sprintf("lookup %s: %v", username, err), http.StatusInternalServerError) - return - } - - if err := h.Change(username, Password(password)); err != nil { - code := http.StatusInternalServerError - if strings.Contains(err.Error(), "invalid username") { - code = http.StatusBadRequest - } - jerror(w, fmt.Sprintf("change %s: %v", username, err), code) - } -} blob - 14394f035faae202363d87b7b634dda0e16cf99a (mode 644) blob + /dev/null --- http_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package mailmux - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strconv" - "strings" - "testing" - "time" -) - -func TestBadRegister(t *testing.T) { - srv := newTestServer() - httpsrv := httptest.NewServer(NewWebServer(srv.Aliases, srv.Users)) - client := httpsrv.Client() - - registrations := []url.Values{ - // this user already exists; created in newTestServer. - url.Values{ - "username": []string{testUsername}, - "password": []string{testPassword}, - }, - // username isn't an email address - url.Values{ - "username": []string{"bla bla hello world!"}, - "password": []string{testPassword}, - }, - // empty password - url.Values{ - "username": []string{testUsername}, - "password": []string{}, - }, - // way too long - url.Values{ - "username": []string{"The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog."}, - "password": []string{"The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog."}, - }, - } - - for i, form := range registrations { - resp, err := client.PostForm(httpsrv.URL+"/register", form) - if err != nil { - t.Error(err) - } - if resp.StatusCode != http.StatusBadRequest { - t.Errorf("bad registration case %d got HTTP status %s", i, resp.Status) - b, err := io.ReadAll(resp.Body) - if err != nil { - t.Fatal(err) - } - t.Log(string(b)) - } - } -} - -// TestAliasLifecycle tests creating, reading, updating then deleting an alias. -func TestAliasLifecycle(t *testing.T) { - srv := newTestServer() - httpsrv := httptest.NewServer(NewWebServer(srv.Aliases, srv.Users)) - client := httpsrv.Client() - - req, err := http.NewRequest(http.MethodPost, httpsrv.URL+"/aliases", nil) - req.SetBasicAuth(testUsername, testPassword) - resp, err := client.Do(req) - if err != nil { - t.Fatalf("create alias: %v", err) - } - defer resp.Body.Close() - var alias Alias - if err := json.NewDecoder(resp.Body).Decode(&alias); err != nil { - t.Fatalf("decode new alias response: %v", err) - } - - req, err = http.NewRequest(http.MethodGet, httpsrv.URL+"/aliases", nil) - req.SetBasicAuth(testUsername, testPassword) - resp, err = client.Do(req) - if err != nil { - t.Fatalf("list aliases: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - t.Log(string(b)) - t.Fatalf("list aliases: HTTP status %s", resp.Status) - } - var aliases []Alias - if err := json.NewDecoder(resp.Body).Decode(&aliases); err != nil { - t.Fatalf("decode list aliases response: %v", err) - } - - // update alias by adding a note and expiry time - v := make(url.Values) - v.Set("expiry", strconv.Itoa(int(time.Now().Add(time.Hour).Unix()))) - v.Set("note", "a test note to describe something") - req, err = http.NewRequest(http.MethodPost, httpsrv.URL+"/aliases/"+alias.Recipient, strings.NewReader(v.Encode())) - if err != nil { - t.Fatalf("create update request: %v", err) - } - req.SetBasicAuth(testUsername, testPassword) - resp, err = client.Do(req) - if err != nil { - t.Fatalf("update alias: %v", err) - } - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - t.Log(string(b)) - t.Fatalf("update alias: HTTP status %s", resp.Status) - } - defer resp.Body.Close() - if err := json.NewDecoder(resp.Body).Decode(&alias); err != nil { - t.Fatalf("decode updated alias response: %v", err) - } - - req, err = http.NewRequest(http.MethodDelete, httpsrv.URL+"/aliases/"+alias.Recipient, nil) - if err != nil { - t.Fatalf("create delete request: %v", err) - } - req.SetBasicAuth(testUsername, testPassword) - resp, err = client.Do(req) - if err != nil { - t.Fatalf("delete alias: %v", err) - } - if resp.StatusCode != http.StatusNoContent { - b, _ := io.ReadAll(resp.Body) - t.Log(string(b)) - t.Fatalf("delete alias: HTTP status %s", resp.Status) - } -} blob - /dev/null blob + c54fda84c19736dc8787b0305198255966dbe9d3 (mode 644) --- /dev/null +++ web/client.go @@ -0,0 +1,125 @@ +package web + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "time" + + "mailmux.net" +) + +type Client struct { + *http.Client + baseURL string + username string + password string +} + +// decodeError reads the apiError from r and returns it. +// A non-nil error is always returned. +func decodeError(r io.Reader) error { + var apierr apiError + if err := json.NewDecoder(r).Decode(&apierr); err != nil { + return fmt.Errorf("decode error: %w", err) + } + if apierr.Error() == "" { + return fmt.Errorf("empty error message") + } + return apierr +} + +func Dial(url, username, password string) (*Client, error) { + return &Client{http.DefaultClient, url, username, password}, nil +} + +func (c *Client) newRequest(method, reqpath string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, c.baseURL+reqpath, body) + if err != nil { + return nil, err + } + req.SetBasicAuth(c.username, c.password) + return req, nil +} + +func (c *Client) get(path string) (*http.Response, error) { + req, err := c.newRequest(http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + return c.Do(req) +} + +func (c *Client) postForm(reqpath string, data url.Values) (*http.Response, error) { + req, err := c.newRequest(http.MethodPost, reqpath, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + return c.Do(req) +} + +func (c *Client) delete(path string) (*http.Response, error) { + req, err := c.newRequest(http.MethodDelete, path, nil) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + return c.Do(req) +} + +func (c *Client) Aliases() ([]mailmux.Alias, error) { + resp, err := c.get("/aliases") + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, decodeError(resp.Body) + } + + var aliases []mailmux.Alias + if err := json.NewDecoder(resp.Body).Decode(&aliases); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return aliases, nil +} + +func (c *Client) CreateAlias(expiry time.Time, note string) (mailmux.Alias, error) { + v := make(url.Values) + if !expiry.IsZero() { + v.Set("expiry", expiry.Format(time.RFC3339)) + } + if note != "" { + v.Set("note", note) + } + resp, err := c.postForm("/aliases", v) + if err != nil { + return mailmux.Alias{}, fmt.Errorf("post form: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return mailmux.Alias{}, decodeError(resp.Body) + } + + var alias mailmux.Alias + if err := json.NewDecoder(resp.Body).Decode(&alias); err != nil { + return alias, fmt.Errorf("decode response: %w", err) + } + return alias, nil +} + +func (c *Client) Delete(recipient string) error { + resp, err := c.delete(path.Join("/aliases/", recipient)) + if err != nil { + return fmt.Errorf("delete request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + return decodeError(resp.Body) + } + return nil +} \ No newline at end of file blob - /dev/null blob + b03bc01a1a75e5ca7f6df8148ab33f956aac4de5 (mode 644) --- /dev/null +++ web/server.go @@ -0,0 +1,238 @@ +package web + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "path" + "strings" + "time" + + "mailmux.net" +) + +// apiError represents errors returned to HTTP API clients. +type apiError struct { + Err string `json:"error"` +} + +func (e apiError) Error() string { + return e.Err +} + +func jerror(w http.ResponseWriter, msg string, status int) { + w.WriteHeader(status) + err := apiError{msg} + json.NewEncoder(w).Encode(err) +} + +func NewServer(aliases mailmux.AliasStore, users mailmux.UserStore) http.Handler { + mux := http.NewServeMux() + aliaseshandler := &aliasesHandler{aliases} + aliashandler := &aliasHandler{aliases} + + authhandler := &authHandler{users} + // 8KB is definitely large enough for any reasonable registration + // request and response. + limitedAuthHandler := http.MaxBytesHandler(authhandler, 8*1024) + + mux.Handle("/register", limitedAuthHandler) + mux.Handle("/aliases", authhandler.basicAuth(aliaseshandler)) + mux.Handle("/aliases/", authhandler.basicAuth(aliashandler)) + return mux +} + +type aliasesHandler struct { + mailmux.AliasStore +} + +func (h *aliasesHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + username, _, ok := req.BasicAuth() + if !ok || len(username) == 0 { + jerror(w, "empty username", http.StatusForbidden) + return + } + + switch req.Method { + case http.MethodGet: + aliases, err := h.Aliases(username) + if err != nil { + jerror(w, err.Error(), http.StatusInternalServerError) + return + } + json.NewEncoder(w).Encode(aliases) + + case http.MethodPost: + alias, err := h.Create(username) + if err != nil { + jerror(w, err.Error(), http.StatusInternalServerError) + return + } + if err := req.ParseForm(); err != nil { + jerror(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest) + return + } + for param := range req.PostForm { + switch param { + case "expiry": + alias.Expiry, err = time.Parse(time.RFC3339, req.PostForm.Get(param)) + if err != nil { + jerror(w, fmt.Sprintf("parse expiry: %v", err), http.StatusBadRequest) + return + } + case "note": + alias.Note = req.PostForm.Get(param) + default: + jerror(w, fmt.Sprintf("bad alias parameter %s", param), http.StatusBadRequest) + return + } + } + if err := h.Put(alias); err != nil { + jerror(w, fmt.Sprintf("update alias %s: %v", alias.Recipient, err), http.StatusInternalServerError) + return + } + json.NewEncoder(w).Encode(alias) + + default: + jerror(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented) + } +} + +type aliasHandler struct { + mailmux.AliasStore +} + +func (h *aliasHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + username, _, ok := req.BasicAuth() + if !ok || len(username) == 0 { + jerror(w, "empty username", http.StatusForbidden) + return + } + + recipient := path.Base(req.URL.Path) + aliases, err := h.Aliases(username) + if err != nil { + jerror(w, fmt.Sprintf("aliases for %s: %v", recipient, err), http.StatusInternalServerError) + return + } + var alias mailmux.Alias + var found bool + for _, a := range aliases { + if a.Recipient == recipient { + alias = a + found = true + } + } + if !found { + jerror(w, "no such alias", http.StatusNotFound) + return + } + + switch req.Method { + case http.MethodDelete: + if err := h.Delete(recipient); err != nil { + jerror(w, fmt.Sprintf("delete %s: %v", recipient, err), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + return + + case http.MethodGet: + json.NewEncoder(w).Encode(alias) + return + + case http.MethodPost: + if err := req.ParseForm(); err != nil { + jerror(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest) + return + } + for param := range req.PostForm { + switch param { + case "expiry": + alias.Expiry, err = time.Parse(time.RFC3339, req.PostForm.Get(param)) + if err != nil { + jerror(w, fmt.Sprintf("parse expiry: %v", err), http.StatusBadRequest) + return + } + case "note": + alias.Note = req.PostForm.Get(param) + default: + jerror(w, fmt.Sprintf("bad alias parameter %s", param), http.StatusBadRequest) + return + } + } + if err := h.Put(alias); err != nil { + jerror(w, fmt.Sprintf("update alias %s: %v", alias.Recipient, err), http.StatusInternalServerError) + return + } + json.NewEncoder(w).Encode(alias) + return + + default: + code := http.StatusMethodNotAllowed + jerror(w, http.StatusText(code), code) + return + } +} + +type authHandler struct { + mailmux.UserStore +} + +func (h *authHandler) basicAuth(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, req *http.Request) { + username, password, ok := req.BasicAuth() + if !ok || len(username) == 0 || len(password) == 0 { + jerror(w, "unauthorised", http.StatusUnauthorized) + return + } + err := h.Authenticate(username, mailmux.Password(password)) + if err != nil { + jerror(w, "unauthorised", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, req) + } + return http.HandlerFunc(fn) +} + +func (h *authHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + code := http.StatusMethodNotAllowed + http.Error(w, http.StatusText(code), code) + return + } + + if err := req.ParseForm(); err != nil { + jerror(w, err.Error(), http.StatusBadRequest) + return + } + username := req.PostForm.Get("username") + if username == "" { + jerror(w, "empty username", http.StatusBadRequest) + return + } + password := req.PostForm.Get("password") + if password == "" { + jerror(w, "empty password", http.StatusBadRequest) + return + } + + _, err := h.Lookup(username) + if err == nil { + jerror(w, "user already exists", http.StatusBadRequest) + return + } else if !errors.Is(err, mailmux.ErrUnknownUser) { + jerror(w, fmt.Sprintf("lookup %s: %v", username, err), http.StatusInternalServerError) + return + } + + if err := h.Change(username, mailmux.Password(password)); err != nil { + code := http.StatusInternalServerError + if strings.Contains(err.Error(), "invalid username") { + code = http.StatusBadRequest + } + jerror(w, fmt.Sprintf("change %s: %v", username, err), code) + } +} blob - /dev/null blob + 66964a49085c780a925222748bbd44341b0cf53f (mode 644) --- /dev/null +++ web/web_test.go @@ -0,0 +1,127 @@ +package web + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + "time" + + "mailmux.net" +) + +const ( + testUsername = "test@example.com" + testPassword = "secret" +) + +func newTestStores() (*mailmux.AliasDB, *mailmux.UserDB, error) { + tmpdb, err := os.CreateTemp("", "") + if err != nil { + return nil, nil, err + } + aliasdb, err := mailmux.OpenAliasDB(tmpdb.Name(), "/usr/share/dict/words") + if err != nil { + return nil, nil, err + } + userdb, err := mailmux.OpenUserDB(tmpdb.Name(), os.TempDir()) + if err != nil { + return nil, nil, err + } + if err := userdb.Change(testUsername, mailmux.Password(testPassword)); err != nil { + return nil, nil, err + } + return aliasdb, userdb, nil +} + +func TestBadRegister(t *testing.T) { + aliasdb, userdb, err := newTestStores() + if err != nil { + t.Fatal(err) + } + httpsrv := httptest.NewServer(NewServer(aliasdb, userdb)) + client := httpsrv.Client() + + registrations := []url.Values{ + // this user already exists; created in newTestServer. + url.Values{ + "username": []string{testUsername}, + "password": []string{testPassword}, + }, + // username isn't an email address + url.Values{ + "username": []string{"bla bla hello world!"}, + "password": []string{testPassword}, + }, + // empty password + url.Values{ + "username": []string{testUsername}, + "password": []string{}, + }, + // way too long + url.Values{ + "username": []string{"The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog."}, + "password": []string{"The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog."}, + }, + // bad fields + url.Values{ + "username": []string{"abuser@hello.invalid"}, + "jfkdsjkdjskf": []string{"bla bla"}, + "du9u1202$%&&!": []string{"dfjkds"}, + }, + } + + for i, form := range registrations { + resp, err := client.PostForm(httpsrv.URL+"/register", form) + if err != nil { + t.Error(err) + } + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("bad registration case %d got HTTP status %s", i, resp.Status) + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + t.Log(string(b)) + } + } +} + +// TestAliasLifecycle tests creating, reading, updating then deleting an alias. +func TestAliasLifecycle(t *testing.T) { + aliasdb, userdb, err := newTestStores() + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(NewServer(aliasdb, userdb)) + if err != nil { + t.Fatal(err) + } + client, err := Dial(srv.URL, testUsername, testPassword) + if err != nil { + t.Fatal(err) + } + + alias, err := client.CreateAlias(time.Now().Add(2*time.Hour), "some description") + if err != nil { + t.Fatalf("create alias: %v", err) + } + + aliases, err := client.Aliases() + if err != nil { + t.Fatalf("list aliases: %v", err) + } + want := alias + got := aliases[0] + if !want.Equal(got) { + t.Errorf("didn't list expected alias after creation: want %v, got %v", want, got) + } + + // TODO update the created alias by setting expiry and note to default, empty values. + + if err := client.Delete(alias.Recipient); err != nil { + t.Fatalf("delete alias: %v", err) + } +}