From cec7bc0e44feebfe9bfe677810ff1bb7f281c527 Mon Sep 17 00:00:00 2001 From: Henry Date: Thu, 1 Apr 2021 09:04:38 +0200 Subject: [PATCH] add flash message helper --- .gitignore | 1 + go.mod | 2 +- go.sum | 2 + web/errors/badrequest.go | 7 + web/errors/errhandler.go | 62 +++++--- web/errors/flashes.go | 109 ++++++++++++++ web/handlers/admin/aliases.go | 23 +-- web/handlers/admin/aliases_test.go | 43 ++++-- web/handlers/admin/denied_keys.go | 36 ++--- web/handlers/admin/denied_keys_test.go | 75 +++++----- web/handlers/admin/handler.go | 28 +++- web/handlers/admin/handler_test.go | 5 +- web/handlers/admin/invites.go | 7 +- web/handlers/admin/invites_test.go | 27 ++-- web/handlers/admin/members.go | 57 +++---- web/handlers/admin/members_test.go | 105 +++++++------ web/handlers/admin/notices.go | 2 + web/handlers/admin/notices_test.go | 42 +++--- web/handlers/admin/setup_test.go | 77 +++++++--- web/handlers/aliases_test.go | 21 ++- web/handlers/auth/withssb.go | 12 +- web/handlers/auth_test.go | 198 ++++++------------------- web/handlers/basic_test.go | 21 +-- web/handlers/http.go | 24 ++- web/handlers/invites_test.go | 78 +++------- web/handlers/notices_test.go | 71 +++------ web/handlers/setup_test.go | 84 +++-------- web/i18n/defaults/active.en.toml | 5 + web/i18n/helper.go | 36 +++-- web/i18n/i18ntesting/testing.go | 82 ++++++++++ web/templates/admin/denied-keys.tmpl | 2 + web/templates/admin/member-list.tmpl | 2 + web/templates/flashes.tmpl | 11 ++ web/utils.go | 4 +- 34 files changed, 727 insertions(+), 634 deletions(-) create mode 100644 web/errors/flashes.go create mode 100644 web/i18n/i18ntesting/testing.go create mode 100644 web/templates/flashes.tmpl diff --git a/.gitignore b/.gitignore index a28e191..a4a6c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ cmd/insert-user/insert-user muxrpc/test/go/testrun muxrpc/test/nodejs/testrun web/handlers/testrun +web/handlers/admin/testrun roomdb/sqlite/testrun # build artifacts from node.js project web/styles diff --git a/go.mod b/go.mod index 9d423dc..3c88e8e 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( go.cryptoscope.co/muxrpc/v2 v2.0.0-beta.1.0.20210308090127-5f1f5f9cbb59 go.cryptoscope.co/netwrap v0.1.1 go.cryptoscope.co/secretstream v1.2.2 - go.mindeco.de v1.10.0 + go.mindeco.de v1.11.0 go.mindeco.de/ssb-refs v0.1.1-0.20210108133850-cf1f44fea870 golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 diff --git a/go.sum b/go.sum index 5be28ab..6b85041 100644 --- a/go.sum +++ b/go.sum @@ -499,6 +499,8 @@ go.mindeco.de v1.9.0 h1:/xli02DkzpIUZxp/rp1nj8z/OZ9MHvkMIr9TfDVcmBg= go.mindeco.de v1.9.0/go.mod h1:ePOcyktbpqzhMPRBDv2gUaDd3h8QtT+DUU1DK+VbQZE= go.mindeco.de v1.10.0 h1:H/bhL+dIgZZnUgBEDlKUJBisTszNiHDONeGZtGdiJJ0= go.mindeco.de v1.10.0/go.mod h1:ePOcyktbpqzhMPRBDv2gUaDd3h8QtT+DUU1DK+VbQZE= +go.mindeco.de v1.11.0 h1:zrNJFjRr7l+eoRywKxBetB1sgQwnrJ9NA65H/xeusTs= +go.mindeco.de v1.11.0/go.mod h1:ePOcyktbpqzhMPRBDv2gUaDd3h8QtT+DUU1DK+VbQZE= go.mindeco.de/ssb-refs v0.1.1-0.20210108133850-cf1f44fea870 h1:TCI3AefMAaOYECvppn30+CfEB0Fn8IES1SKvvacc3/c= go.mindeco.de/ssb-refs v0.1.1-0.20210108133850-cf1f44fea870/go.mod h1:OnBnV02ux4lLsZ39LID6yYLqSDp+dqTHb/3miYPkQFs= go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= diff --git a/web/errors/badrequest.go b/web/errors/badrequest.go index ff68752..750f795 100644 --- a/web/errors/badrequest.go +++ b/web/errors/badrequest.go @@ -34,6 +34,13 @@ func (f ErrForbidden) Error() string { return fmt.Sprintf("rooms/web: access denied: %s", f.Details) } +// ErrRedirect decide to not render a page during the controller +type ErrRedirect struct{ Path string } + +func (err ErrRedirect) Error() string { + return fmt.Sprintf("rooms/web: redirecting to: %s", err.Path) +} + type PageNotFound struct{ Path string } func (e PageNotFound) Error() string { diff --git a/web/errors/errhandler.go b/web/errors/errhandler.go index cc76d63..16fc891 100644 --- a/web/errors/errhandler.go +++ b/web/errors/errhandler.go @@ -31,7 +31,40 @@ func (eh *ErrorHandler) SetRenderer(r *render.Renderer) { } func (eh *ErrorHandler) Handle(rw http.ResponseWriter, req *http.Request, code int, err error) { - var ih = i18n.LocalizerFromRequest(eh.locHelper, req) + var redirectErr ErrRedirect + if errors.As(err, &redirectErr) { + http.Redirect(rw, req, redirectErr.Path, http.StatusTemporaryRedirect) + return + } + + var ih = eh.locHelper.FromRequest(req) + + code, msg := localizeError(ih, err) + + data := errorTemplateData{ + Err: template.HTML(msg), + // TODO: localize status codes? might be fine with a few + Status: http.StatusText(code), + StatusCode: code, + } + + renderErr := eh.render.Render(rw, req, "error.tmpl", code, data) + if renderErr != nil { + logger := logging.FromContext(req.Context()) + level.Error(logger).Log("event", "error template renderfailed", + "orig-err", err, + "render-err", renderErr, + ) + } +} + +type errorTemplateData struct { + StatusCode int + Status string + Err template.HTML +} + +func localizeError(ih *i18n.Localizer, err error) (int, string) { // default, unlocalized message msg := err.Error() @@ -44,10 +77,13 @@ func (eh *ErrorHandler) Handle(rw http.ResponseWriter, req *http.Request, code i f ErrForbidden ) + code := http.StatusInternalServerError + switch { + case err == ErrNotAuthorized: code = http.StatusForbidden - msg = ih.LocalizeSimple("ErrorAuthBadLogin") + msg = ih.LocalizeSimple("ErrorNotAuthorized") case err == auth.ErrBadLogin: msg = ih.LocalizeSimple("ErrorAuthBadLogin") @@ -83,25 +119,5 @@ func (eh *ErrorHandler) Handle(rw http.ResponseWriter, req *http.Request, code i }) } - data := errorTemplateData{ - Err: template.HTML(msg), - // TODO: localize status codes? might be fine with a few - Status: http.StatusText(code), - StatusCode: code, - } - - renderErr := eh.render.Render(rw, req, "error.tmpl", code, data) - if renderErr != nil { - logger := logging.FromContext(req.Context()) - level.Error(logger).Log("event", "error template renderfailed", - "orig-err", err, - "render-err", renderErr, - ) - } -} - -type errorTemplateData struct { - StatusCode int - Status string - Err template.HTML + return code, msg } diff --git a/web/errors/flashes.go b/web/errors/flashes.go new file mode 100644 index 0000000..803c1df --- /dev/null +++ b/web/errors/flashes.go @@ -0,0 +1,109 @@ +package errors + +import ( + "encoding/gob" + "fmt" + "net/http" + + "github.com/gorilla/sessions" + "github.com/ssb-ngi-pointer/go-ssb-room/web/i18n" +) + +type FlashHelper struct { + store sessions.Store + + locHelper *i18n.Helper +} + +func NewFlashHelper(s sessions.Store, loc *i18n.Helper) *FlashHelper { + gob.Register(FlashMessage{}) + + return &FlashHelper{ + store: s, + locHelper: loc, + } +} + +const flashSession = "go-ssb-room-flash-messages" + +type FlashKind uint + +const ( + _ FlashKind = iota + // FlashError signals that a problem occured + FlashError + // FlashNotification represents a normal message (like "xyz added/updated successfull") + FlashNotification +) + +type FlashMessage struct { + Kind FlashKind + Message string +} + +// TODO: rethink error return - maybe panic() / maybe render package? + +// AddMessage expects a i18n label, translates it and adds it as a FlashNotification +func (fh FlashHelper) AddMessage(rw http.ResponseWriter, req *http.Request, label string) { + session, err := fh.store.Get(req, flashSession) + if err != nil { + panic(fmt.Errorf("flashHelper: failed to get session: %w", err)) + } + + ih := fh.locHelper.FromRequest(req) + + session.AddFlash(FlashMessage{ + Kind: FlashNotification, + Message: ih.LocalizeSimple(label), + }) + + if err := session.Save(req, rw); err != nil { + panic(fmt.Errorf("flashHelper: failed to save session: %w", err)) + } +} + +// AddError adds a FlashError and translates the passed err using localizeError() +func (fh FlashHelper) AddError(rw http.ResponseWriter, req *http.Request, err error) { + session, getErr := fh.store.Get(req, flashSession) + if getErr != nil { + panic(fmt.Errorf("flashHelper: failed to get session: %w", err)) + } + + ih := fh.locHelper.FromRequest(req) + + _, msg := localizeError(ih, err) + + session.AddFlash(FlashMessage{ + Kind: FlashError, + Message: msg, + }) + if err := session.Save(req, rw); err != nil { + panic(fmt.Errorf("flashHelper: failed to save session: %w", err)) + } +} + +// GetAll returns all the FlashMessages, emptys and updates the store +func (fh FlashHelper) GetAll(rw http.ResponseWriter, req *http.Request) ([]FlashMessage, error) { + session, err := fh.store.Get(req, flashSession) + if err != nil { + return nil, err + } + + opaqueFlashes := session.Flashes() + + flashes := make([]FlashMessage, len(opaqueFlashes)) + + for i, of := range opaqueFlashes { + f, ok := of.(FlashMessage) + if !ok { + return nil, fmt.Errorf("GetFlashes: failed to unpack flash: %T", of) + } + + flashes[i].Kind = f.Kind + flashes[i].Message = f.Message + } + + err = session.Save(req, rw) + + return flashes, err +} diff --git a/web/handlers/admin/aliases.go b/web/handlers/admin/aliases.go index 5342c54..9e0eee5 100644 --- a/web/handlers/admin/aliases.go +++ b/web/handlers/admin/aliases.go @@ -3,7 +3,6 @@ package admin import ( - "errors" "fmt" "net/http" "strconv" @@ -20,6 +19,8 @@ import ( type aliasesHandler struct { r *render.Renderer + flashes *weberrors.FlashHelper + db roomdb.AliasesService } @@ -38,11 +39,8 @@ func (h aliasesHandler) revokeConfirm(rw http.ResponseWriter, req *http.Request) entry, err := h.db.GetByID(req.Context(), id) if err != nil { - if errors.Is(err, roomdb.ErrNotFound) { - http.Redirect(rw, req, redirectToAliases, http.StatusFound) - return nil, ErrRedirected - } - return nil, err + h.flashes.AddError(rw, req, err) + return nil, weberrors.ErrRedirect{Path: redirectToAliases} } return map[string]interface{}{ @@ -65,17 +63,12 @@ func (h aliasesHandler) revoke(rw http.ResponseWriter, req *http.Request) { return } - status := http.StatusFound + status := http.StatusTemporaryRedirect err = h.db.Revoke(req.Context(), req.FormValue("name")) if err != nil { - if !errors.Is(err, roomdb.ErrNotFound) { - // TODO: flash error - h.r.Error(rw, req, http.StatusInternalServerError, err) - return - } - status = http.StatusNotFound - h.r.Error(rw, req, http.StatusInternalServerError, err) - return + h.flashes.AddError(rw, req, err) + } else { + h.flashes.AddMessage(rw, req, "AdminAliasRevoked") } http.Redirect(rw, req, redirectToAliases, status) diff --git a/web/handlers/admin/aliases_test.go b/web/handlers/admin/aliases_test.go index e757075..f7dc6e4 100644 --- a/web/handlers/admin/aliases_test.go +++ b/web/handlers/admin/aliases_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/ssb-ngi-pointer/go-ssb-room/roomdb" - "github.com/ssb-ngi-pointer/go-ssb-room/web" "github.com/ssb-ngi-pointer/go-ssb-room/web/router" "github.com/ssb-ngi-pointer/go-ssb-room/web/webassert" refs "go.mindeco.de/ssb-refs" @@ -23,10 +22,9 @@ func TestAliasesRevokeConfirmation(t *testing.T) { testEntry := roomdb.Alias{ID: 666, Name: "the-test-name", Feed: *testKey} ts.AliasesDB.GetByIDReturns(testEntry, nil) - urlTo := web.NewURLTo(ts.Router) - urlRevokeConfirm := urlTo(router.AdminAliasesRevokeConfirm, "id", 3) + urlRevokeConfirm := ts.URLTo(router.AdminAliasesRevokeConfirm, "id", 3) - html, resp := ts.Client.GetHTML(urlRevokeConfirm.String()) + html, resp := ts.Client.GetHTML(urlRevokeConfirm) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") a.Equal(testKey.Ref(), html.Find("pre#verify").Text(), "has the key for verification") @@ -40,10 +38,8 @@ func TestAliasesRevokeConfirmation(t *testing.T) { action, ok := form.Attr("action") a.True(ok, "form has action set") - addURL, err := ts.Router.Get(router.AdminAliasesRevoke).URL() - a.NoError(err) - - a.Equal(addURL.String(), action) + addURL := ts.URLTo(router.AdminAliasesRevoke) + a.Equal(addURL.Path, action) webassert.ElementsInForm(t, form, []webassert.FormElement{ {Name: "name", Type: "hidden", Value: testEntry.Name}, @@ -54,14 +50,23 @@ func TestAliasesRevoke(t *testing.T) { ts := newSession(t) a := assert.New(t) - urlTo := web.NewURLTo(ts.Router) - urlRevoke := urlTo(router.AdminAliasesRevoke) + urlRevoke := ts.URLTo(router.AdminAliasesRevoke) + overviewURL := ts.URLTo(router.AdminAliasesOverview) ts.AliasesDB.RevokeReturns(nil) addVals := url.Values{"name": []string{"the-name"}} - rec := ts.Client.PostForm(urlRevoke.String(), addVals) - a.Equal(http.StatusFound, rec.Code) + rec := ts.Client.PostForm(urlRevoke, addVals) + a.Equal(http.StatusTemporaryRedirect, rec.Code) + a.Equal(overviewURL.Path, rec.Header().Get("Location")) + a.True(len(rec.Result().Cookies()) > 0, "got a cookie") + + // check flash messages + doc, resp := ts.Client.GetHTML(overviewURL) + a.Equal(http.StatusOK, resp.Code) + flashes := doc.Find("#flashes-list").Children() + a.Equal(1, flashes.Length()) + a.Equal("AdminAliasRevoked", flashes.Text()) a.Equal(1, ts.AliasesDB.RevokeCallCount()) _, theName := ts.AliasesDB.RevokeArgsForCall(0) @@ -70,7 +75,15 @@ func TestAliasesRevoke(t *testing.T) { // now for unknown ID ts.AliasesDB.RevokeReturns(roomdb.ErrNotFound) addVals = url.Values{"name": []string{"nope"}} - rec = ts.Client.PostForm(urlRevoke.String(), addVals) - a.Equal(http.StatusNotFound, rec.Code) - //TODO: update redirect code with flash errors + rec = ts.Client.PostForm(urlRevoke, addVals) + a.Equal(http.StatusTemporaryRedirect, rec.Code) + a.Equal(overviewURL.Path, rec.Header().Get("Location")) + a.True(len(rec.Result().Cookies()) > 0, "got a cookie") + + // check flash messages + doc, resp = ts.Client.GetHTML(overviewURL) + a.Equal(http.StatusOK, resp.Code) + flashes = doc.Find("#flashes-list").Children() + a.Equal(1, flashes.Length()) + a.Equal("ErrorNotFound", flashes.Text()) } diff --git a/web/handlers/admin/denied_keys.go b/web/handlers/admin/denied_keys.go index 26fbfaf..2a5332e 100644 --- a/web/handlers/admin/denied_keys.go +++ b/web/handlers/admin/denied_keys.go @@ -19,6 +19,8 @@ import ( type deniedKeysHandler struct { r *render.Renderer + flashes *weberrors.FlashHelper + db roomdb.DeniedKeysService } @@ -40,7 +42,8 @@ func (h deniedKeysHandler) add(w http.ResponseWriter, req *http.Request) { newEntryParsed, err := refs.ParseFeedRef(newEntry) if err != nil { err = weberrors.ErrBadRequest{Where: "Public Key", Details: err} - h.r.Error(w, req, http.StatusBadRequest, err) + h.flashes.AddError(w, req, err) + http.Redirect(w, req, redirectToDeniedKeys, http.StatusTemporaryRedirect) return } @@ -49,16 +52,12 @@ func (h deniedKeysHandler) add(w http.ResponseWriter, req *http.Request) { err = h.db.Add(req.Context(), *newEntryParsed, comment) if err != nil { - code := http.StatusInternalServerError - var aa roomdb.ErrAlreadyAdded - if errors.As(err, &aa) { - code = http.StatusBadRequest - } - h.r.Error(w, req, code, err) - return + h.flashes.AddError(w, req, err) + } else { + h.flashes.AddMessage(w, req, "AdminDeniedKeysAdded") } - http.Redirect(w, req, redirectToDeniedKeys, http.StatusFound) + http.Redirect(w, req, redirectToDeniedKeys, http.StatusTemporaryRedirect) } func (h deniedKeysHandler) overview(rw http.ResponseWriter, req *http.Request) (interface{}, error) { @@ -77,13 +76,14 @@ func (h deniedKeysHandler) overview(rw http.ResponseWriter, req *http.Request) ( } pageData[csrf.TemplateTag] = csrf.TemplateField(req) + pageData["Flashes"], err = h.flashes.GetAll(rw, req) + if err != nil { + return nil, err + } return pageData, nil } -// TODO: move to render package so that we can decide to not render a page during the controller -var ErrRedirected = errors.New("render: not rendered but redirected") - func (h deniedKeysHandler) removeConfirm(rw http.ResponseWriter, req *http.Request) (interface{}, error) { id, err := strconv.ParseInt(req.URL.Query().Get("id"), 10, 64) if err != nil { @@ -93,12 +93,8 @@ func (h deniedKeysHandler) removeConfirm(rw http.ResponseWriter, req *http.Reque entry, err := h.db.GetByID(req.Context(), id) if err != nil { - if errors.Is(err, roomdb.ErrNotFound) { - // TODO "flash" errors - http.Redirect(rw, req, redirectToDeniedKeys, http.StatusFound) - return nil, ErrRedirected - } - return nil, err + h.flashes.AddError(rw, req, err) + return nil, weberrors.ErrRedirect{Path: redirectToDeniedKeys} } return map[string]interface{}{ @@ -112,7 +108,7 @@ func (h deniedKeysHandler) remove(rw http.ResponseWriter, req *http.Request) { if err != nil { err = weberrors.ErrBadRequest{Where: "Form data", Details: err} // TODO "flash" errors - http.Redirect(rw, req, redirectToDeniedKeys, http.StatusFound) + http.Redirect(rw, req, redirectToDeniedKeys, http.StatusTemporaryRedirect) return } @@ -120,7 +116,7 @@ func (h deniedKeysHandler) remove(rw http.ResponseWriter, req *http.Request) { if err != nil { err = weberrors.ErrBadRequest{Where: "ID", Details: err} // TODO "flash" errors - http.Redirect(rw, req, redirectToDeniedKeys, http.StatusFound) + http.Redirect(rw, req, redirectToDeniedKeys, http.StatusTemporaryRedirect) return } diff --git a/web/handlers/admin/denied_keys_test.go b/web/handlers/admin/denied_keys_test.go index ad2a8d6..ca6e21b 100644 --- a/web/handlers/admin/denied_keys_test.go +++ b/web/handlers/admin/denied_keys_test.go @@ -6,15 +6,11 @@ import ( "bytes" "net/http" "net/url" - "strings" "testing" - "github.com/PuerkitoBio/goquery" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/ssb-ngi-pointer/go-ssb-room/roomdb" - "github.com/ssb-ngi-pointer/go-ssb-room/web" "github.com/ssb-ngi-pointer/go-ssb-room/web/router" "github.com/ssb-ngi-pointer/go-ssb-room/web/webassert" refs "go.mindeco.de/ssb-refs" @@ -24,10 +20,9 @@ func TestDeniedKeysEmpty(t *testing.T) { ts := newSession(t) a := assert.New(t) - url, err := ts.Router.Get(router.AdminDeniedKeysOverview).URL() - a.Nil(err) + url := ts.URLTo(router.AdminDeniedKeysOverview) - html, resp := ts.Client.GetHTML(url.String()) + html, resp := ts.Client.GetHTML(url) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") webassert.Localized(t, html, []webassert.LocalizedElement{ @@ -41,10 +36,9 @@ func TestDeniedKeysAdd(t *testing.T) { ts := newSession(t) a := assert.New(t) - listURL, err := ts.Router.Get(router.AdminDeniedKeysOverview).URL() - a.NoError(err) + listURL := ts.URLTo(router.AdminDeniedKeysOverview) - html, resp := ts.Client.GetHTML(listURL.String()) + html, resp := ts.Client.GetHTML(listURL) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") formSelection := html.Find("form#add-entry") @@ -57,10 +51,8 @@ func TestDeniedKeysAdd(t *testing.T) { action, ok := formSelection.Attr("action") a.True(ok, "form has action set") - addURL, err := ts.Router.Get(router.AdminDeniedKeysAdd).URL() - a.NoError(err) - - a.Equal(addURL.String(), action) + addURL := ts.URLTo(router.AdminDeniedKeysAdd) + a.Equal(addURL.Path, action) webassert.ElementsInForm(t, formSelection, []webassert.FormElement{ {Name: "pub_key", Type: "text"}, @@ -73,8 +65,8 @@ func TestDeniedKeysAdd(t *testing.T) { // just any key that looks valid "pub_key": []string{newKey}, } - rec := ts.Client.PostForm(addURL.String(), addVals) - a.Equal(http.StatusFound, rec.Code) + rec := ts.Client.PostForm(addURL, addVals) + a.Equal(http.StatusTemporaryRedirect, rec.Code) a.Equal(1, ts.DeniedKeysDB.AddCallCount()) _, addedKey, addedComment := ts.DeniedKeysDB.AddArgsForCall(0) @@ -85,29 +77,30 @@ func TestDeniedKeysAdd(t *testing.T) { func TestDeniedKeysDontAddInvalid(t *testing.T) { ts := newSession(t) a := assert.New(t) - r := require.New(t) - addURL, err := ts.Router.Get(router.AdminDeniedKeysAdd).URL() - a.NoError(err) + addURL := ts.URLTo(router.AdminDeniedKeysAdd) newKey := "@some-garbage" addVals := url.Values{ "comment": []string{"some-comment"}, "pub_key": []string{newKey}, } - rec := ts.Client.PostForm(addURL.String(), addVals) - a.Equal(http.StatusBadRequest, rec.Code) + rec := ts.Client.PostForm(addURL, addVals) + a.Equal(http.StatusTemporaryRedirect, rec.Code) - a.Equal(0, ts.DeniedKeysDB.AddCallCount()) + a.Equal(0, ts.DeniedKeysDB.AddCallCount(), "did not call add") - doc, err := goquery.NewDocumentFromReader(rec.Body) - r.NoError(err) + listURL := ts.URLTo(router.AdminDeniedKeysOverview) + res := rec.Result() + a.Equal(listURL.Path, res.Header.Get("Location"), "redirecting to overview") + a.True(len(res.Cookies()) > 0, "got a cookie (flash msg)") - expErr := `bad request: feedRef: couldn't parse "@some-garbage"` - gotMsg := doc.Find("#errBody").Text() - if !a.True(strings.HasPrefix(gotMsg, expErr), "did not find errBody") { - t.Log(gotMsg) - } + doc, resp := ts.Client.GetHTML(listURL) + a.Equal(http.StatusOK, resp.Code) + + flashes := doc.Find("#flashes-list").Children() + a.Equal(1, flashes.Length()) + a.Equal("ErrorBadRequest", flashes.Text()) } func TestDeniedKeys(t *testing.T) { @@ -121,7 +114,9 @@ func TestDeniedKeys(t *testing.T) { } ts.DeniedKeysDB.ListReturns(lst, nil) - html, resp := ts.Client.GetHTML("/denied") + deniedOverviewURL := ts.URLTo(router.AdminDeniedKeysOverview) + + html, resp := ts.Client.GetHTML(deniedOverviewURL) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") webassert.Localized(t, html, []webassert.LocalizedElement{ @@ -137,7 +132,7 @@ func TestDeniedKeys(t *testing.T) { } ts.DeniedKeysDB.ListReturns(lst, nil) - html, resp = ts.Client.GetHTML("/denied") + html, resp = ts.Client.GetHTML(deniedOverviewURL) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") webassert.Localized(t, html, []webassert.LocalizedElement{ @@ -164,10 +159,9 @@ func TestDeniedKeysRemoveConfirmation(t *testing.T) { testEntry := roomdb.ListEntry{ID: 666, PubKey: *testKey} ts.DeniedKeysDB.GetByIDReturns(testEntry, nil) - urlTo := web.NewURLTo(ts.Router) - urlRemoveConfirm := urlTo(router.AdminDeniedKeysRemoveConfirm, "id", 3) + urlRemoveConfirm := ts.URLTo(router.AdminDeniedKeysRemoveConfirm, "id", 3) - html, resp := ts.Client.GetHTML(urlRemoveConfirm.String()) + html, resp := ts.Client.GetHTML(urlRemoveConfirm) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") a.Equal(testKey.Ref(), html.Find("pre#verify").Text(), "has the key for verification") @@ -181,10 +175,8 @@ func TestDeniedKeysRemoveConfirmation(t *testing.T) { action, ok := form.Attr("action") a.True(ok, "form has action set") - addURL, err := ts.Router.Get(router.AdminDeniedKeysRemove).URL() - a.NoError(err) - - a.Equal(addURL.String(), action) + addURL := ts.URLTo(router.AdminDeniedKeysRemove) + a.Equal(addURL.Path, action) webassert.ElementsInForm(t, form, []webassert.FormElement{ {Name: "id", Type: "hidden", Value: "666"}, @@ -195,13 +187,12 @@ func TestDeniedKeysRemove(t *testing.T) { ts := newSession(t) a := assert.New(t) - urlTo := web.NewURLTo(ts.Router) - urlRemove := urlTo(router.AdminDeniedKeysRemove) + urlRemove := ts.URLTo(router.AdminDeniedKeysRemove) ts.DeniedKeysDB.RemoveIDReturns(nil) addVals := url.Values{"id": []string{"666"}} - rec := ts.Client.PostForm(urlRemove.String(), addVals) + rec := ts.Client.PostForm(urlRemove, addVals) a.Equal(http.StatusFound, rec.Code) a.Equal(1, ts.DeniedKeysDB.RemoveIDCallCount()) @@ -211,7 +202,7 @@ func TestDeniedKeysRemove(t *testing.T) { // now for unknown ID ts.DeniedKeysDB.RemoveIDReturns(roomdb.ErrNotFound) addVals = url.Values{"id": []string{"667"}} - rec = ts.Client.PostForm(urlRemove.String(), addVals) + rec = ts.Client.PostForm(urlRemove, addVals) a.Equal(http.StatusNotFound, rec.Code) //TODO: update redirect code with flash errors } diff --git a/web/handlers/admin/handler.go b/web/handlers/admin/handler.go index 465244c..8bec6e4 100644 --- a/web/handlers/admin/handler.go +++ b/web/handlers/admin/handler.go @@ -18,6 +18,7 @@ import ( "github.com/ssb-ngi-pointer/go-ssb-room/roomdb" "github.com/ssb-ngi-pointer/go-ssb-room/roomstate" + weberrors "github.com/ssb-ngi-pointer/go-ssb-room/web/errors" ) // HTMLTemplates define the list of files the template system should load. @@ -60,11 +61,11 @@ func Handler( domainName string, r *render.Renderer, roomState *roomstate.Manager, + fh *weberrors.FlashHelper, dbs Databases, ) http.Handler { mux := &http.ServeMux{} - // TODO: configure 404 handler var dashboardHandler = dashboardHandler{ r: r, dbs: dbs, @@ -84,14 +85,18 @@ func Handler( })) var ah = aliasesHandler{ - r: r, + r: r, + flashes: fh, + db: dbs.Aliases, } mux.HandleFunc("/aliases/revoke/confirm", r.HTML("admin/aliases-revoke-confirm.tmpl", ah.revokeConfirm)) mux.HandleFunc("/aliases/revoke", ah.revoke) var dh = deniedKeysHandler{ - r: r, + r: r, + flashes: fh, + db: dbs.DeniedKeys, } mux.HandleFunc("/denied", r.HTML("admin/denied-keys.tmpl", dh.overview)) @@ -100,7 +105,9 @@ func Handler( mux.HandleFunc("/denied/remove", dh.remove) var mh = membersHandler{ - r: r, + r: r, + flashes: fh, + db: dbs.Members, } mux.HandleFunc("/member", r.HTML("admin/member.tmpl", mh.details)) @@ -111,7 +118,9 @@ func Handler( mux.HandleFunc("/members/remove", mh.remove) var ih = invitesHandler{ - r: r, + r: r, + flashes: fh, + db: dbs.Invites, config: dbs.Config, @@ -124,7 +133,9 @@ func Handler( mux.HandleFunc("/invites/revoke", ih.revoke) var nh = noticeHandler{ - r: r, + r: r, + flashes: fh, + noticeDB: dbs.Notices, pinnedDB: dbs.PinnedNotices, } @@ -133,6 +144,11 @@ func Handler( mux.HandleFunc("/notice/translation/add", nh.addTranslation) mux.HandleFunc("/notice/save", nh.save) + // path:/ matches everything that isn't registerd (ie. its the "Not Found handler") + mux.HandleFunc("/", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + r.Error(rw, req, 404, weberrors.ErrNotFound{What: req.URL.Path}) + })) + return customStripPrefix("/admin", mux) } diff --git a/web/handlers/admin/handler_test.go b/web/handlers/admin/handler_test.go index 2e5c579..0884bc6 100644 --- a/web/handlers/admin/handler_test.go +++ b/web/handlers/admin/handler_test.go @@ -21,10 +21,9 @@ func TestDashoard(t *testing.T) { ts.InvitesDB.CountReturns(3, nil) // 3 invites ts.DeniedKeysDB.CountReturns(2, nil) // 2 banned - url, err := ts.Router.Get(router.AdminDashboard).URL() - a.Nil(err) + dashURL := ts.URLTo(router.AdminDashboard) - html, resp := ts.Client.GetHTML(url.String()) + html, resp := ts.Client.GetHTML(dashURL) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") a.Equal("1", html.Find("#online-count").Text()) diff --git a/web/handlers/admin/invites.go b/web/handlers/admin/invites.go index 3bd9343..a2c19fe 100644 --- a/web/handlers/admin/invites.go +++ b/web/handlers/admin/invites.go @@ -17,7 +17,8 @@ import ( ) type invitesHandler struct { - r *render.Renderer + r *render.Renderer + flashes *weberrors.FlashHelper db roomdb.InvitesService config roomdb.RoomConfig @@ -42,6 +43,10 @@ func (h invitesHandler) overview(rw http.ResponseWriter, req *http.Request) (int } pageData[csrf.TemplateTag] = csrf.TemplateField(req) + pageData["Flashes"], err = h.flashes.GetAll(rw, req) + if err != nil { + return nil, err + } return pageData, nil } diff --git a/web/handlers/admin/invites_test.go b/web/handlers/admin/invites_test.go index adefd3c..965bb1c 100644 --- a/web/handlers/admin/invites_test.go +++ b/web/handlers/admin/invites_test.go @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/require" "github.com/ssb-ngi-pointer/go-ssb-room/roomdb" - "github.com/ssb-ngi-pointer/go-ssb-room/web" "github.com/ssb-ngi-pointer/go-ssb-room/web/router" "github.com/ssb-ngi-pointer/go-ssb-room/web/webassert" ) @@ -28,7 +27,9 @@ func TestInvitesOverview(t *testing.T) { } ts.InvitesDB.ListReturns(lst, nil) - html, resp := ts.Client.GetHTML("/invites") + invitesOverviewURL := ts.URLTo(router.AdminInvitesOverview) + + html, resp := ts.Client.GetHTML(invitesOverviewURL) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") webassert.Localized(t, html, []webassert.LocalizedElement{ @@ -46,7 +47,7 @@ func TestInvitesOverview(t *testing.T) { } ts.InvitesDB.ListReturns(lst, nil) - html, resp = ts.Client.GetHTML("/invites") + html, resp = ts.Client.GetHTML(invitesOverviewURL) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") webassert.Localized(t, html, []webassert.LocalizedElement{ @@ -68,10 +69,9 @@ func TestInvitesCreateForm(t *testing.T) { ts := newSession(t) a := assert.New(t) - url, err := ts.Router.Get(router.AdminInvitesOverview).URL() - a.Nil(err) + overviewURL := ts.URLTo(router.AdminInvitesOverview) - html, resp := ts.Client.GetHTML(url.String()) + html, resp := ts.Client.GetHTML(overviewURL) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") webassert.Localized(t, html, []webassert.LocalizedElement{ @@ -89,10 +89,8 @@ func TestInvitesCreateForm(t *testing.T) { action, ok := formSelection.Attr("action") a.True(ok, "form has action set") - addURL, err := ts.Router.Get(router.AdminInvitesCreate).URL() - a.NoError(err) - - a.Equal(addURL.String(), action) + addURL := ts.URLTo(router.AdminInvitesCreate) + a.Equal(addURL.Path, action) } func TestInvitesCreate(t *testing.T) { @@ -100,13 +98,12 @@ func TestInvitesCreate(t *testing.T) { a := assert.New(t) r := require.New(t) - urlTo := web.NewURLTo(ts.Router) - urlRemove := urlTo(router.AdminInvitesCreate) + urlRemove := ts.URLTo(router.AdminInvitesCreate) testInvite := "your-fake-test-invite" ts.InvitesDB.CreateReturns(testInvite, nil) - rec := ts.Client.PostForm(urlRemove.String(), url.Values{}) + rec := ts.Client.PostForm(urlRemove, url.Values{}) a.Equal(http.StatusOK, rec.Code) r.Equal(1, ts.InvitesDB.CreateCallCount(), "expected one invites.Create call") @@ -121,9 +118,7 @@ func TestInvitesCreate(t *testing.T) { {"#welcome", "AdminInviteCreatedTitle" + "AdminInviteCreatedInstruct"}, }) - wantURL := urlTo(router.CompleteInviteFacade, "token", testInvite) - wantURL.Host = ts.Domain - wantURL.Scheme = "https" + wantURL := ts.URLTo(router.CompleteInviteFacade, "token", testInvite) shownLink := doc.Find("#invite-facade-link").Text() a.Equal(wantURL.String(), shownLink) diff --git a/web/handlers/admin/members.go b/web/handlers/admin/members.go index 3a690e6..ef0ab01 100644 --- a/web/handlers/admin/members.go +++ b/web/handlers/admin/members.go @@ -22,6 +22,8 @@ import ( type membersHandler struct { r *render.Renderer + flashes *weberrors.FlashHelper + db roomdb.MembersService } @@ -44,22 +46,19 @@ func (h membersHandler) add(w http.ResponseWriter, req *http.Request) { newEntryParsed, err := refs.ParseFeedRef(newEntry) if err != nil { err = weberrors.ErrBadRequest{Where: "Public Key", Details: err} - h.r.Error(w, req, http.StatusBadRequest, err) + h.flashes.AddError(w, req, err) + http.Redirect(w, req, redirectToMembers, http.StatusTemporaryRedirect) return } _, err = h.db.Add(req.Context(), *newEntryParsed, roomdb.RoleMember) if err != nil { - code := http.StatusInternalServerError - var aa roomdb.ErrAlreadyAdded - if errors.As(err, &aa) { - code = http.StatusBadRequest - } - h.r.Error(w, req, code, err) - return + h.flashes.AddError(w, req, err) + } else { + h.flashes.AddMessage(w, req, "AdminMemberAdded") } - http.Redirect(w, req, redirectToMembers, http.StatusFound) + http.Redirect(w, req, redirectToMembers, http.StatusTemporaryRedirect) } func (h membersHandler) changeRole(w http.ResponseWriter, req *http.Request) { @@ -98,11 +97,12 @@ func (h membersHandler) changeRole(w http.ResponseWriter, req *http.Request) { if err := h.db.SetRole(req.Context(), memberID, role); err != nil { err = weberrors.DatabaseError{Reason: err} - // TODO: not found error h.r.Error(w, req, http.StatusInternalServerError, err) return } + h.flashes.AddMessage(w, req, "AdminMemberUpdated") + urlTo := web.NewURLTo(router.CompleteApp()) memberDetailsURL := urlTo(router.AdminMemberDetails, "id", memberID).String() http.Redirect(w, req, memberDetailsURL, http.StatusTemporaryRedirect) @@ -127,6 +127,11 @@ func (h membersHandler) overview(rw http.ResponseWriter, req *http.Request) (int pageData["AllRoles"] = []roomdb.Role{roomdb.RoleMember, roomdb.RoleModerator, roomdb.RoleAdmin} + pageData["Flashes"], err = h.flashes.GetAll(rw, req) + if err != nil { + return nil, err + } + return pageData, nil } @@ -162,8 +167,8 @@ func (h membersHandler) removeConfirm(rw http.ResponseWriter, req *http.Request) entry, err := h.db.GetByID(req.Context(), id) if err != nil { if errors.Is(err, roomdb.ErrNotFound) { - http.Redirect(rw, req, redirectToMembers, http.StatusFound) - return nil, ErrRedirected + h.flashes.AddError(rw, req, err) + return nil, weberrors.ErrRedirect{Path: redirectToMembers} } return nil, err } @@ -175,32 +180,32 @@ func (h membersHandler) removeConfirm(rw http.ResponseWriter, req *http.Request) } func (h membersHandler) remove(rw http.ResponseWriter, req *http.Request) { - err := req.ParseForm() - if err != nil { + if req.Method != "POST" { + err := weberrors.ErrBadRequest{Where: "HTTP Method", Details: fmt.Errorf("expected POST not %s", req.Method)} + h.r.Error(rw, req, http.StatusBadRequest, err) + return + } + + if err := req.ParseForm(); err != nil { err = weberrors.ErrBadRequest{Where: "Form data", Details: err} - // TODO "flash" errors - http.Redirect(rw, req, redirectToMembers, http.StatusFound) + h.r.Error(rw, req, http.StatusBadRequest, err) return } id, err := strconv.ParseInt(req.FormValue("id"), 10, 64) if err != nil { err = weberrors.ErrBadRequest{Where: "ID", Details: err} - // TODO "flash" errors - http.Redirect(rw, req, redirectToMembers, http.StatusFound) + h.flashes.AddError(rw, req, err) + http.Redirect(rw, req, redirectToMembers, http.StatusTemporaryRedirect) return } - status := http.StatusFound err = h.db.RemoveID(req.Context(), id) if err != nil { - if !errors.Is(err, roomdb.ErrNotFound) { - // TODO "flash" errors - h.r.Error(rw, req, http.StatusInternalServerError, err) - return - } - status = http.StatusNotFound + h.flashes.AddError(rw, req, err) + } else { + h.flashes.AddMessage(rw, req, "AdminMemberRemoved") } - http.Redirect(rw, req, redirectToMembers, status) + http.Redirect(rw, req, redirectToMembers, http.StatusTemporaryRedirect) } diff --git a/web/handlers/admin/members_test.go b/web/handlers/admin/members_test.go index 4a7a00c..49c4552 100644 --- a/web/handlers/admin/members_test.go +++ b/web/handlers/admin/members_test.go @@ -4,16 +4,11 @@ import ( "bytes" "net/http" "net/url" - "strings" "testing" - "github.com/PuerkitoBio/goquery" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/ssb-ngi-pointer/go-ssb-room/roomdb" - "github.com/ssb-ngi-pointer/go-ssb-room/web" "github.com/ssb-ngi-pointer/go-ssb-room/web/router" "github.com/ssb-ngi-pointer/go-ssb-room/web/webassert" refs "go.mindeco.de/ssb-refs" @@ -23,10 +18,9 @@ func TestMembersEmpty(t *testing.T) { ts := newSession(t) a := assert.New(t) - url, err := ts.Router.Get(router.AdminMembersOverview).URL() - a.Nil(err) + url := ts.URLTo(router.AdminMembersOverview) - html, resp := ts.Client.GetHTML(url.String()) + html, resp := ts.Client.GetHTML(url) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") webassert.Localized(t, html, []webassert.LocalizedElement{ @@ -40,10 +34,9 @@ func TestMembersAdd(t *testing.T) { ts := newSession(t) a := assert.New(t) - listURL, err := ts.Router.Get(router.AdminMembersOverview).URL() - a.NoError(err) + listURL := ts.URLTo(router.AdminMembersOverview) - html, resp := ts.Client.GetHTML(listURL.String()) + html, resp := ts.Client.GetHTML(listURL) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") formSelection := html.Find("form#add-entry") @@ -56,10 +49,8 @@ func TestMembersAdd(t *testing.T) { action, ok := formSelection.Attr("action") a.True(ok, "form has action set") - addURL, err := ts.Router.Get(router.AdminMembersAdd).URL() - a.NoError(err) - - a.Equal(addURL.String(), action) + addURL := ts.URLTo(router.AdminMembersAdd) + a.Equal(addURL.Path, action) webassert.ElementsInForm(t, formSelection, []webassert.FormElement{ {Name: "pub_key", Type: "text"}, @@ -70,8 +61,8 @@ func TestMembersAdd(t *testing.T) { // just any key that looks valid "pub_key": []string{newKey}, } - rec := ts.Client.PostForm(addURL.String(), addVals) - a.Equal(http.StatusFound, rec.Code) + rec := ts.Client.PostForm(addURL, addVals) + a.Equal(http.StatusTemporaryRedirect, rec.Code) a.Equal(1, ts.MembersDB.AddCallCount()) _, addedPubKey, addedRole := ts.MembersDB.AddArgsForCall(0) @@ -83,29 +74,31 @@ func TestMembersAdd(t *testing.T) { func TestMembersDontAddInvalid(t *testing.T) { ts := newSession(t) a := assert.New(t) - r := require.New(t) - addURL, err := ts.Router.Get(router.AdminMembersAdd).URL() - a.NoError(err) + addURL := ts.URLTo(router.AdminMembersAdd) newKey := "@some-garbage" addVals := url.Values{ "nick": []string{"some-test-nick"}, "pub_key": []string{newKey}, } - rec := ts.Client.PostForm(addURL.String(), addVals) - a.Equal(http.StatusBadRequest, rec.Code) + rec := ts.Client.PostForm(addURL, addVals) + a.Equal(http.StatusTemporaryRedirect, rec.Code) a.Equal(0, ts.MembersDB.AddCallCount()) - doc, err := goquery.NewDocumentFromReader(rec.Body) - r.NoError(err) + listURL := ts.URLTo(router.AdminMembersOverview) + res := rec.Result() + a.Equal(listURL.Path, res.Header.Get("Location"), "redirecting to overview") + a.True(len(res.Cookies()) > 0, "got a cookie (flash msg)") + + doc, resp := ts.Client.GetHTML(listURL) + a.Equal(http.StatusOK, resp.Code) + + flashes := doc.Find("#flashes-list").Children() + a.Equal(1, flashes.Length()) + a.Equal("ErrorBadRequest", flashes.Text()) - expErr := `bad public key: feedRef: couldn't parse "@some-garbage"` - gotMsg := doc.Find("#errBody").Text() - if !a.True(strings.HasPrefix(gotMsg, expErr), "did not find errBody") { - t.Log(gotMsg) - } } func TestMembers(t *testing.T) { @@ -119,7 +112,9 @@ func TestMembers(t *testing.T) { } ts.MembersDB.ListReturns(lst, nil) - html, resp := ts.Client.GetHTML("/members") + membersOveriwURL := ts.URLTo(router.AdminMembersOverview) + + html, resp := ts.Client.GetHTML(membersOveriwURL) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") webassert.Localized(t, html, []webassert.LocalizedElement{ @@ -135,7 +130,7 @@ func TestMembers(t *testing.T) { } ts.MembersDB.ListReturns(lst, nil) - html, resp = ts.Client.GetHTML("/members") + html, resp = ts.Client.GetHTML(membersOveriwURL) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") webassert.Localized(t, html, []webassert.LocalizedElement{ @@ -218,10 +213,9 @@ func TestMembersRemoveConfirmation(t *testing.T) { testEntry := roomdb.Member{ID: 666, PubKey: *testKey} ts.MembersDB.GetByIDReturns(testEntry, nil) - urlTo := web.NewURLTo(ts.Router) - urlRemoveConfirm := urlTo(router.AdminMembersRemoveConfirm, "id", 3) + urlRemoveConfirm := ts.URLTo(router.AdminMembersRemoveConfirm, "id", 3) - html, resp := ts.Client.GetHTML(urlRemoveConfirm.String()) + html, resp := ts.Client.GetHTML(urlRemoveConfirm) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") a.Equal(testKey.Ref(), html.Find("pre#verify").Text(), "has the key for verification") @@ -235,10 +229,8 @@ func TestMembersRemoveConfirmation(t *testing.T) { action, ok := form.Attr("action") a.True(ok, "form has action set") - addURL, err := ts.Router.Get(router.AdminMembersRemove).URL() - a.NoError(err) - - a.Equal(addURL.String(), action) + addURL := ts.URLTo(router.AdminMembersRemove) + a.Equal(addURL.Path, action) webassert.ElementsInForm(t, form, []webassert.FormElement{ {Name: "id", Type: "hidden", Value: "666"}, @@ -249,23 +241,46 @@ func TestMembersRemove(t *testing.T) { ts := newSession(t) a := assert.New(t) - urlTo := web.NewURLTo(ts.Router) - urlRemove := urlTo(router.AdminMembersRemove) + urlRemove := ts.URLTo(router.AdminMembersRemove) ts.MembersDB.RemoveIDReturns(nil) addVals := url.Values{"id": []string{"666"}} - rec := ts.Client.PostForm(urlRemove.String(), addVals) - a.Equal(http.StatusFound, rec.Code) + rec := ts.Client.PostForm(urlRemove, addVals) + a.Equal(http.StatusTemporaryRedirect, rec.Code) a.Equal(1, ts.MembersDB.RemoveIDCallCount()) _, theID := ts.MembersDB.RemoveIDArgsForCall(0) a.EqualValues(666, theID) + listURL := ts.URLTo(router.AdminMembersOverview) + // check flash message + res := rec.Result() + a.Equal(listURL.Path, res.Header.Get("Location"), "redirecting to overview") + a.True(len(res.Cookies()) > 0, "got a cookie (flash msg)") + + doc, resp := ts.Client.GetHTML(listURL) + a.Equal(http.StatusOK, resp.Code) + + flashes := doc.Find("#flashes-list").Children() + a.Equal(1, flashes.Length()) + a.Equal("AdminMemberRemoved", flashes.Text()) + // now for unknown ID ts.MembersDB.RemoveIDReturns(roomdb.ErrNotFound) addVals = url.Values{"id": []string{"667"}} - rec = ts.Client.PostForm(urlRemove.String(), addVals) - a.Equal(http.StatusNotFound, rec.Code) - //TODO: update redirect code with flash errors + rec = ts.Client.PostForm(urlRemove, addVals) + a.Equal(http.StatusTemporaryRedirect, rec.Code) + + // check flash message + res = rec.Result() + a.Equal(listURL.Path, res.Header.Get("Location"), "redirecting to overview") + a.True(len(res.Cookies()) > 0, "got a cookie (flash msg)") + + doc, resp = ts.Client.GetHTML(listURL) + a.Equal(http.StatusOK, resp.Code) + + flashes = doc.Find("#flashes-list").Children() + a.Equal(1, flashes.Length()) + a.Equal("ErrorNotFound", flashes.Text()) } diff --git a/web/handlers/admin/notices.go b/web/handlers/admin/notices.go index 16a5d7f..3f9545e 100644 --- a/web/handlers/admin/notices.go +++ b/web/handlers/admin/notices.go @@ -19,6 +19,8 @@ import ( type noticeHandler struct { r *render.Renderer + flashes *weberrors.FlashHelper + noticeDB roomdb.NoticesService pinnedDB roomdb.PinnedNoticesService } diff --git a/web/handlers/admin/notices_test.go b/web/handlers/admin/notices_test.go index c2f2583..e0a8f7d 100644 --- a/web/handlers/admin/notices_test.go +++ b/web/handlers/admin/notices_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/ssb-ngi-pointer/go-ssb-room/roomdb" - "github.com/ssb-ngi-pointer/go-ssb-room/web" "github.com/ssb-ngi-pointer/go-ssb-room/web/router" "github.com/ssb-ngi-pointer/go-ssb-room/web/webassert" "github.com/stretchr/testify/assert" @@ -17,8 +16,6 @@ import ( func TestNoticeSaveActuallyCalled(t *testing.T) { ts := newSession(t) a := assert.New(t) - // instantiate the urlTo helper (constructs urls for us!) - urlTo := web.NewURLTo(ts.Router) id := []string{"1"} title := []string{"SSB Breaking News: This Test Is Great"} @@ -26,9 +23,9 @@ func TestNoticeSaveActuallyCalled(t *testing.T) { language := []string{"en-GB"} // POST a correct request to the save handler, and verify that the save was handled using the mock database) - u := urlTo(router.AdminNoticeSave) + u := ts.URLTo(router.AdminNoticeSave) formValues := url.Values{"id": id, "title": title, "content": content, "language": language} - resp := ts.Client.PostForm(u.String(), formValues) + resp := ts.Client.PostForm(u, formValues) a.Equal(http.StatusSeeOther, resp.Code, "POST should work") a.Equal(1, ts.NoticeDB.SaveCallCount(), "noticedb should have saved after POST completed") } @@ -37,8 +34,6 @@ func TestNoticeSaveActuallyCalled(t *testing.T) { func TestNoticeSaveRefusesIncomplete(t *testing.T) { ts := newSession(t) a := assert.New(t) - // instantiate the urlTo helper (constructs urls for us!) - urlTo := web.NewURLTo(ts.Router) // notice values we are selectively omitting in the tests below id := []string{"1"} @@ -47,24 +42,24 @@ func TestNoticeSaveRefusesIncomplete(t *testing.T) { language := []string{"pt"} /* save without id */ - u := urlTo(router.AdminNoticeSave) + u := ts.URLTo(router.AdminNoticeSave) emptyParams := url.Values{} - resp := ts.Client.PostForm(u.String(), emptyParams) + resp := ts.Client.PostForm(u, emptyParams) a.Equal(http.StatusInternalServerError, resp.Code, "saving without id should not work") /* save without title */ formValues := url.Values{"id": id, "content": content, "language": language} - resp = ts.Client.PostForm(u.String(), formValues) + resp = ts.Client.PostForm(u, formValues) a.Equal(http.StatusInternalServerError, resp.Code, "saving without title should not work") /* save without content */ formValues = url.Values{"id": id, "title": title, "language": language} - resp = ts.Client.PostForm(u.String(), formValues) + resp = ts.Client.PostForm(u, formValues) a.Equal(http.StatusInternalServerError, resp.Code, "saving without content should not work") /* save without language */ formValues = url.Values{"id": id, "title": title, "content": content} - resp = ts.Client.PostForm(u.String(), formValues) + resp = ts.Client.PostForm(u, formValues) a.Equal(http.StatusInternalServerError, resp.Code, "saving without language should not work") a.Equal(0, ts.NoticeDB.SaveCallCount(), "noticedb should never save incomplete requests") @@ -74,12 +69,11 @@ func TestNoticeSaveRefusesIncomplete(t *testing.T) { func TestNoticeAddLanguageOnlyAllowsPost(t *testing.T) { ts := newSession(t) a := assert.New(t) - // instantiate the urlTo helper (constructs urls for us!) - urlTo := web.NewURLTo(ts.Router) + // instantiate the ts.URLTo helper (constructs urls for us!) // verify that a GET request is no bueno - u := urlTo(router.AdminNoticeAddTranslation, "name", roomdb.NoticeNews.String()) - _, resp := ts.Client.GetHTML(u.String()) + u := ts.URLTo(router.AdminNoticeAddTranslation, "name", roomdb.NoticeNews.String()) + _, resp := ts.Client.GetHTML(u) a.Equal(http.StatusMethodNotAllowed, resp.Code, "GET should not be allowed for this route") // next up, we verify that a correct POST request actually works: @@ -89,7 +83,7 @@ func TestNoticeAddLanguageOnlyAllowsPost(t *testing.T) { language := []string{"pt"} formValues := url.Values{"name": []string{roomdb.NoticeNews.String()}, "id": id, "title": title, "content": content, "language": language} - resp = ts.Client.PostForm(u.String(), formValues) + resp = ts.Client.PostForm(u, formValues) a.Equal(http.StatusTemporaryRedirect, resp.Code) } @@ -97,8 +91,7 @@ func TestNoticeAddLanguageOnlyAllowsPost(t *testing.T) { func TestNoticeDraftLanguageIncludesAllFields(t *testing.T) { ts := newSession(t) a := assert.New(t) - // instantiate the urlTo helper (constructs urls for us!) - urlTo := web.NewURLTo(ts.Router) + // instantiate the ts.URLTo helper (constructs urls for us!) // to test translations we first need to add a notice to the notice mockdb notice := roomdb.Notice{ @@ -110,8 +103,8 @@ func TestNoticeDraftLanguageIncludesAllFields(t *testing.T) { // make sure we return a notice when accessing pinned notices (which are the only notices with translations at writing (2021-03-11) ts.PinnedDB.GetReturns(¬ice, nil) - u := urlTo(router.AdminNoticeDraftTranslation, "name", roomdb.NoticeNews.String()) - html, resp := ts.Client.GetHTML(u.String()) + u := ts.URLTo(router.AdminNoticeDraftTranslation, "name", roomdb.NoticeNews.String()) + html, resp := ts.Client.GetHTML(u) form := html.Find("form") a.Equal(http.StatusOK, resp.Code, "Wrong HTTP status code") // FormElement defaults to input if tag omitted @@ -125,8 +118,7 @@ func TestNoticeDraftLanguageIncludesAllFields(t *testing.T) { func TestNoticeEditFormIncludesAllFields(t *testing.T) { ts := newSession(t) a := assert.New(t) - // instantiate the urlTo helper (constructs urls for us!) - urlTo := web.NewURLTo(ts.Router) + // instantiate the ts.URLTo helper (constructs urls for us!) // Create mock notice data to operate on notice := roomdb.Notice{ @@ -137,8 +129,8 @@ func TestNoticeEditFormIncludesAllFields(t *testing.T) { } ts.NoticeDB.GetByIDReturns(notice, nil) - u := urlTo(router.AdminNoticeEdit, "id", 1) - html, resp := ts.Client.GetHTML(u.String()) + u := ts.URLTo(router.AdminNoticeEdit, "id", 1) + html, resp := ts.Client.GetHTML(u) form := html.Find("form") a.Equal(http.StatusOK, resp.Code, "Wrong HTTP status code") diff --git a/web/handlers/admin/setup_test.go b/web/handlers/admin/setup_test.go index 5179c9d..e8fe897 100644 --- a/web/handlers/admin/setup_test.go +++ b/web/handlers/admin/setup_test.go @@ -4,30 +4,41 @@ package admin import ( "context" + "crypto/rand" "net/http" + "net/url" + "os" + "path/filepath" "testing" "time" "github.com/dustin/go-humanize" - "github.com/gorilla/mux" + "github.com/gorilla/sessions" "github.com/pkg/errors" "go.mindeco.de/http/render" "go.mindeco.de/http/tester" "go.mindeco.de/logging/logtest" "github.com/ssb-ngi-pointer/go-ssb-room/internal/randutil" + "github.com/ssb-ngi-pointer/go-ssb-room/internal/repo" "github.com/ssb-ngi-pointer/go-ssb-room/roomdb" "github.com/ssb-ngi-pointer/go-ssb-room/roomdb/mockdb" "github.com/ssb-ngi-pointer/go-ssb-room/roomstate" "github.com/ssb-ngi-pointer/go-ssb-room/web" + weberrs "github.com/ssb-ngi-pointer/go-ssb-room/web/errors" + "github.com/ssb-ngi-pointer/go-ssb-room/web/i18n" + "github.com/ssb-ngi-pointer/go-ssb-room/web/i18n/i18ntesting" "github.com/ssb-ngi-pointer/go-ssb-room/web/members" "github.com/ssb-ngi-pointer/go-ssb-room/web/router" ) type testSession struct { + Domain string Mux *http.ServeMux + Client *tester.Tester - Router *mux.Router + + URLTo web.URLMaker AliasesDB *mockdb.FakeAliasesService ConfigDB *mockdb.FakeRoomConfig @@ -39,8 +50,6 @@ type testSession struct { User roomdb.Member - Domain string - RoomState *roomstate.Manager } @@ -62,49 +71,78 @@ func newSession(t *testing.T) *testSession { ctx := context.TODO() ts.RoomState = roomstate.NewManager(ctx, log) - ts.Router = router.CompleteApp() - ts.Domain = randutil.String(10) + // instantiate the urlTo helper (constructs urls for us!) + // the cookiejar in our custom http/tester needs a non-empty domain and scheme + router := router.CompleteApp() + urlTo := web.NewURLTo(router) + ts.URLTo = func(name string, vals ...interface{}) *url.URL { + testURL := urlTo(name, vals...) + if testURL == nil { + t.Fatalf("no URL for %s", name) + } + testURL.Host = ts.Domain + testURL.Scheme = "https" // fake + return testURL + } + // fake user ts.User = roomdb.Member{ ID: 1234, Role: roomdb.RoleModerator, } + testPath := filepath.Join("testrun", t.Name()) + os.RemoveAll(testPath) + + i18ntesting.WriteReplacement(t) + + testRepo := repo.New(testPath) + locHelper, err := i18n.New(testRepo) + if err != nil { + t.Fatal(err) + } + + authKey := make([]byte, 64) + rand.Read(authKey) + encKey := make([]byte, 32) + rand.Read(encKey) + + sessionsPath := filepath.Join(testPath, "sessions") + os.MkdirAll(sessionsPath, 0700) + fsStore := sessions.NewFilesystemStore(sessionsPath, authKey, encKey) + + flashHelper := weberrs.NewFlashHelper(fsStore, locHelper) + // setup rendering // TODO: make testing utils and move these there - testFuncs := web.TemplateFuncs(ts.Router) - testFuncs["i18n"] = func(msgID string) string { return msgID } - testFuncs["i18npl"] = func(msgID string, count int, _ ...interface{}) string { - if count == 1 { - return msgID + "Singular" - } - return msgID + "Plural" - } + testFuncs := web.TemplateFuncs(router) testFuncs["current_page_is"] = func(routeName string) bool { return true } testFuncs["is_logged_in"] = func() *roomdb.Member { return &ts.User } testFuncs["urlToNotice"] = func(name string) string { return "" } testFuncs["relative_time"] = func(when time.Time) string { return humanize.Time(when) } - r, err := render.New(web.Templates, + renderOpts := []render.Option{ render.SetLogger(log), - render.BaseTemplates("base.tmpl", "menu.tmpl"), + render.BaseTemplates("base.tmpl", "menu.tmpl", "flashes.tmpl"), render.AddTemplates(append(HTMLTemplates, "error.tmpl")...), render.ErrorTemplate("error.tmpl"), render.FuncMap(testFuncs), - ) + } + renderOpts = append(renderOpts, locHelper.GetRenderFuncs()...) + + r, err := render.New(web.Templates, renderOpts...) if err != nil { t.Fatal(errors.Wrap(err, "setup: render init failed")) } - ts.Mux = http.NewServeMux() - handler := Handler( ts.Domain, r, ts.RoomState, + flashHelper, Databases{ Aliases: ts.AliasesDB, Config: ts.ConfigDB, @@ -118,6 +156,7 @@ func newSession(t *testing.T) *testSession { handler = members.MiddlewareForTests(ts.User)(handler) + ts.Mux = http.NewServeMux() ts.Mux.Handle("/", handler) ts.Client = tester.New(ts.Mux, t) diff --git a/web/handlers/aliases_test.go b/web/handlers/aliases_test.go index 478cbb2..7d4d35d 100644 --- a/web/handlers/aliases_test.go +++ b/web/handlers/aliases_test.go @@ -32,23 +32,30 @@ func TestAliasResolve(t *testing.T) { } ts.AliasesDB.ResolveReturns(testAlias, nil) + // to construct the /alias/{name} url we need to bypass urlTo + // (which builds ?alias=name) + routes := router.CompleteApp() + // default is HTML - htmlURL, err := ts.Router.Get(router.CompleteAliasResolve).URL("alias", testAlias.Name) - r.Nil(err) + + htmlURL, err := routes.Get(router.CompleteAliasResolve).URL("alias", testAlias.Name) + r.NoError(err) + t.Log("resolving", htmlURL.String()) - html, resp := ts.Client.GetHTML(htmlURL.String()) + html, resp := ts.Client.GetHTML(htmlURL) a.Equal(http.StatusOK, resp.Code) a.Equal(testAlias.Name, html.Find("title").Text()) - // default is HTML - jsonURL, err := ts.Router.Get(router.CompleteAliasResolve).URL("alias", testAlias.Name) - r.Nil(err) + // now as JSON + jsonURL, err := routes.Get(router.CompleteAliasResolve).URL("alias", testAlias.Name) + r.NoError(err) + q := jsonURL.Query() q.Set("encoding", "json") jsonURL.RawQuery = q.Encode() t.Log("resolving", jsonURL.String()) - resp = ts.Client.GetBody(jsonURL.String()) + resp = ts.Client.GetBody(jsonURL) a.Equal(http.StatusOK, resp.Code) var ar aliasJSONResponse diff --git a/web/handlers/auth/withssb.go b/web/handlers/auth/withssb.go index d2fd0bb..42fe17b 100644 --- a/web/handlers/auth/withssb.go +++ b/web/handlers/auth/withssb.go @@ -6,6 +6,7 @@ import ( "context" "encoding/base64" "encoding/gob" + "errors" "fmt" "html/template" "image/color" @@ -223,9 +224,8 @@ func (h WithSSBHandler) DecideMethod(w http.ResponseWriter, req *http.Request) { _, err := h.membersdb.GetByFeed(req.Context(), *cid) if err != nil { - if err == roomdb.ErrNotFound { - errMsg := fmt.Errorf("ssb http auth: client isn't a member: %w", err) - h.render.Error(w, req, http.StatusForbidden, errMsg) + if errors.Is(err, roomdb.ErrNotFound) { + h.render.Error(w, req, http.StatusForbidden, weberrors.ErrForbidden{Details: err}) return } h.render.Error(w, req, http.StatusInternalServerError, err) @@ -273,11 +273,11 @@ func (h WithSSBHandler) clientInitiated(w http.ResponseWriter, req *http.Request // check that we have that member member, err := h.membersdb.GetByFeed(req.Context(), client) if err != nil { - errMsg := fmt.Errorf("ssb http auth: client isn't a member: %w", err) - if err == roomdb.ErrNotFound { + if errors.Is(err, roomdb.ErrNotFound) { + errMsg := fmt.Errorf("ssb http auth: client isn't a member: %w", err) return weberrors.ErrForbidden{Details: errMsg} } - return errMsg + return err } payload.ClientID = client diff --git a/web/handlers/auth_test.go b/web/handlers/auth_test.go index 9252e8b..666449e 100644 --- a/web/handlers/auth_test.go +++ b/web/handlers/auth_test.go @@ -8,7 +8,6 @@ import ( "encoding/base64" "fmt" "net/http" - "net/http/cookiejar" "net/url" "strings" "testing" @@ -21,7 +20,6 @@ import ( "github.com/ssb-ngi-pointer/go-ssb-room/internal/maybemod/keys" "github.com/ssb-ngi-pointer/go-ssb-room/internal/signinwithssb" "github.com/ssb-ngi-pointer/go-ssb-room/roomdb" - "github.com/ssb-ngi-pointer/go-ssb-room/web" "github.com/ssb-ngi-pointer/go-ssb-room/web/router" "github.com/ssb-ngi-pointer/go-ssb-room/web/webassert" refs "go.mindeco.de/ssb-refs" @@ -29,30 +27,32 @@ import ( func TestRestricted(t *testing.T) { ts := setup(t) - a := assert.New(t) testURLs := []string{ - "/admin/admin", - "/admin/admin/", + "/admin/", + "/admin/anything/", } - for _, turl := range testURLs { + for _, tstr := range testURLs { + turl, err := url.Parse(tstr) + if err != nil { + t.Fatal(err) + } html, resp := ts.Client.GetHTML(turl) - a.Equal(http.StatusUnauthorized, resp.Code, "wrong HTTP status code for %q", turl) + a.Equal(http.StatusForbidden, resp.Code, "wrong HTTP status code for %q", turl) found := html.Find("h1").Text() - a.Equal("Error #401 - Unauthorized", found, "wrong error message code for %q", turl) + a.Equal("Error #403 - Forbidden", found, "wrong error message code for %q", turl) } } func TestLoginForm(t *testing.T) { ts := setup(t) + a := assert.New(t) - a, r := assert.New(t), require.New(t) + url := ts.URLTo(router.AuthFallbackLogin) - url, err := ts.Router.Get(router.AuthFallbackLogin).URL() - r.Nil(err) - html, resp := ts.Client.GetHTML(url.String()) + html, resp := ts.Client.GetHTML(url) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") webassert.Localized(t, html, []webassert.LocalizedElement{ @@ -63,24 +63,15 @@ func TestLoginForm(t *testing.T) { func TestFallbackAuth(t *testing.T) { ts := setup(t) - a, r := assert.New(t), require.New(t) + a := assert.New(t) - // very cheap "browser" client session - jar, err := cookiejar.New(nil) - r.NoError(err) + signInFormURL := ts.URLTo(router.AuthFallbackLogin) - signInFormURL, err := ts.Router.Get(router.AuthFallbackLogin).URL() - r.Nil(err) - signInFormURL.Host = "localhost" - signInFormURL.Scheme = "https" - - doc, resp := ts.Client.GetHTML(signInFormURL.String()) + doc, resp := ts.Client.GetHTML(signInFormURL) a.Equal(http.StatusOK, resp.Code) csrfCookie := resp.Result().Cookies() - a.Len(csrfCookie, 1, "should have one cookie for CSRF protection validation") - - jar.SetCookies(signInFormURL, csrfCookie) + a.True(len(csrfCookie) > 0, "should have one cookie for CSRF protection validation") passwordForm := doc.Find("#password-fallback") webassert.CSRFTokenPresent(t, passwordForm) @@ -102,53 +93,21 @@ func TestFallbackAuth(t *testing.T) { } ts.AuthFallbackDB.CheckReturns(int64(23), nil) - signInURL, err := ts.Router.Get(router.AuthFallbackFinalize).URL() - r.Nil(err) + signInURL := ts.URLTo(router.AuthFallbackFinalize) - signInURL.Host = "localhost" - signInURL.Scheme = "https" - - var csrfCookieHeader = http.Header(map[string][]string{}) + var csrfCookieHeader = make(http.Header) csrfCookieHeader.Set("Referer", "https://localhost") - cs := jar.Cookies(signInURL) - r.Len(cs, 1, "expecting one cookie for csrf") - theCookie := cs[0].String() - a.NotEqual("", theCookie, "should have a new cookie") - csrfCookieHeader.Set("Cookie", theCookie) ts.Client.SetHeaders(csrfCookieHeader) - resp = ts.Client.PostForm(signInURL.String(), loginVals) + resp = ts.Client.PostForm(signInURL, loginVals) a.Equal(http.StatusSeeOther, resp.Code, "wrong HTTP status code for sign in") a.Equal(1, ts.AuthFallbackDB.CheckCallCount()) - sessionCookie := resp.Result().Cookies() - jar.SetCookies(signInURL, sessionCookie) - // now request the protected dashboard page - dashboardURL, err := ts.Router.Get(router.AdminDashboard).URL() - r.Nil(err) - dashboardURL.Host = "localhost" - dashboardURL.Scheme = "https" + dashboardURL := ts.URLTo(router.AdminDashboard) - var sessionHeader = http.Header(map[string][]string{}) - cs = jar.Cookies(dashboardURL) - // TODO: why doesnt this return the csrf cookie?! - // r.Len(cs, 2, "expecting one cookie!") - for _, c := range cs { - theCookie := c.String() - a.NotEqual("", theCookie, "should have a new cookie") - sessionHeader.Add("Cookie", theCookie) - } - - durl := dashboardURL.String() - t.Log(durl) - - // update headers - ts.Client.ClearHeaders() - ts.Client.SetHeaders(sessionHeader) - - html, resp := ts.Client.GetHTML(durl) + html, resp := ts.Client.GetHTML(dashboardURL) if !a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code for dashboard") { t.Log(html.Find("body").Text()) } @@ -160,7 +119,7 @@ func TestFallbackAuth(t *testing.T) { testRef := refs.FeedRef{Algo: "test", ID: bytes.Repeat([]byte{0}, 16)} ts.RoomState.AddEndpoint(testRef, nil) - html, resp = ts.Client.GetHTML(durl) + html, resp = ts.Client.GetHTML(dashboardURL) if !a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") { t.Log(html.Find("body").Text()) } @@ -171,7 +130,7 @@ func TestFallbackAuth(t *testing.T) { testRef2 := refs.FeedRef{Algo: "test", ID: bytes.Repeat([]byte{1}, 16)} ts.RoomState.AddEndpoint(testRef2, nil) - html, resp = ts.Client.GetHTML(durl) + html, resp = ts.Client.GetHTML(dashboardURL) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") webassert.Localized(t, html, []webassert.LocalizedElement{ @@ -192,17 +151,13 @@ func TestAuthWithSSBClientInitNotConnected(t *testing.T) { cc := signinwithssb.GenerateChallenge() - urlTo := web.NewURLTo(ts.Router) - - signInStartURL := urlTo(router.AuthWithSSBLogin, + signInStartURL := ts.URLTo(router.AuthWithSSBLogin, "cid", client.Feed.Ref(), "cc", cc, ) r.NotNil(signInStartURL) - - t.Log(signInStartURL.String()) - doc, resp := ts.Client.GetHTML(signInStartURL.String()) - a.Equal(http.StatusInternalServerError, resp.Code) // TODO: StatusForbidden + doc, resp := ts.Client.GetHTML(signInStartURL) + a.Equal(http.StatusForbidden, resp.Code) webassert.Localized(t, doc, []webassert.LocalizedElement{ // {"#welcome", "AuthWithSSBWelcome"}, @@ -223,17 +178,15 @@ func TestAuthWithSSBClientInitNotAllowed(t *testing.T) { cc := signinwithssb.GenerateChallenge() - urlTo := web.NewURLTo(ts.Router) - - signInStartURL := urlTo(router.AuthWithSSBLogin, + signInStartURL := ts.URLTo(router.AuthWithSSBLogin, "cid", client.Feed.Ref(), "cc", cc, ) r.NotNil(signInStartURL) - t.Log(signInStartURL.String()) - doc, resp := ts.Client.GetHTML(signInStartURL.String()) + doc, resp := ts.Client.GetHTML(signInStartURL) a.Equal(http.StatusForbidden, resp.Code) + t.Log(resp.Body.String()) webassert.Localized(t, doc, []webassert.LocalizedElement{ // {"#welcome", "AuthWithSSBWelcome"}, @@ -278,10 +231,6 @@ func TestAuthWithSSBClientInitHasClient(t *testing.T) { ts := setup(t) a, r := assert.New(t), require.New(t) - // very cheap "browser" client session - jar, err := cookiejar.New(nil) - r.NoError(err) - // the request to be signed later var payload signinwithssb.ClientPayload payload.ServerID = ts.NetworkInfo.RoomID @@ -339,8 +288,8 @@ func TestAuthWithSSBClientInitHasClient(t *testing.T) { payload.ClientChallenge = cc // prepare the url - urlTo := web.NewURLTo(ts.Router) - signInStartURL := urlTo(router.AuthWithSSBLogin, + + signInStartURL := ts.URLTo(router.AuthWithSSBLogin, "cid", client.Feed.Ref(), "cc", cc, ) @@ -350,11 +299,10 @@ func TestAuthWithSSBClientInitHasClient(t *testing.T) { r.NotNil(signInStartURL) t.Log(signInStartURL.String()) - doc, resp := ts.Client.GetHTML(signInStartURL.String()) + doc, resp := ts.Client.GetHTML(signInStartURL) a.Equal(http.StatusTemporaryRedirect, resp.Code) - dashboardURL, err := ts.Router.Get(router.AdminDashboard).URL() - r.Nil(err) + dashboardURL := ts.URLTo(router.AdminDashboard) a.Equal(dashboardURL.Path, resp.Header().Get("Location")) webassert.Localized(t, doc, []webassert.LocalizedElement{ @@ -373,31 +321,8 @@ func TestAuthWithSSBClientInitHasClient(t *testing.T) { // check that we have a new cookie sessionCookie := resp.Result().Cookies() r.True(len(sessionCookie) > 0, "expecting one cookie!") - jar.SetCookies(signInStartURL, sessionCookie) - // now request the protected dashboard page - dashboardURL.Host = "localhost" - dashboardURL.Scheme = "https" - - // load the cookie for the dashboard - cs := jar.Cookies(dashboardURL) - r.True(len(cs) > 0, "expecting one cookie!") - - var sessionHeader = http.Header(map[string][]string{}) - for _, c := range cs { - theCookie := c.String() - a.NotEqual("", theCookie, "should have a new cookie") - sessionHeader.Add("Cookie", theCookie) - } - - durl := dashboardURL.String() - t.Log(durl) - - // update headers - ts.Client.ClearHeaders() - ts.Client.SetHeaders(sessionHeader) - - html, resp := ts.Client.GetHTML(durl) + html, resp := ts.Client.GetHTML(dashboardURL) if !a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code for dashboard") { t.Log(html.Find("body").Text()) } @@ -421,13 +346,13 @@ func TestAuthWithSSBServerInitHappyPath(t *testing.T) { ts.MembersDB.GetByFeedReturns(testMember, nil) // prepare the url - urlTo := web.NewURLTo(ts.Router) - signInStartURL := urlTo(router.AuthWithSSBLogin, + + signInStartURL := ts.URLTo(router.AuthWithSSBLogin, "cid", client.Feed.Ref(), ) r.NotNil(signInStartURL) - html, resp := ts.Client.GetHTML(signInStartURL.String()) + html, resp := ts.Client.GetHTML(signInStartURL) if !a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code for dashboard") { t.Log(html.Find("body").Text()) } @@ -476,8 +401,8 @@ func TestAuthWithSSBServerInitHappyPath(t *testing.T) { }() // start reading sse - sseURL := urlTo(router.AuthWithSSBServerEvents, "sc", serverChallenge) - resp = ts.Client.GetBody(sseURL.String()) + sseURL := ts.URLTo(router.AuthWithSSBServerEvents, "sc", serverChallenge) + resp = ts.Client.GetBody(sseURL) a.Equal(http.StatusOK, resp.Result().StatusCode) // check contents of sse channel @@ -492,38 +417,14 @@ func TestAuthWithSSBServerInitHappyPath(t *testing.T) { // use the token and go to /withssb/finalize and get a cookie // (this happens in the browser engine via login-events.js) - finalizeURL := urlTo(router.AuthWithSSBFinalize, "token", testToken) - finalizeURL.Host = "localhost" - finalizeURL.Scheme = "https" + finalizeURL := ts.URLTo(router.AuthWithSSBFinalize, "token", testToken) - resp = ts.Client.GetBody(finalizeURL.String()) - - csrfCookie := resp.Result().Cookies() - a.Len(csrfCookie, 2, "csrf and session cookie") - - // very cheap "browser" client session - jar, err := cookiejar.New(nil) - r.NoError(err) - jar.SetCookies(finalizeURL, csrfCookie) + resp = ts.Client.GetBody(finalizeURL) // now request the protected dashboard page - dashboardURL, err := ts.Router.Get(router.AdminDashboard).URL() - r.Nil(err) - dashboardURL.Host = "localhost" - dashboardURL.Scheme = "https" + dashboardURL := ts.URLTo(router.AdminDashboard) - // load the cookie for the dashboard - cs := jar.Cookies(dashboardURL) - r.True(len(cs) > 0, "expecting one cookie!") - var sessionHeader = http.Header(map[string][]string{}) - for _, c := range cs { - theCookie := c.String() - a.NotEqual("", theCookie, "should have a new cookie") - sessionHeader.Add("Cookie", theCookie) - } - ts.Client.SetHeaders(sessionHeader) - - html, resp = ts.Client.GetHTML(dashboardURL.String()) + html, resp = ts.Client.GetHTML(dashboardURL) if !a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code for dashboard") { t.Log(html.Find("body").Text()) } @@ -547,13 +448,12 @@ func TestAuthWithSSBServerInitWrongSolution(t *testing.T) { ts.MembersDB.GetByFeedReturns(testMember, nil) // prepare the url - urlTo := web.NewURLTo(ts.Router) - signInStartURL := urlTo(router.AuthWithSSBLogin, + signInStartURL := ts.URLTo(router.AuthWithSSBLogin, "cid", client.Feed.Ref(), ) r.NotNil(signInStartURL) - html, resp := ts.Client.GetHTML(signInStartURL.String()) + html, resp := ts.Client.GetHTML(signInStartURL) if !a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code for dashboard") { t.Log(html.Find("body").Text()) } @@ -571,8 +471,8 @@ func TestAuthWithSSBServerInitWrongSolution(t *testing.T) { }() // start reading sse - sseURL := urlTo(router.AuthWithSSBServerEvents, "sc", serverChallenge) - resp = ts.Client.GetBody(sseURL.String()) + sseURL := ts.URLTo(router.AuthWithSSBServerEvents, "sc", serverChallenge) + resp = ts.Client.GetBody(sseURL) a.Equal(http.StatusOK, resp.Result().StatusCode) // check contents of sse channel @@ -585,7 +485,7 @@ func TestAuthWithSSBServerInitWrongSolution(t *testing.T) { a.True(strings.Contains(sseBody, "event: failed\n"), "success event") // use an invalid token - finalizeURL := urlTo(router.AuthWithSSBFinalize, "token", "wrong") - resp = ts.Client.GetBody(finalizeURL.String()) + finalizeURL := ts.URLTo(router.AuthWithSSBFinalize, "token", "wrong") + resp = ts.Client.GetBody(finalizeURL) a.Equal(http.StatusForbidden, resp.Result().StatusCode) } diff --git a/web/handlers/basic_test.go b/web/handlers/basic_test.go index 6da1e7f..e6e8465 100644 --- a/web/handlers/basic_test.go +++ b/web/handlers/basic_test.go @@ -4,10 +4,10 @@ package handlers import ( "net/http" + "net/url" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/ssb-ngi-pointer/go-ssb-room/web/router" "github.com/ssb-ngi-pointer/go-ssb-room/web/webassert" @@ -17,11 +17,10 @@ func TestIndex(t *testing.T) { ts := setup(t) a := assert.New(t) - r := require.New(t) - url, err := ts.Router.Get(router.CompleteIndex).URL() - r.Nil(err) - html, resp := ts.Client.GetHTML(url.String()) + url := ts.URLTo(router.CompleteIndex) + + html, resp := ts.Client.GetHTML(url) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") webassert.Localized(t, html, []webassert.LocalizedElement{ {"h1", "Default Notice Title"}, @@ -36,11 +35,10 @@ func TestAbout(t *testing.T) { ts := setup(t) a := assert.New(t) - r := require.New(t) - url, err := ts.Router.Get(router.CompleteAbout).URL() - r.Nil(err) - html, resp := ts.Client.GetHTML(url.String()) + url := ts.URLTo(router.CompleteAbout) + + html, resp := ts.Client.GetHTML(url) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code") found := html.Find("h1").Text() a.Equal("The about page", found) @@ -51,7 +49,10 @@ func TestNotFound(t *testing.T) { a := assert.New(t) - html, resp := ts.Client.GetHTML("/some/random/ASDKLANZXC") + url404, err := url.Parse("/some/random/ASDKLANZXC") + a.NoError(err) + + html, resp := ts.Client.GetHTML(url404) a.Equal(http.StatusNotFound, resp.Code, "wrong HTTP status code") found := html.Find("h1").Text() a.Equal("Error #404 - Not Found", found) diff --git a/web/handlers/http.go b/web/handlers/http.go index 9ba3d44..97f87b6 100644 --- a/web/handlers/http.go +++ b/web/handlers/http.go @@ -85,26 +85,14 @@ func New( roomsAuth.HTMLTemplates, admin.HTMLTemplates, ) - allTheTemplates = append(allTheTemplates, "error.tmpl") - r, err := render.New(web.Templates, + renderOpts := []render.Option{ render.SetLogger(logger), - render.BaseTemplates("base.tmpl", "menu.tmpl"), + render.BaseTemplates("base.tmpl", "menu.tmpl", "flashes.tmpl"), render.AddTemplates(allTheTemplates...), - // render.ErrorTemplate(), render.SetErrorHandler(eh.Handle), render.FuncMap(web.TemplateFuncs(m)), - // TODO: move these to the i18n helper pkg - render.InjectTemplateFunc("i18npl", func(r *http.Request) interface{} { - loc := i18n.LocalizerFromRequest(locHelper, r) - return loc.LocalizePlurals - }), - render.InjectTemplateFunc("i18n", func(r *http.Request) interface{} { - loc := i18n.LocalizerFromRequest(locHelper, r) - return loc.LocalizeSimple - }), - render.InjectTemplateFunc("current_page_is", func(r *http.Request) interface{} { return func(routeName string) bool { route := m.Get(routeName) @@ -146,7 +134,10 @@ func New( }), render.InjectTemplateFunc("is_logged_in", members.TemplateHelper()), - ) + } + renderOpts = append(renderOpts, locHelper.GetRenderFuncs()...) + + r, err := render.New(web.Templates, renderOpts...) if err != nil { return nil, fmt.Errorf("web Handler: failed to create renderer: %w", err) } @@ -165,6 +156,8 @@ func New( }, } + flashHelper := weberrs.NewFlashHelper(cookieStore, locHelper) + authWithPassword, err := auth.NewHandler(dbs.AuthFallback, auth.SetStore(cookieStore), auth.SetErrorHandler(func(rw http.ResponseWriter, req *http.Request, err error, code int) { @@ -238,6 +231,7 @@ func New( netInfo.Domain, r, roomState, + flashHelper, admin.Databases{ Aliases: dbs.Aliases, Config: dbs.Config, diff --git a/web/handlers/invites_test.go b/web/handlers/invites_test.go index 6df0c2c..614c1ca 100644 --- a/web/handlers/invites_test.go +++ b/web/handlers/invites_test.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "encoding/json" "net/http" - "net/http/cookiejar" "net/url" "strings" "testing" @@ -14,7 +13,6 @@ import ( "github.com/stretchr/testify/require" "github.com/ssb-ngi-pointer/go-ssb-room/roomdb" - "github.com/ssb-ngi-pointer/go-ssb-room/web" weberrors "github.com/ssb-ngi-pointer/go-ssb-room/web/errors" "github.com/ssb-ngi-pointer/go-ssb-room/web/router" "github.com/ssb-ngi-pointer/go-ssb-room/web/webassert" @@ -24,22 +22,18 @@ import ( func TestInviteShowAcceptForm(t *testing.T) { ts := setup(t) - urlTo := web.NewURLTo(ts.Router) - t.Run("token doesnt exist", func(t *testing.T) { a, r := assert.New(t), require.New(t) testToken := "nonexistant-test-token" - acceptURL404 := urlTo(router.CompleteInviteFacade, "token", testToken) + acceptURL404 := ts.URLTo(router.CompleteInviteFacade, "token", testToken) r.NotNil(acceptURL404) // prep the mocked db for http:404 ts.InvitesDB.GetByTokenReturns(roomdb.Invite{}, roomdb.ErrNotFound) // request the form - acceptForm := acceptURL404.String() - t.Log(acceptForm) - doc, resp := ts.Client.GetHTML(acceptForm) + doc, resp := ts.Client.GetHTML(acceptURL404) // 500 until https://github.com/ssb-ngi-pointer/go-ssb-room/issues/66 is fixed a.Equal(http.StatusInternalServerError, resp.Code) @@ -62,17 +56,14 @@ func TestInviteShowAcceptForm(t *testing.T) { a, r := assert.New(t), require.New(t) testToken := "existing-test-token" - facadeURL := urlTo(router.CompleteInviteFacade, "token", testToken) - r.NotNil(facadeURL) + validAcceptURL := ts.URLTo(router.CompleteInviteFacade, "token", testToken) // prep the mocked db for http:200 fakeExistingInvite := roomdb.Invite{ID: 1234} ts.InvitesDB.GetByTokenReturns(fakeExistingInvite, nil) // request the form - validAcceptForm := facadeURL.String() - t.Log(validAcceptForm) - doc, resp := ts.Client.GetHTML(validAcceptForm) + doc, resp := ts.Client.GetHTML(validAcceptURL) a.Equal(http.StatusOK, resp.Code) // check database calls @@ -91,9 +82,10 @@ func TestInviteShowAcceptForm(t *testing.T) { a.True(ok) // Fallback URL in data-href-fallback - fallbackURL := urlTo(router.CompleteInviteFacadeFallback, "token", testToken) + fallbackURL := ts.URLTo(router.CompleteInviteFacadeFallback, "token", testToken) + want := fallbackURL.Path + "?" + fallbackURL.RawQuery joinDataHrefFallback, ok := doc.Find("#join-room-uri").Attr("data-href-fallback") - a.Equal(fallbackURL.String(), joinDataHrefFallback) + a.Equal(want, joinDataHrefFallback) a.True(ok) // ssb-uri in data-href @@ -112,10 +104,7 @@ func TestInviteShowAcceptForm(t *testing.T) { a.Equal(testToken, inviteParam) postTo := params.Get("postTo") - expectedConsumeInviteURL, err := ts.Router.Get(router.CompleteInviteConsume).URL() - expectedConsumeInviteURL.Scheme = "https" - expectedConsumeInviteURL.Host = "localhost" - r.NoError(err) + expectedConsumeInviteURL := ts.URLTo(router.CompleteInviteConsume) a.Equal(expectedConsumeInviteURL.String(), postTo) }) } @@ -123,41 +112,30 @@ func TestInviteShowAcceptForm(t *testing.T) { func TestInviteConsumeInviteHTTP(t *testing.T) { ts := setup(t) a, r := assert.New(t), require.New(t) - urlTo := web.NewURLTo(ts.Router) testToken := "existing-test-token-2" - validAcceptURL := urlTo(router.CompleteInviteInsertID, "token", testToken) - r.NotNil(validAcceptURL) - validAcceptURL.Host = "localhost" - validAcceptURL.Scheme = "https" - + validAcceptURL := ts.URLTo(router.CompleteInviteInsertID, "token", testToken) testInvite := roomdb.Invite{ID: 4321} ts.InvitesDB.GetByTokenReturns(testInvite, nil) // request the form (for a valid csrf token) - validAcceptForm := validAcceptURL.String() - t.Log(validAcceptForm) - doc, resp := ts.Client.GetHTML(validAcceptForm) + doc, resp := ts.Client.GetHTML(validAcceptURL) a.Equal(http.StatusOK, resp.Code) form := doc.Find("form#consume") r.Equal(1, form.Length()) + consumeInviteURLString, has := form.Attr("action") + a.True(has, "form should have an action attribute") + expectedConsumeInviteURL := ts.URLTo(router.CompleteInviteConsume) + a.Equal(expectedConsumeInviteURL.Path, consumeInviteURLString) + webassert.CSRFTokenPresent(t, form) webassert.ElementsInForm(t, form, []webassert.FormElement{ {Name: "invite", Type: "hidden", Value: testToken}, {Name: "id", Type: "text"}, }) - // we need a functional jar to unpack the Set-Cookie response for the csrf token - jar, err := cookiejar.New(nil) - r.NoError(err) - - // update the jar - csrfCookie := resp.Result().Cookies() - a.Len(csrfCookie, 1, "should have one cookie for CSRF protection validation") - jar.SetCookies(validAcceptURL, csrfCookie) - // get the corresponding token from the page csrfTokenElem := doc.Find("input[name='gorilla.csrf.Token']") a.Equal(1, csrfTokenElem.Length()) @@ -179,31 +157,18 @@ func TestInviteConsumeInviteHTTP(t *testing.T) { } // construct the consume endpoint url - consumeInviteURLString, has := form.Attr("action") - a.True(has, "form should have an action attribute") - expectedConsumeInviteURL, err := ts.Router.Get(router.CompleteInviteConsume).URL() - r.Nil(err) - a.Equal(expectedConsumeInviteURL.String(), consumeInviteURLString) - consumeInviteURL, err := url.Parse(consumeInviteURLString) - r.Nil(err) - consumeInviteURL.Host = "localhost" - consumeInviteURL.Scheme = "https" + consumeInviteURL := ts.URLTo(router.CompleteInviteConsume) - // construct the header with Referer and Cookie + // construct the header with the Referer or csrf check var csrfCookieHeader = http.Header(map[string][]string{}) csrfCookieHeader.Set("Referer", "https://localhost") - cs := jar.Cookies(consumeInviteURL) - r.Len(cs, 1, "expecting one cookie for csrf") - theCookie := cs[0].String() - a.NotEqual("", theCookie, "should have a new cookie") - csrfCookieHeader.Set("Cookie", theCookie) ts.Client.SetHeaders(csrfCookieHeader) // prepare the mock ts.InvitesDB.ConsumeReturns(testInvite, nil) // send the POST - resp = ts.Client.PostForm(consumeInviteURL.String(), consumeVals) + resp = ts.Client.PostForm(consumeInviteURL, consumeVals) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code for sign in") // check how consume was called @@ -216,11 +181,8 @@ func TestInviteConsumeInviteHTTP(t *testing.T) { func TestInviteConsumeInviteJSON(t *testing.T) { ts := setup(t) a, r := assert.New(t), require.New(t) - urlTo := web.NewURLTo(ts.Router) testToken := "existing-test-token-2" - validAcceptURL := urlTo(router.CompleteInviteFacade, "token", testToken) - r.NotNil(validAcceptURL) testInvite := roomdb.Invite{ID: 4321} ts.InvitesDB.GetByTokenReturns(testInvite, nil) @@ -236,14 +198,14 @@ func TestInviteConsumeInviteJSON(t *testing.T) { consume.ID = testNewMember // construct the consume endpoint url - consumeInviteURL := urlTo(router.CompleteInviteConsume) + consumeInviteURL := ts.URLTo(router.CompleteInviteConsume) r.NotNil(consumeInviteURL) // prepare the mock ts.InvitesDB.ConsumeReturns(testInvite, nil) // send the POST - resp := ts.Client.SendJSON(consumeInviteURL.String(), consume) + resp := ts.Client.SendJSON(consumeInviteURL, consume) a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code for sign in") // check how consume was called diff --git a/web/handlers/notices_test.go b/web/handlers/notices_test.go index 3881b8d..c79f954 100644 --- a/web/handlers/notices_test.go +++ b/web/handlers/notices_test.go @@ -2,15 +2,12 @@ package handlers import ( "net/http" - "net/http/cookiejar" "net/url" "testing" "github.com/ssb-ngi-pointer/go-ssb-room/roomdb" - "github.com/ssb-ngi-pointer/go-ssb-room/web" "github.com/ssb-ngi-pointer/go-ssb-room/web/router" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) // TestNoticeSmokeTest ensures the most basic notice serving is working @@ -25,7 +22,8 @@ func TestNoticeSmokeTest(t *testing.T) { ts.NoticeDB.GetByIDReturns(noticeData, nil) - html, res := ts.Client.GetHTML("/notice/show?id=1") + noticeURL := ts.URLTo(router.CompleteNoticeShow, "id", "1") + html, res := ts.Client.GetHTML(noticeURL) a.Equal(http.StatusOK, res.Code, "wrong HTTP status code") a.Equal("Welcome!", html.Find("title").Text()) } @@ -47,7 +45,8 @@ Hello world! ts.NoticeDB.GetByIDReturns(noticeData, nil) - html, res := ts.Client.GetHTML("/notice/show?id=1") + noticeURL := ts.URLTo(router.CompleteNoticeShow, "id", "1") + html, res := ts.Client.GetHTML(noticeURL) a.Equal(http.StatusOK, res.Code, "wrong HTTP status code") a.Equal("Welcome!", html.Find("title").Text()) a.Equal("The loveliest of rooms is here", html.Find("h2").Text()) @@ -57,9 +56,7 @@ Hello world! // then we log in as an admin and see that the edit links are there. func TestNoticesEditButtonVisible(t *testing.T) { ts := setup(t) - a, r := assert.New(t), require.New(t) - - urlTo := web.NewURLTo(ts.Router) + a := assert.New(t) ts.AliasesDB.ResolveReturns(roomdb.Alias{}, roomdb.ErrNotFound) @@ -71,12 +68,11 @@ func TestNoticesEditButtonVisible(t *testing.T) { ts.NoticeDB.GetByIDReturns(noticeData, nil) // first, we confirm that the button is missing when not logged in - noticeURL := urlTo(router.CompleteNoticeShow, "id", 42) - noticeURL.Host = "localhost" - noticeURL.Scheme = "https" + noticeURL := ts.URLTo(router.CompleteNoticeShow, "id", 42) + editButtonSelector := `#edit-notice` - doc, resp := ts.Client.GetHTML(noticeURL.String()) + doc, resp := ts.Client.GetHTML(noticeURL) a.Equal(http.StatusOK, resp.Code) // empty selection <=> we have no link @@ -84,25 +80,17 @@ func TestNoticesEditButtonVisible(t *testing.T) { // start preparing the ~login dance~ // TODO: make this code reusable and share it with the login => /dashboard http:200 test - // cookiejar: a very cheap client session // TODO: refactor login dance for re-use in testing / across tests - jar, err := cookiejar.New(nil) - r.NoError(err) // when dealing with cookies we also need to have an Host and URL-Scheme // for the jar to save and load them correctly - formEndpoint := urlTo(router.AuthFallbackLogin) - r.NotNil(formEndpoint) - formEndpoint.Host = "localhost" - formEndpoint.Scheme = "https" + formEndpoint := ts.URLTo(router.AuthFallbackLogin) - doc, resp = ts.Client.GetHTML(formEndpoint.String()) + doc, resp = ts.Client.GetHTML(formEndpoint) a.Equal(http.StatusOK, resp.Code) csrfCookie := resp.Result().Cookies() - a.Len(csrfCookie, 1, "should have one cookie for CSRF protection validation") - t.Log(csrfCookie) - jar.SetCookies(formEndpoint, csrfCookie) + a.True(len(csrfCookie) > 0, "should have one cookie for CSRF protection validation") csrfTokenElem := doc.Find("input[type=hidden]") a.Equal(1, csrfTokenElem.Length()) @@ -125,44 +113,19 @@ func TestNoticesEditButtonVisible(t *testing.T) { ts.AuthFallbackDB.CheckReturns(testUser.ID, nil) ts.MembersDB.GetByIDReturns(testUser, nil) - postEndpoint, err := ts.Router.Get(router.AuthFallbackFinalize).URL() - r.Nil(err) - postEndpoint.Host = "localhost" - postEndpoint.Scheme = "https" + postEndpoint := ts.URLTo(router.AuthFallbackFinalize) // construct HTTP Header with Referer and Cookie - var csrfCookieHeader = http.Header(map[string][]string{}) - csrfCookieHeader.Set("Referer", "https://localhost") - cs := jar.Cookies(postEndpoint) - r.Len(cs, 1, "expecting one cookie for csrf") - theCookie := cs[0].String() - a.NotEqual("", theCookie, "should have a new cookie") - csrfCookieHeader.Set("Cookie", theCookie) - ts.Client.SetHeaders(csrfCookieHeader) + var refererHeader = make(http.Header) + refererHeader.Set("Referer", "https://localhost") + ts.Client.SetHeaders(refererHeader) - resp = ts.Client.PostForm(postEndpoint.String(), loginVals) + resp = ts.Client.PostForm(postEndpoint, loginVals) a.Equal(http.StatusSeeOther, resp.Code, "wrong HTTP status code for sign in") - sessionCookie := resp.Result().Cookies() - jar.SetCookies(postEndpoint, sessionCookie) - - var sessionHeader = http.Header(map[string][]string{}) - cs = jar.Cookies(noticeURL) - // TODO: why doesnt this return the csrf cookie?! - r.NotEqual(len(cs), 0, "expecting a cookie!") - for _, c := range cs { - theCookie := c.String() - a.NotEqual("", theCookie, "should have a new cookie") - sessionHeader.Add("Cookie", theCookie) - } - - // update headers - ts.Client.ClearHeaders() - ts.Client.SetHeaders(sessionHeader) - cnt := ts.MembersDB.GetByIDCallCount() // now we are logged in, anchor tag should be there - doc, resp = ts.Client.GetHTML(noticeURL.String()) + doc, resp = ts.Client.GetHTML(noticeURL) a.Equal(http.StatusOK, resp.Code) a.Equal(cnt+1, ts.MembersDB.GetByIDCallCount()) diff --git a/web/handlers/setup_test.go b/web/handlers/setup_test.go index 89bcc96..4f777f5 100644 --- a/web/handlers/setup_test.go +++ b/web/handlers/setup_test.go @@ -5,15 +5,12 @@ package handlers import ( "bytes" "context" - "fmt" - "io/ioutil" "net/http" + "net/url" "os" "path/filepath" "testing" - "github.com/BurntSushi/toml" - "github.com/gorilla/mux" "go.mindeco.de/http/tester" "go.mindeco.de/logging/logtest" @@ -24,7 +21,8 @@ import ( "github.com/ssb-ngi-pointer/go-ssb-room/roomdb" "github.com/ssb-ngi-pointer/go-ssb-room/roomdb/mockdb" "github.com/ssb-ngi-pointer/go-ssb-room/roomstate" - "github.com/ssb-ngi-pointer/go-ssb-room/web/i18n" + "github.com/ssb-ngi-pointer/go-ssb-room/web" + "github.com/ssb-ngi-pointer/go-ssb-room/web/i18n/i18ntesting" "github.com/ssb-ngi-pointer/go-ssb-room/web/router" refs "go.mindeco.de/ssb-refs" ) @@ -32,7 +30,7 @@ import ( type testSession struct { Mux *http.ServeMux Client *tester.Tester - Router *mux.Router + URLTo web.URLMaker // mocked dbs AuthDB *mockdb.FakeAuthWithSSBService @@ -55,8 +53,6 @@ type testSession struct { NetworkInfo network.ServerEndpointDetails } -var testI18N = justTheKeys() - func setup(t *testing.T) *testSession { t.Parallel() var ts testSession @@ -65,12 +61,7 @@ func setup(t *testing.T) *testSession { os.RemoveAll(testRepoPath) testRepo := repo.New(testRepoPath) - testOverride := filepath.Join(testRepo.GetPath("i18n"), "active.en.toml") - os.MkdirAll(filepath.Dir(testOverride), 0700) - err := ioutil.WriteFile(testOverride, testI18N, 0700) - if err != nil { - t.Fatal(err) - } + i18ntesting.WriteReplacement(t) ts.AuthDB = new(mockdb.FakeAuthWithSSBService) ts.AuthFallbackDB = new(mockdb.FakeAuthFallbackService) @@ -107,7 +98,18 @@ func setup(t *testing.T) *testSession { ctx := context.TODO() ts.RoomState = roomstate.NewManager(ctx, log) - ts.Router = router.CompleteApp() + // instantiate the urlTo helper (constructs urls for us!) + // the cookiejar in our custom http/tester needs a non-empty domain and scheme + urlTo := web.NewURLTo(router.CompleteApp()) + ts.URLTo = func(name string, vals ...interface{}) *url.URL { + testURL := urlTo(name, vals...) + if testURL == nil { + t.Fatalf("no URL for %s", name) + } + testURL.Host = ts.NetworkInfo.Domain + testURL.Scheme = "https" // fake + return testURL + } ts.SignalBridge = signinwithssb.NewSignalBridge() @@ -140,55 +142,3 @@ func setup(t *testing.T) *testSession { return &ts } - -// auto generate from defaults a list of Label = "Label" -// must keep order of input intact -// (at least all the globals before starting with nested plurals) -// also replaces 'one' and 'other' in plurals -func justTheKeys() []byte { - f, err := i18n.Defaults.Open("active.en.toml") - if err != nil { - panic(err) - } - justAMap := make(map[string]interface{}) - md, err := toml.DecodeReader(f, &justAMap) - if err != nil { - panic(err) - } - - var buf = &bytes.Buffer{} - - // if we don't produce the same order as the input - // (in go maps are ALWAYS random access when ranged over) - // nested keys (such as plural form) will mess up the global level... - for _, k := range md.Keys() { - key := k.String() - val, has := justAMap[key] - if !has { - // fmt.Println("i18n test warning:", key, "not unmarshaled") - continue - } - - switch tv := val.(type) { - - case string: - fmt.Fprintf(buf, "%s = \"%s\"\n", key, key) - - case map[string]interface{}: - // fmt.Println("i18n test warning: custom map for ", key) - - fmt.Fprintf(buf, "\n[%s]\n", key) - // replace "one" and "other" keys - // with Label and LabelPlural - tv["one"] = key + "Singular" - tv["other"] = key + "Plural" - toml.NewEncoder(buf).Encode(tv) - fmt.Fprintln(buf) - - default: - panic(fmt.Sprintf("unhandled toml structure under %s: %T\n", key, val)) - } - } - - return buf.Bytes() -} diff --git a/web/i18n/defaults/active.en.toml b/web/i18n/defaults/active.en.toml index be535ec..0e1389e 100644 --- a/web/i18n/defaults/active.en.toml +++ b/web/i18n/defaults/active.en.toml @@ -88,6 +88,7 @@ Settings = "Settings" AdminDeniedKeysTitle = "Banned" AdminDeniedKeysWelcome = "This page can be used to ban SSB IDs so that they can't access the room any more." AdminDeniedKeysAdd = "Add" +AdminDeniedKeysAdded = "Key was added to the list." AdminDeniedKeysRemove = "Remove" AdminDeniedKeysComment = "Comment" AdminDeniedKeysCommentDescription = "The person who added this ban, added the following comment" @@ -112,6 +113,10 @@ AdminMemberDetailsAliasRevoke = "Revoke" AdminMemberDetailsExclusion = "Exclusion from this room" AdminMemberDetailsRemove = "Remove member" +AdminMemberAdded = "Member added successfully." +AdminMemberUpdated = "Member updated." +AdminMemberRemoved = "Member removed." + # invite dashboard ################## diff --git a/web/i18n/helper.go b/web/i18n/helper.go index f4e71f1..20fb32f 100644 --- a/web/i18n/helper.go +++ b/web/i18n/helper.go @@ -15,6 +15,7 @@ import ( "github.com/BurntSushi/toml" "github.com/nicksnyder/go-i18n/v2/i18n" + "go.mindeco.de/http/render" "golang.org/x/text/language" "github.com/ssb-ngi-pointer/go-ssb-room/internal/repo" @@ -116,11 +117,26 @@ func New(r repo.Interface) (*Helper, error) { return &Helper{bundle: bundle}, nil } +func (h Helper) GetRenderFuncs() []render.Option { + var opts = []render.Option{ + render.InjectTemplateFunc("i18npl", func(r *http.Request) interface{} { + loc := h.FromRequest(r) + return loc.LocalizePlurals + }), + + render.InjectTemplateFunc("i18n", func(r *http.Request) interface{} { + loc := h.FromRequest(r) + return loc.LocalizeSimple + }), + } + return opts +} + type Localizer struct { loc *i18n.Localizer } -func (h Helper) NewLocalizer(lang string, accept ...string) *Localizer { +func (h Helper) newLocalizer(lang string, accept ...string) *Localizer { var langs = []string{lang} langs = append(langs, accept...) var l Localizer @@ -128,6 +144,15 @@ func (h Helper) NewLocalizer(lang string, accept ...string) *Localizer { return &l } +// FromRequest returns a new Localizer for the passed helper, +// using form value 'lang' and Accept-Language http header from the passed request. +// TODO: user settings/cookie values? +func (h Helper) FromRequest(r *http.Request) *Localizer { + lang := r.FormValue("lang") + accept := r.Header.Get("Accept-Language") + return h.newLocalizer(lang, accept) +} + func (l Localizer) LocalizeSimple(messageID string) string { msg, err := l.loc.Localize(&i18n.LocalizeConfig{ MessageID: messageID, @@ -178,12 +203,3 @@ func (l Localizer) LocalizePluralsWithData(messageID string, pluralCount int, tp panic(fmt.Sprintf("i18n/error: failed to localize label %s: %s", messageID, err)) } - -// LocalizerFromRequest returns a new Localizer for the passed helper, -// using form value 'lang' and Accept-Language http header from the passed request. -// TODO: user settings/cookie values? -func LocalizerFromRequest(helper *Helper, r *http.Request) *Localizer { - lang := r.FormValue("lang") - accept := r.Header.Get("Accept-Language") - return helper.NewLocalizer(lang, accept) -} diff --git a/web/i18n/i18ntesting/testing.go b/web/i18n/i18ntesting/testing.go new file mode 100644 index 0000000..30a7819 --- /dev/null +++ b/web/i18n/i18ntesting/testing.go @@ -0,0 +1,82 @@ +package i18ntesting + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/BurntSushi/toml" + + "github.com/ssb-ngi-pointer/go-ssb-room/internal/repo" + "github.com/ssb-ngi-pointer/go-ssb-room/web/i18n" +) + +// justTheKeys auto generates from the defaults a list of Label = "Label" +// must keep order of input intact +// (at least all the globals before starting with nested plurals) +// also replaces 'one' and 'other' in plurals +func justTheKeys(t *testing.T) []byte { + f, err := i18n.Defaults.Open("active.en.toml") + if err != nil { + t.Fatal(err) + } + justAMap := make(map[string]interface{}) + md, err := toml.DecodeReader(f, &justAMap) + if err != nil { + t.Fatal(err) + } + + var buf = &bytes.Buffer{} + + // if we don't produce the same order as the input + // (in go maps are ALWAYS random access when ranged over) + // nested keys (such as plural form) will mess up the global level... + for _, k := range md.Keys() { + key := k.String() + val, has := justAMap[key] + if !has { + // fmt.Println("i18n test warning:", key, "not unmarshaled") + continue + } + + switch tv := val.(type) { + + case string: + fmt.Fprintf(buf, "%s = \"%s\"\n", key, key) + + case map[string]interface{}: + // fmt.Println("i18n test warning: custom map for ", key) + + fmt.Fprintf(buf, "\n[%s]\n", key) + // replace "one" and "other" keys + // with Label and LabelPlural + tv["one"] = key + "Singular" + tv["other"] = key + "Plural" + toml.NewEncoder(buf).Encode(tv) + fmt.Fprintln(buf) + + default: + t.Fatalf("unhandled toml structure under %s: %T\n", key, val) + } + } + + return buf.Bytes() +} + +func WriteReplacement(t *testing.T) { + r := repo.New(filepath.Join("testrun", t.Name())) + + testOverride := filepath.Join(r.GetPath("i18n"), "active.en.toml") + t.Log(testOverride) + os.MkdirAll(filepath.Dir(testOverride), 0700) + + content := justTheKeys(t) + + err := ioutil.WriteFile(testOverride, content, 0700) + if err != nil { + t.Fatal(err) + } +} diff --git a/web/templates/admin/denied-keys.tmpl b/web/templates/admin/denied-keys.tmpl index 76cc09b..803b757 100644 --- a/web/templates/admin/denied-keys.tmpl +++ b/web/templates/admin/denied-keys.tmpl @@ -6,6 +6,8 @@

{{i18n "AdminDeniedKeysWelcome"}}

+ {{ template "flashes" . }} +

{{i18n "AdminMembersWelcome"}}

+ {{ template "flashes" . }} +
+{{range .Flashes}} +
  • {{.Message}}
  • +{{end}} + +{{end}} +{{ end }} \ No newline at end of file diff --git a/web/utils.go b/web/utils.go index 08654bc..66183bb 100644 --- a/web/utils.go +++ b/web/utils.go @@ -34,12 +34,14 @@ func TemplateFuncs(m *mux.Router) template.FuncMap { } } +type URLMaker func(string, ...interface{}) *url.URL + // NewURLTo returns a template helper function for a router. // It is usually called with one parameter, the route name, which should be defined in the router package. // If it's called with more then one, it has a to be a pair of two values. (1, 3, 5, 7, etc.) // The first value of such a pair is the placeholder name in the router (i.e. in '/our/routes/{id:[0-9]+}/test' it would be id ) // and the 2nd value is the actual value that should be put in place of the placeholder. -func NewURLTo(appRouter *mux.Router) func(string, ...interface{}) *url.URL { +func NewURLTo(appRouter *mux.Router) URLMaker { l := logging.Logger("helper.URLTo") // TOOD: inject in a scoped way return func(routeName string, ps ...interface{}) *url.URL { route := appRouter.Get(routeName)