add privacy mode and role tests (fixes #185)

* denied/remove
* denied/add
* invites/revoke
* members/remove
* notices/edit
* notices/add

also:
* add members.CheckAction helper
* fix muxrpc abort bug and update to v2.0.5
* strictly use SeeOther not 307 (fixes #149)
This commit is contained in:
Henry 2021-05-13 09:57:45 +02:00 committed by Henry
parent 98c5a59348
commit 385b98a3a1
24 changed files with 798 additions and 251 deletions

2
go.mod
View File

@ -30,7 +30,7 @@ require (
github.com/volatiletech/sqlboiler-sqlite3 v0.0.0-20210314195744-a1c697a68aef // indirect
github.com/volatiletech/sqlboiler/v4 v4.5.0
github.com/volatiletech/strmangle v0.0.1
go.cryptoscope.co/muxrpc/v2 v2.0.4
go.cryptoscope.co/muxrpc/v2 v2.0.5
go.cryptoscope.co/netwrap v0.1.1
go.cryptoscope.co/secretstream v1.2.2
go.mindeco.de v1.11.0

2
go.sum
View File

@ -508,6 +508,8 @@ go.cryptoscope.co/muxrpc/v2 v2.0.2 h1:UdlGHY+EEYZpJz5HWnWz0r34pULYxJHfFTeqLvv+7s
go.cryptoscope.co/muxrpc/v2 v2.0.2/go.mod h1:MgaeojIkWY3lLuoNw1mlMT3b3jiZwOj/fgsoGZp/VNA=
go.cryptoscope.co/muxrpc/v2 v2.0.4 h1:NLN//zPt9UKFelnPNBh3fefrQ/TFylCflPZhKiDtK3U=
go.cryptoscope.co/muxrpc/v2 v2.0.4/go.mod h1:MgaeojIkWY3lLuoNw1mlMT3b3jiZwOj/fgsoGZp/VNA=
go.cryptoscope.co/muxrpc/v2 v2.0.5 h1:yZEp49Qx4KWF/DD+Hg+6vPrl4cjlcH0Ex5kzaz0XpMA=
go.cryptoscope.co/muxrpc/v2 v2.0.5/go.mod h1:MgaeojIkWY3lLuoNw1mlMT3b3jiZwOj/fgsoGZp/VNA=
go.cryptoscope.co/netwrap v0.1.0/go.mod h1:7zcYswCa4CT+ct54e9uH9+IIbYYETEMHKDNpzl8Ukew=
go.cryptoscope.co/netwrap v0.1.1 h1:JLzzGKEvrUrkKzu3iM0DhpHmt+L/gYqmpcf1lJMUyFs=
go.cryptoscope.co/netwrap v0.1.1/go.mod h1:7zcYswCa4CT+ct54e9uH9+IIbYYETEMHKDNpzl8Ukew=

View File

@ -111,7 +111,7 @@ func TestAliasRegister(t *testing.T) {
r.Error(err)
var callErr *muxrpc.CallError
r.True(errors.As(err, &callErr), "expected a call error: %T", err)
r.True(errors.As(err, &callErr), "expected a call error: %T -- %s", err, err)
r.Equal(`alias ("bob") is already taken`, callErr.Message)
for _, bot := range theBots {

View File

@ -83,6 +83,8 @@ const (
ModeRestricted
)
var AllPrivacyModes = []PrivacyMode{ModeOpen, ModeCommunity, ModeRestricted}
// Implements the SQL marshaling interfaces (Scanner for Scan & Valuer for Value) for PrivacyMode
// Scan implements https://pkg.go.dev/database/sql#Scanner to read integers into a privacy mode

View File

@ -37,7 +37,7 @@ type Server struct {
closers multicloser.Closer
closed bool
closedMu sync.Mutex
closedMu *sync.Mutex
closeErr error
Network network.Network
@ -87,6 +87,7 @@ func New(
opts ...Option,
) (*Server, error) {
var s Server
s.closedMu = new(sync.Mutex)
s.Members = membersdb
s.DeniedKeys = deniedkeysdb

View File

@ -67,13 +67,15 @@ func (h aliasesHandler) revoke(rw http.ResponseWriter, req *http.Request) {
return
}
defer http.Redirect(rw, req, redirectToMembers, http.StatusSeeOther)
aliasName := req.FormValue("name")
ctx := req.Context()
aliasEntry, err := h.db.Resolve(ctx, aliasName)
if err != nil {
h.r.Error(rw, req, http.StatusBadRequest, err)
h.flashes.AddError(rw, req, err)
return
}
@ -81,24 +83,22 @@ func (h aliasesHandler) revoke(rw http.ResponseWriter, req *http.Request) {
currentMember := members.FromContext(ctx)
if currentMember == nil {
err := weberrors.ErrForbidden{Details: fmt.Errorf("not an member")}
h.r.Error(rw, req, http.StatusInternalServerError, err)
h.flashes.AddError(rw, req, err)
return
}
// ensure own alias or admin
if !aliasEntry.Feed.Equal(&currentMember.PubKey) && currentMember.Role != roomdb.RoleAdmin {
err := weberrors.ErrForbidden{Details: fmt.Errorf("not your alias or not an admin")}
h.r.Error(rw, req, http.StatusInternalServerError, err)
h.flashes.AddError(rw, req, err)
return
}
status := http.StatusTemporaryRedirect // TODO: should be SeeOther because it's method POST coming in
err = h.db.Revoke(ctx, aliasName)
if err != nil {
h.flashes.AddError(rw, req, err)
} else {
h.flashes.AddMessage(rw, req, "AdminMemberDetailsAliasRevoked")
return
}
http.Redirect(rw, req, redirectToMembers, status)
h.flashes.AddMessage(rw, req, "AdminMemberDetailsAliasRevoked")
}

View File

@ -63,7 +63,7 @@ func TestAliasesRevoke(t *testing.T) {
addVals := url.Values{"name": []string{"the-name"}}
rec := ts.Client.PostForm(urlRevoke, addVals)
a.Equal(http.StatusTemporaryRedirect, rec.Code)
a.Equal(http.StatusSeeOther, rec.Code)
a.Equal(overviewURL.Path, rec.Header().Get("Location"))
a.True(len(rec.Result().Cookies()) > 0, "got a cookie")
@ -77,7 +77,7 @@ func TestAliasesRevoke(t *testing.T) {
ts.AliasesDB.RevokeReturns(roomdb.ErrNotFound)
addVals = url.Values{"name": []string{"nope"}}
rec = ts.Client.PostForm(urlRevoke, addVals)
a.Equal(http.StatusTemporaryRedirect, rec.Code)
a.Equal(http.StatusSeeOther, rec.Code)
a.Equal(overviewURL.Path, rec.Header().Get("Location"))
a.True(len(rec.Result().Cookies()) > 0, "got a cookie")

View File

@ -12,6 +12,7 @@ import (
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
weberrors "github.com/ssb-ngi-pointer/go-ssb-room/web/errors"
"github.com/ssb-ngi-pointer/go-ssb-room/web/members"
refs "go.mindeco.de/ssb-refs"
)
@ -20,7 +21,8 @@ type deniedKeysHandler struct {
flashes *weberrors.FlashHelper
db roomdb.DeniedKeysService
db roomdb.DeniedKeysService
roomCfg roomdb.RoomConfig
}
const redirectToDeniedKeys = "/admin/denied"
@ -29,6 +31,15 @@ func (h deniedKeysHandler) add(w http.ResponseWriter, req *http.Request) {
// always redirect
defer http.Redirect(w, req, redirectToDeniedKeys, http.StatusSeeOther)
ctx := req.Context()
_, err := members.CheckAllowed(ctx, h.roomCfg, members.ActionChangeDeniedKeys)
if err != nil {
err := weberrors.ErrNotAuthorized
h.flashes.AddError(w, req, err)
return
}
if req.Method != "POST" {
err := weberrors.ErrBadRequest{Where: "HTTP Method", Details: fmt.Errorf("expected POST not %s", req.Method)}
h.flashes.AddError(w, req, err)
@ -109,7 +120,16 @@ func (h deniedKeysHandler) remove(rw http.ResponseWriter, req *http.Request) {
// always redirect
defer http.Redirect(rw, req, redirectToDeniedKeys, http.StatusSeeOther)
err := req.ParseForm()
ctx := req.Context()
_, err := members.CheckAllowed(ctx, h.roomCfg, members.ActionChangeDeniedKeys)
if err != nil {
err := weberrors.ErrNotAuthorized
h.flashes.AddError(rw, req, err)
return
}
err = req.ParseForm()
if err != nil {
err = weberrors.ErrBadRequest{Where: "Form data", Details: err}
h.flashes.AddError(rw, req, err)
@ -123,7 +143,7 @@ func (h deniedKeysHandler) remove(rw http.ResponseWriter, req *http.Request) {
return
}
err = h.db.RemoveID(req.Context(), id)
err = h.db.RemoveID(ctx, id)
if err != nil {
h.flashes.AddError(rw, req, err)
} else {

View File

@ -10,6 +10,7 @@ import (
"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/router"
@ -33,10 +34,12 @@ func TestDeniedKeysEmpty(t *testing.T) {
})
}
func TestDeniedKeysDisabledInterface(t *testing.T) {
func TestDeniedKeysAddDisabledInterface(t *testing.T) {
ts := newSession(t)
a := assert.New(t)
ts.ConfigDB.GetPrivacyModeReturns(roomdb.ModeRestricted, nil)
listURL := ts.URLTo(router.AdminDeniedKeysOverview)
ts.User = roomdb.Member{
@ -44,6 +47,7 @@ func TestDeniedKeysDisabledInterface(t *testing.T) {
Role: roomdb.RoleAdmin,
}
// check basic form
html, resp := ts.Client.GetHTML(listURL)
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
@ -65,27 +69,39 @@ func TestDeniedKeysDisabledInterface(t *testing.T) {
{Name: "comment", Type: "text"},
})
// create assertion helpers
newKey := "@x7iOLUcq3o+sjGeAnipvWeGzfuYgrXl8L4LYlxIhwDc=.ed25519"
addVals := url.Values{
"comment": []string{"some comment"},
// just any key that looks valid
"pub_key": []string{newKey},
addVals := url.Values{"comment": []string{"some comment"}, "pub_key": []string{newKey}}
totalAddCallCount := 0
checkCanPostNewEntry := func(t *testing.T, shouldWork bool) {
a := assert.New(t)
r := require.New(t)
rec := ts.Client.PostForm(addURL, addVals)
a.Equal(http.StatusSeeOther, rec.Code)
a.Equal(listURL.Path, rec.Header().Get("Location"))
var wantedLabel = "ErrorNotAuthorized"
if shouldWork {
totalAddCallCount++
wantedLabel = "AdminDeniedKeysAdded"
// require call count to not panic
r.Equal(totalAddCallCount, ts.DeniedKeysDB.AddCallCount())
_, addedKey, addedComment := ts.DeniedKeysDB.AddArgsForCall(totalAddCallCount - 1)
a.Equal(newKey, addedKey.Ref())
a.Equal("some comment", addedComment)
} else {
r.Equal(totalAddCallCount, ts.DeniedKeysDB.AddCallCount())
}
webassert.HasFlashMessages(t, ts.Client, listURL, wantedLabel)
}
rec := ts.Client.PostForm(addURL, addVals)
a.Equal(http.StatusSeeOther, rec.Code)
overview := ts.URLTo(router.AdminDeniedKeysOverview)
a.Equal(overview.Path, rec.Header().Get("Location"))
webassert.HasFlashMessages(t, ts.Client, overview, "AdminDeniedKeysAdded")
a.Equal(1, ts.DeniedKeysDB.AddCallCount())
_, addedKey, addedComment := ts.DeniedKeysDB.AddArgsForCall(0)
a.Equal(newKey, addedKey.Ref())
a.Equal("some comment", addedComment)
/* Verify that the inputs are visible/hidden depending on user roles */
checkInputsAreDisabled := func(shouldBeDisabled bool) {
html, resp = ts.Client.GetHTML(listURL)
checkInputsAreDisabled := func(t *testing.T, shouldBeDisabled bool) {
a := assert.New(t)
html, resp := ts.Client.GetHTML(listURL)
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
inputContainer := html.Find("#denied-keys-input-container")
a.Equal(1, inputContainer.Length())
@ -94,26 +110,41 @@ func TestDeniedKeysDisabledInterface(t *testing.T) {
a.Equal(3, inputs.Length())
inputs.Each(func(i int, el *goquery.Selection) {
_, disabled := el.Attr("disabled")
a.Equal(shouldBeDisabled, disabled)
name, _ := el.Attr("name")
a.Equal(shouldBeDisabled, disabled, "found diabled tag on element %q", name)
})
}
// verify that inputs are enabled for RoleAdmin
checkInputsAreDisabled(false)
// verify that inputs are enabled for RoleModerator
ts.User = roomdb.Member{
ID: 9001,
Role: roomdb.RoleModerator,
/* test various restricted mode with the roles member, mod, admin */
for _, mode := range roomdb.AllPrivacyModes {
t.Run(mode.String(), func(t *testing.T) {
ts.ConfigDB.GetPrivacyModeReturns(mode, nil)
t.Run("role:member", func(t *testing.T) {
ts.User = roomdb.Member{
ID: 7331,
Role: roomdb.RoleMember,
}
checkInputsAreDisabled(t, mode != roomdb.ModeCommunity)
checkCanPostNewEntry(t, mode == roomdb.ModeCommunity)
})
t.Run("role:moderator", func(t *testing.T) {
ts.User = roomdb.Member{
ID: 9001,
Role: roomdb.RoleModerator,
}
checkInputsAreDisabled(t, false)
checkCanPostNewEntry(t, true)
})
t.Run("role:admin", func(t *testing.T) {
ts.User = roomdb.Member{
ID: 1234,
Role: roomdb.RoleAdmin,
}
checkInputsAreDisabled(t, false)
checkCanPostNewEntry(t, true)
})
})
}
checkInputsAreDisabled(false)
// verify that inputs are disabled for RoleMember
ts.User = roomdb.Member{
ID: 7331,
Role: roomdb.RoleMember,
}
checkInputsAreDisabled(true)
}
func TestDeniedKeysAdd(t *testing.T) {
@ -295,3 +326,93 @@ func TestDeniedKeysRemove(t *testing.T) {
a.Equal(overview.Path, rec.Header().Get("Location"))
webassert.HasFlashMessages(t, ts.Client, overview, "ErrorNotFound")
}
func TestDeniedKeysRemovalRights(t *testing.T) {
ts := newSession(t)
// check disabled remove button on list page
ts.DeniedKeysDB.ListReturns([]roomdb.ListEntry{
{ID: 666, PubKey: generatePubKey(), Comment: "test-entry"},
}, nil)
urlRemoveConfirm := ts.URLTo(router.AdminDeniedKeysRemoveConfirm, "id", "666").String()
listURL := ts.URLTo(router.AdminDeniedKeysOverview)
listShouldShowTheRemoveButtonAsWorking := func(shouldWork bool) func(t *testing.T) {
return func(t *testing.T) {
a := assert.New(t)
html, resp := ts.Client.GetHTML(listURL)
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
linktToConfirm := html.Find("#theList li a")
a.Equal(1, linktToConfirm.Length())
// pubkey, comment, submit button
linkText, has := linktToConfirm.Attr("href")
a.True(has, "anchor should have a href in any case")
if shouldWork {
a.Equal(urlRemoveConfirm, linkText)
} else {
a.True(linktToConfirm.HasClass("line-through"), "should have strikethrogh class")
a.Equal("#", linkText)
}
}
}
// check who can actually remove
ts.DeniedKeysDB.RemoveIDReturns(nil)
urlRemove := ts.URLTo(router.AdminDeniedKeysRemove)
removeVals := url.Values{"id": []string{"666"}}
totalRemoveCallCount := 0
removeFromListShouldWork := func(works bool) func(t *testing.T) {
return func(t *testing.T) {
a := assert.New(t)
r := require.New(t)
rec := ts.Client.PostForm(urlRemove, removeVals)
a.Equal(http.StatusSeeOther, rec.Code, "unexpected exit code %s", rec.Result().Status)
if works {
totalRemoveCallCount++
_, userID := ts.DeniedKeysDB.RemoveIDArgsForCall(totalRemoveCallCount - 1)
a.EqualValues(666, userID)
webassert.HasFlashMessages(t, ts.Client, listURL, "AdminDeniedKeysRemoved")
} else {
webassert.HasFlashMessages(t, ts.Client, listURL, "ErrorNotAuthorized")
}
r.Equal(totalRemoveCallCount, ts.DeniedKeysDB.RemoveIDCallCount())
}
}
// the users who will execute the action
memberUser := roomdb.Member{
ID: 7331,
Role: roomdb.RoleMember,
PubKey: generatePubKey(),
}
modUser := roomdb.Member{
ID: 9001,
Role: roomdb.RoleModerator,
PubKey: generatePubKey(),
}
adminUser := roomdb.Member{
ID: 1337,
Role: roomdb.RoleAdmin,
PubKey: generatePubKey(),
}
/* test various restricted mode with the roles member, mod, admin */
for _, mode := range roomdb.AllPrivacyModes {
ts.ConfigDB.GetPrivacyModeReturns(mode, nil)
// members can remove entries only in community mode
ts.User = memberUser
t.Run(mode.String()+" member sees link working", listShouldShowTheRemoveButtonAsWorking(mode == roomdb.ModeCommunity))
t.Run(mode.String()+" member can actually remove", removeFromListShouldWork(mode == roomdb.ModeCommunity))
// mods & admins can always invite
ts.User = modUser
t.Run(mode.String()+" mod sees link working", listShouldShowTheRemoveButtonAsWorking(true))
t.Run(mode.String()+" mod can actually remove", removeFromListShouldWork(true))
ts.User = adminUser
t.Run(mode.String()+" admin sees link working", listShouldShowTheRemoveButtonAsWorking(true))
t.Run(mode.String()+" admin sees link working", removeFromListShouldWork(true))
}
}

View File

@ -22,7 +22,6 @@ import (
"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/i18n"
"github.com/ssb-ngi-pointer/go-ssb-room/web/members"
"github.com/ssb-ngi-pointer/go-ssb-room/web/router"
)
@ -114,6 +113,8 @@ func Handler(
flashes: fh,
db: dbs.DeniedKeys,
roomCfg: dbs.Config,
}
mux.HandleFunc("/denied", r.HTML("admin/denied-keys.tmpl", dh.overview))
mux.HandleFunc("/denied/add", dh.add)
@ -129,6 +130,7 @@ func Handler(
db: dbs.Members,
fallbackAuthDB: dbs.AuthFallback,
roomCfgDB: dbs.Config,
}
mux.HandleFunc("/member", r.HTML("admin/member.tmpl", mh.details))
mux.HandleFunc("/members", r.HTML("admin/member-list.tmpl", mh.overview))
@ -156,16 +158,17 @@ func Handler(
var nh = noticeHandler{
r: r,
urlTo: urlTo,
flashes: fh,
noticeDB: dbs.Notices,
pinnedDB: dbs.PinnedNotices,
roomCfg: dbs.Config,
}
onlyModsAndAdmins := checkMemberRole(r.Error, roomdb.RoleModerator, roomdb.RoleAdmin)
mux.Handle("/notice/edit", onlyModsAndAdmins(r.HTML("admin/notice-edit.tmpl", nh.edit)))
mux.Handle("/notice/translation/draft", onlyModsAndAdmins(r.HTML("admin/notice-edit.tmpl", nh.draftTranslation)))
mux.Handle("/notice/translation/add", onlyModsAndAdmins(http.HandlerFunc(nh.addTranslation)))
mux.Handle("/notice/save", onlyModsAndAdmins(http.HandlerFunc(nh.save)))
mux.Handle("/notice/edit", r.HTML("admin/notice-edit.tmpl", nh.edit))
mux.Handle("/notice/translation/draft", r.HTML("admin/notice-edit.tmpl", nh.draftTranslation))
mux.Handle("/notice/translation/add", http.HandlerFunc(nh.addTranslation))
mux.Handle("/notice/save", http.HandlerFunc(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) {
@ -175,35 +178,6 @@ func Handler(
return customStripPrefix("/admin", mux)
}
func checkMemberRole(eh render.ErrorHandlerFunc, roles ...roomdb.Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
currentMember := members.FromContext(req.Context())
if currentMember == nil {
err := weberrors.ErrRedirect{Path: "/admin/dashboard", Reason: fmt.Errorf("not an member")}
eh(rw, req, http.StatusSeeOther, err)
return
}
var roleMatched = false
for _, r := range roles {
if currentMember.Role == r {
roleMatched = true
break
}
}
if !roleMatched {
err := weberrors.ErrRedirect{Path: "/admin/dashboard", Reason: weberrors.ErrNotAuthorized}
eh(rw, req, http.StatusSeeOther, err)
return
}
next.ServeHTTP(rw, req)
})
}
}
// how many elements does a paginated page have by default
const defaultPageSize = 20

View File

@ -55,36 +55,19 @@ func (h invitesHandler) create(w http.ResponseWriter, req *http.Request) (interf
if req.Method != "POST" {
return nil, weberrors.ErrBadRequest{Where: "HTTP Method", Details: fmt.Errorf("expected POST not %s", req.Method)}
}
if err := req.ParseForm(); err != nil {
return nil, weberrors.ErrBadRequest{Where: "Form data", Details: err}
}
member := members.FromContext(req.Context())
if member == nil {
return nil, weberrors.ErrNotAuthorized
}
pm, err := h.config.GetPrivacyMode(req.Context())
ctx := req.Context()
member, err := members.CheckAllowed(ctx, h.config, members.ActionInviteMember)
if err != nil {
return nil, err
}
/* We want to check:
* 1. the room's privacy mode
* 2. the role of the member trying to create the invite
* and deny unallowed requests (e.g. member creating invite in ModeRestricted)
*/
switch pm {
case roomdb.ModeOpen:
case roomdb.ModeCommunity:
if member.Role == roomdb.RoleUnknown {
return nil, weberrors.ErrNotAuthorized
}
case roomdb.ModeRestricted:
if member.Role == roomdb.RoleMember || member.Role == roomdb.RoleUnknown {
return nil, weberrors.ErrNotAuthorized
}
}
token, err := h.db.Create(req.Context(), member.ID)
token, err := h.db.Create(ctx, member.ID)
if err != nil {
return nil, err
}
@ -122,32 +105,37 @@ func (h invitesHandler) revokeConfirm(rw http.ResponseWriter, req *http.Request)
const redirectToInvites = "/admin/invites"
func (h invitesHandler) revoke(rw http.ResponseWriter, req *http.Request) {
// always redirect
defer http.Redirect(rw, req, redirectToInvites, http.StatusSeeOther)
ctx := req.Context()
if _, err := members.CheckAllowed(ctx, h.config, members.ActionInviteMember); err != nil {
h.flashes.AddError(rw, req, err)
return
}
err := req.ParseForm()
if err != nil {
err = weberrors.ErrBadRequest{Where: "Form data", Details: err}
// TODO "flash" errors
http.Redirect(rw, req, redirectToInvites, http.StatusFound)
h.flashes.AddError(rw, req, 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, redirectToInvites, http.StatusFound)
h.flashes.AddError(rw, req, err)
return
}
status := http.StatusFound
err = h.db.Revoke(req.Context(), id)
err = h.db.Revoke(ctx, id)
if err != nil {
if !errors.Is(err, roomdb.ErrNotFound) {
// TODO "flash" errors
h.r.Error(rw, req, http.StatusInternalServerError, err)
return
if errors.Is(err, roomdb.ErrNotFound) {
err = weberrors.ErrNotFound{What: "invite"}
}
status = http.StatusNotFound
h.flashes.AddError(rw, req, err)
} else {
h.flashes.AddMessage(rw, req, "InviteRevoked")
}
http.Redirect(rw, req, redirectToInvites, status)
}

View File

@ -81,11 +81,13 @@ func TestInvitesOverview(t *testing.T) {
Role: roomdb.RoleAdmin,
}
testInviteButtonDisabled(false)
ts.User = roomdb.Member{
ID: 7331,
Role: roomdb.RoleModerator,
}
testInviteButtonDisabled(false)
ts.User = roomdb.Member{
ID: 9001,
Role: roomdb.RoleMember,
@ -99,11 +101,13 @@ func TestInvitesOverview(t *testing.T) {
Role: roomdb.RoleAdmin,
}
testInviteButtonDisabled(false)
ts.User = roomdb.Member{
ID: 7331,
Role: roomdb.RoleModerator,
}
testInviteButtonDisabled(false)
ts.User = roomdb.Member{
ID: 9001,
Role: roomdb.RoleMember,
@ -139,7 +143,7 @@ func TestInvitesCreateForm(t *testing.T) {
a.Equal(addURL.String(), action)
}
func TestInvitesCreate(t *testing.T) {
func TestInvitesCreateAndRevoke(t *testing.T) {
ts := newSession(t)
a := assert.New(t)
r := require.New(t)
@ -150,7 +154,10 @@ func TestInvitesCreate(t *testing.T) {
ts.InvitesDB.CreateReturns(testInvite, nil)
totalCreateCallCount := 0
createInviteShouldWork := func(works bool) *httptest.ResponseRecorder {
createInviteShouldWork := func(t *testing.T, works bool) *httptest.ResponseRecorder {
a := assert.New(t)
r := require.New(t)
rec := ts.Client.PostForm(urlCreate, url.Values{})
if works {
totalCreateCallCount += 1
@ -165,10 +172,31 @@ func TestInvitesCreate(t *testing.T) {
return rec
}
rec := createInviteShouldWork(true)
totalRevokeCallCount := 0
urlRevoke := ts.URLTo(router.AdminInvitesRevoke)
canRevokeInvite := func(t *testing.T, canRevoke bool) {
a := assert.New(t)
r := require.New(t)
rec := ts.Client.PostForm(urlRevoke, url.Values{
"id": []string{"666"},
})
a.Equal(http.StatusSeeOther, rec.Code)
if canRevoke {
totalRevokeCallCount += 1
r.Equal(totalRevokeCallCount, ts.InvitesDB.RevokeCallCount())
_, userID := ts.InvitesDB.RevokeArgsForCall(totalRevokeCallCount - 1)
a.EqualValues(666, userID)
} else {
r.Equal(totalRevokeCallCount, ts.InvitesDB.RevokeCallCount())
}
return
}
rec := createInviteShouldWork(t, true)
doc, err := goquery.NewDocumentFromReader(rec.Body)
require.NoError(t, err, "failed to parse response")
r.NoError(err, "failed to parse response")
webassert.Localized(t, doc, []webassert.LocalizedElement{
{"title", "AdminInviteCreatedTitle"},
@ -197,16 +225,29 @@ func TestInvitesCreate(t *testing.T) {
}
/* test invite creation under various restricted mode with the roles member, mod, admin */
modes := []roomdb.PrivacyMode{roomdb.ModeRestricted, roomdb.ModeCommunity}
for _, mode := range modes {
ts.ConfigDB.GetPrivacyModeReturns(mode, nil)
ts.User = memberUser
// members can only invite in community rooms
createInviteShouldWork(mode == roomdb.ModeCommunity)
// mods & admins can always invite
ts.User = modUser
createInviteShouldWork(true)
ts.User = adminUser
createInviteShouldWork(true)
for _, mode := range roomdb.AllPrivacyModes {
t.Run(mode.String(), func(t *testing.T) {
ts.ConfigDB.GetPrivacyModeReturns(mode, nil)
// members can only invite in community rooms
t.Run("members", func(t *testing.T) {
ts.User = memberUser
createInviteShouldWork(t, mode != roomdb.ModeRestricted)
canRevokeInvite(t, mode != roomdb.ModeRestricted)
})
// mods & admins can always invite
t.Run("mods", func(t *testing.T) {
ts.User = modUser
createInviteShouldWork(t, true)
canRevokeInvite(t, true)
})
t.Run("admins", func(t *testing.T) {
ts.User = adminUser
createInviteShouldWork(t, true)
canRevokeInvite(t, true)
})
})
}
}

View File

@ -29,6 +29,7 @@ type membersHandler struct {
db roomdb.MembersService
fallbackAuthDB roomdb.AuthFallbackService
roomCfgDB roomdb.RoomConfig
}
const redirectToMembers = "/admin/members"
@ -46,23 +47,23 @@ func (h membersHandler) add(w http.ResponseWriter, req *http.Request) {
return
}
defer http.Redirect(w, req, redirectToMembers, http.StatusSeeOther)
newEntry := req.Form.Get("pub_key")
newEntryParsed, err := refs.ParseFeedRef(newEntry)
if err != nil {
err = weberrors.ErrBadRequest{Where: "Public Key", Details: 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 {
h.flashes.AddError(w, req, err)
} else {
h.flashes.AddMessage(w, req, "AdminMemberAdded")
return
}
http.Redirect(w, req, redirectToMembers, http.StatusTemporaryRedirect)
h.flashes.AddMessage(w, req, "AdminMemberAdded")
}
func (h membersHandler) changeRole(w http.ResponseWriter, req *http.Request) {
@ -108,7 +109,7 @@ func (h membersHandler) changeRole(w http.ResponseWriter, req *http.Request) {
h.flashes.AddMessage(w, req, "AdminMemberUpdated")
memberDetailsURL := h.urlTo(router.AdminMemberDetails, "id", memberID).String()
http.Redirect(w, req, memberDetailsURL, http.StatusTemporaryRedirect)
http.Redirect(w, req, memberDetailsURL, http.StatusSeeOther)
}
func (h membersHandler) overview(rw http.ResponseWriter, req *http.Request) (interface{}, error) {
@ -191,6 +192,8 @@ func (h membersHandler) removeConfirm(rw http.ResponseWriter, req *http.Request)
}
func (h membersHandler) remove(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
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)
@ -203,22 +206,26 @@ func (h membersHandler) remove(rw http.ResponseWriter, req *http.Request) {
return
}
defer http.Redirect(rw, req, redirectToMembers, http.StatusSeeOther)
if _, err := members.CheckAllowed(ctx, h.roomCfgDB, members.ActionRemoveMember); err != nil {
h.flashes.AddError(rw, req, err)
return
}
id, err := strconv.ParseInt(req.FormValue("id"), 10, 64)
if err != nil {
err = weberrors.ErrBadRequest{Where: "ID", Details: err}
h.flashes.AddError(rw, req, err)
http.Redirect(rw, req, redirectToMembers, http.StatusTemporaryRedirect)
return
}
err = h.db.RemoveID(req.Context(), id)
err = h.db.RemoveID(ctx, id)
if err != nil {
h.flashes.AddError(rw, req, err)
} else {
h.flashes.AddMessage(rw, req, "AdminMemberRemoved")
}
http.Redirect(rw, req, redirectToMembers, http.StatusTemporaryRedirect)
}
func (h membersHandler) createPasswordResetToken(rw http.ResponseWriter, req *http.Request) (interface{}, error) {

View File

@ -70,7 +70,7 @@ func TestMembersAdd(t *testing.T) {
"pub_key": []string{newKey},
}
rec := ts.Client.PostForm(addURL, addVals)
a.Equal(http.StatusTemporaryRedirect, rec.Code)
a.Equal(http.StatusSeeOther, rec.Code)
a.Equal(1, ts.MembersDB.AddCallCount())
_, addedPubKey, addedRole := ts.MembersDB.AddArgsForCall(0)
@ -128,7 +128,7 @@ func TestMembersDontAddInvalid(t *testing.T) {
"pub_key": []string{newKey},
}
rec := ts.Client.PostForm(addURL, addVals)
a.Equal(http.StatusTemporaryRedirect, rec.Code)
a.Equal(http.StatusSeeOther, rec.Code)
a.Equal(0, ts.MembersDB.AddCallCount())
@ -262,36 +262,102 @@ func TestMemberDetails(t *testing.T) {
wantLink = ts.URLTo(router.AdminMembersRemoveConfirm, "id", 1)
a.Equal(wantLink.String(), removeLink)
testDisabledBehaviour := func(isElevated bool) {
html, resp := ts.Client.GetHTML(memberURL)
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
testChangeRoleDisabledBehaviour := func(t *testing.T, html *goquery.Document, canSee bool) {
a := assert.New(t)
// check for SSB ID
ssbID := html.Find("#ssb-id")
a.Equal(feedRef.Ref(), ssbID.Text())
// check for change-role dropdown
roleDropdown := html.Find("#change-role")
if isElevated {
if canSee {
a.Equal(1, roleDropdown.Length())
} else {
a.Equal(0, roleDropdown.Length())
}
}
testDisabledBehaviour(true)
/* Now: verify that moderators cannot make room settings changes */
ts.User = roomdb.Member{
ID: 7331,
Role: roomdb.RoleModerator,
}
testDisabledBehaviour(true)
testRemoveButtonHiddenBehavior := func(t *testing.T, html *goquery.Document, canSee bool) {
a := assert.New(t)
/* Finally: verify that members cannot make room settings changes */
ts.User = roomdb.Member{
ID: 9001,
Role: roomdb.RoleMember,
rmButton := html.Find("a#remove-member")
if canSee {
a.Equal(1, rmButton.Length())
} else {
a.Equal(0, rmButton.Length())
}
}
overviewURL := ts.URLTo(router.AdminMembersOverview)
removeURL := ts.URLTo(router.AdminMembersRemove)
totalRemoveCallCount := 0
testCanDoRemoveBehavior := func(t *testing.T, html *goquery.Document, canDo bool) {
a := assert.New(t)
resp := ts.Client.PostForm(removeURL, url.Values{"id": []string{"1"}})
a.Equal(http.StatusSeeOther, resp.Code, "unexpected status code")
var wantLabel string
if canDo {
totalRemoveCallCount++
wantLabel = "AdminMemberRemoved"
} else {
wantLabel = "ErrorNotAuthorized"
}
a.Equal(totalRemoveCallCount, ts.MembersDB.RemoveIDCallCount())
webassert.HasFlashMessages(t, ts.Client, overviewURL, wantLabel)
}
memberUser := roomdb.Member{
ID: 7331,
Role: roomdb.RoleMember,
PubKey: generatePubKey(),
}
modUser := roomdb.Member{
ID: 9001,
Role: roomdb.RoleModerator,
PubKey: generatePubKey(),
}
adminUser := roomdb.Member{
ID: 1337,
Role: roomdb.RoleAdmin,
PubKey: generatePubKey(),
}
for _, mode := range roomdb.AllPrivacyModes {
t.Run(mode.String(), func(t *testing.T) {
ts.ConfigDB.GetPrivacyModeReturns(mode, nil)
// members can only invite in community rooms
t.Run("members", func(t *testing.T) {
ts.User = memberUser
html, resp := ts.Client.GetHTML(memberURL)
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
testChangeRoleDisabledBehaviour(t, html, false)
testRemoveButtonHiddenBehavior(t, html, false)
testCanDoRemoveBehavior(t, html, false)
})
// mods & admins can always invite
t.Run("mods", func(t *testing.T) {
ts.User = modUser
html, resp := ts.Client.GetHTML(memberURL)
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
testChangeRoleDisabledBehaviour(t, html, true)
testRemoveButtonHiddenBehavior(t, html, true)
testCanDoRemoveBehavior(t, html, true)
})
t.Run("admins", func(t *testing.T) {
ts.User = adminUser
html, resp := ts.Client.GetHTML(memberURL)
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
testChangeRoleDisabledBehaviour(t, html, true)
testRemoveButtonHiddenBehavior(t, html, true)
testCanDoRemoveBehavior(t, html, true)
})
})
}
testDisabledBehaviour(false)
}
func TestMembersRemoveConfirmation(t *testing.T) {
@ -337,7 +403,7 @@ func TestMembersRemove(t *testing.T) {
addVals := url.Values{"id": []string{"666"}}
rec := ts.Client.PostForm(urlRemove, addVals)
a.Equal(http.StatusTemporaryRedirect, rec.Code)
a.Equal(http.StatusSeeOther, rec.Code)
a.Equal(1, ts.MembersDB.RemoveIDCallCount())
_, theID := ts.MembersDB.RemoveIDArgsForCall(0)
@ -355,7 +421,7 @@ func TestMembersRemove(t *testing.T) {
ts.MembersDB.RemoveIDReturns(roomdb.ErrNotFound)
addVals = url.Values{"id": []string{"667"}}
rec = ts.Client.PostForm(urlRemove, addVals)
a.Equal(http.StatusTemporaryRedirect, rec.Code)
a.Equal(http.StatusSeeOther, rec.Code)
// check flash message
res = rec.Result()

View File

@ -7,6 +7,8 @@ import (
"strconv"
"strings"
"github.com/ssb-ngi-pointer/go-ssb-room/web"
"github.com/ssb-ngi-pointer/go-ssb-room/web/members"
"github.com/ssb-ngi-pointer/go-ssb-room/web/router"
"github.com/gorilla/csrf"
@ -17,17 +19,24 @@ import (
)
type noticeHandler struct {
r *render.Renderer
r *render.Renderer
urlTo web.URLMaker
flashes *weberrors.FlashHelper
noticeDB roomdb.NoticesService
pinnedDB roomdb.PinnedNoticesService
roomCfg roomdb.RoomConfig
}
func (h noticeHandler) draftTranslation(rw http.ResponseWriter, req *http.Request) (interface{}, error) {
pinnedName := req.URL.Query().Get("name")
if _, err := members.CheckAllowed(req.Context(), h.roomCfg, members.ActionChangeNotice); err != nil {
h.flashes.AddError(rw, req, err)
noticesURL := h.urlTo(router.CompleteNoticeList)
http.Redirect(rw, req, noticesURL.String(), http.StatusSeeOther)
return nil, err
}
pinnedName := req.URL.Query().Get("name")
if !roomdb.PinnedNoticeName(pinnedName).Valid() {
return nil, weberrors.ErrBadRequest{Where: "pinnedName", Details: fmt.Errorf("invalid pinned notice name")}
}
@ -40,12 +49,7 @@ func (h noticeHandler) draftTranslation(rw http.ResponseWriter, req *http.Reques
}
func (h noticeHandler) addTranslation(rw http.ResponseWriter, req *http.Request) {
err := req.ParseForm()
if err != nil {
err = weberrors.ErrBadRequest{Where: "form data", Details: err}
h.r.Error(rw, req, http.StatusInternalServerError, err)
return
}
ctx := req.Context()
// reply with 405 error: Method not allowed
if req.Method != "POST" {
@ -54,10 +58,30 @@ func (h noticeHandler) addTranslation(rw http.ResponseWriter, req *http.Request)
return
}
err := req.ParseForm()
if err != nil {
err = weberrors.ErrBadRequest{Where: "form data", Details: err}
h.r.Error(rw, req, http.StatusInternalServerError, err)
return
}
redirect := req.FormValue("redirect")
if redirect == "" {
noticesURL := h.urlTo(router.CompleteNoticeList)
redirect = noticesURL.String()
}
defer http.Redirect(rw, req, redirect, http.StatusSeeOther)
if _, err := members.CheckAllowed(ctx, h.roomCfg, members.ActionChangeNotice); err != nil {
h.flashes.AddError(rw, req, err)
return
}
pinnedName := roomdb.PinnedNoticeName(req.FormValue("name"))
if !pinnedName.Valid() {
err := weberrors.ErrBadRequest{Where: "name", Details: fmt.Errorf("invalid pinned notice name")}
h.r.Error(rw, req, http.StatusInternalServerError, err)
h.flashes.AddError(rw, req, err)
return
}
@ -65,7 +89,7 @@ func (h noticeHandler) addTranslation(rw http.ResponseWriter, req *http.Request)
n.Title = req.FormValue("title")
if n.Title == "" {
err = weberrors.ErrBadRequest{Where: "title", Details: fmt.Errorf("title can't be empty")}
h.r.Error(rw, req, http.StatusInternalServerError, err)
h.flashes.AddError(rw, req, err)
return
}
@ -73,40 +97,45 @@ func (h noticeHandler) addTranslation(rw http.ResponseWriter, req *http.Request)
n.Language = req.FormValue("language")
if n.Language == "" {
err := weberrors.ErrBadRequest{Where: "language", Details: fmt.Errorf("language can't be empty")}
h.r.Error(rw, req, http.StatusInternalServerError, err)
h.flashes.AddError(rw, req, err)
return
}
n.Content = req.FormValue("content")
if n.Content == "" {
err = weberrors.ErrBadRequest{Where: "content", Details: fmt.Errorf("content can't be empty")}
h.r.Error(rw, req, http.StatusInternalServerError, err)
h.flashes.AddError(rw, req, err)
return
}
// https://github.com/russross/blackfriday/issues/575
n.Content = strings.Replace(n.Content, "\r\n", "\n", -1)
err = h.noticeDB.Save(req.Context(), &n)
err = h.noticeDB.Save(ctx, &n)
if err != nil {
h.r.Error(rw, req, http.StatusInternalServerError, err)
h.flashes.AddError(rw, req, err)
return
}
err = h.pinnedDB.Set(req.Context(), pinnedName, n.ID)
err = h.pinnedDB.Set(ctx, pinnedName, n.ID)
if err != nil {
h.r.Error(rw, req, http.StatusInternalServerError, err)
h.flashes.AddError(rw, req, err)
return
}
// TODO: redirect to edit page of the new notice (need to add urlTo to handler)
redirect := req.FormValue("redirect")
if redirect == "" {
redirect = "/"
}
http.Redirect(rw, req, redirect, http.StatusTemporaryRedirect)
h.flashes.AddMessage(rw, req, "NoticeUpdated")
}
func (h noticeHandler) edit(rw http.ResponseWriter, req *http.Request) (interface{}, error) {
ctx := req.Context()
if _, err := members.CheckAllowed(ctx, h.roomCfg, members.ActionChangeNotice); err != nil {
h.flashes.AddError(rw, req, err)
noticesURL := h.urlTo(router.CompleteNoticeList)
http.Redirect(rw, req, noticesURL.String(), http.StatusSeeOther)
return nil, err
}
id, err := strconv.ParseInt(req.URL.Query().Get("id"), 10, 64)
if err != nil {
err = weberrors.ErrBadRequest{Where: "ID", Details: err}
@ -128,8 +157,6 @@ func (h noticeHandler) edit(rw http.ResponseWriter, req *http.Request) (interfac
"SubmitAction": router.AdminNoticeSave,
"Notice": n,
"ContentPreview": template.HTML(preview),
// "Debug": string(preview),
// "DebugHex": hex.Dump(contentBytes),
csrf.TemplateTag: csrf.TemplateField(req),
}
pageData["Flashes"], err = h.flashes.GetAll(rw, req)
@ -141,6 +168,14 @@ func (h noticeHandler) edit(rw http.ResponseWriter, req *http.Request) (interfac
}
func (h noticeHandler) save(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
if req.Method != "POST" {
err := weberrors.ErrBadRequest{Where: "http method type", Details: fmt.Errorf("add translation only accepts POST requests, sorry!")}
h.r.Error(rw, req, http.StatusMethodNotAllowed, err)
return
}
err := req.ParseForm()
if err != nil {
err = weberrors.ErrBadRequest{Where: "form data", Details: err}
@ -150,12 +185,16 @@ func (h noticeHandler) save(rw http.ResponseWriter, req *http.Request) {
redirect := req.FormValue("redirect")
if redirect == "" {
noticesURL, err := router.CompleteApp().Get(router.CompleteNoticeList).URL()
if err != nil {
h.r.Error(rw, req, http.StatusInternalServerError, err)
return
}
redirect = noticesURL.Path
noticesURL := h.urlTo(router.CompleteNoticeList)
redirect = noticesURL.String()
}
// now, always redirect
defer http.Redirect(rw, req, redirect, http.StatusSeeOther)
if _, err := members.CheckAllowed(ctx, h.roomCfg, members.ActionChangeNotice); err != nil {
h.flashes.AddError(rw, req, err)
return
}
var n roomdb.Notice
@ -163,7 +202,6 @@ func (h noticeHandler) save(rw http.ResponseWriter, req *http.Request) {
if err != nil {
err = weberrors.ErrBadRequest{Where: "id", Details: err}
h.flashes.AddError(rw, req, err)
http.Redirect(rw, req, redirect, http.StatusSeeOther)
return
}
@ -171,7 +209,6 @@ func (h noticeHandler) save(rw http.ResponseWriter, req *http.Request) {
if n.Title == "" {
err = weberrors.ErrBadRequest{Where: "title", Details: fmt.Errorf("title can't be empty")}
h.flashes.AddError(rw, req, err)
http.Redirect(rw, req, redirect, http.StatusSeeOther)
return
}
@ -180,7 +217,6 @@ func (h noticeHandler) save(rw http.ResponseWriter, req *http.Request) {
if n.Language == "" {
err = weberrors.ErrBadRequest{Where: "language", Details: fmt.Errorf("language can't be empty")}
h.flashes.AddError(rw, req, err)
http.Redirect(rw, req, redirect, http.StatusSeeOther)
return
}
@ -188,7 +224,6 @@ func (h noticeHandler) save(rw http.ResponseWriter, req *http.Request) {
if n.Content == "" {
err = weberrors.ErrBadRequest{Where: "content", Details: fmt.Errorf("content can't be empty")}
h.flashes.AddError(rw, req, err)
http.Redirect(rw, req, redirect, http.StatusSeeOther)
return
}
@ -198,10 +233,8 @@ func (h noticeHandler) save(rw http.ResponseWriter, req *http.Request) {
err = h.noticeDB.Save(req.Context(), &n)
if err != nil {
h.flashes.AddError(rw, req, err)
http.Redirect(rw, req, redirect, http.StatusSeeOther)
return
}
h.flashes.AddMessage(rw, req, "NoticeUpdated")
http.Redirect(rw, req, redirect, http.StatusSeeOther)
}

View File

@ -50,7 +50,7 @@ func TestNoticeSaveRefusesIncomplete(t *testing.T) {
loc := resp.Header().Get("Location")
noticesList := ts.URLTo(router.CompleteNoticeList)
a.Equal(noticesList.Path, loc)
a.Equal(noticesList.String(), loc)
// we should get noticesList here but
// due to issue #35 we cant get /notices/list in package admin tests
@ -99,7 +99,8 @@ func TestNoticeAddLanguageOnlyAllowsPost(t *testing.T) {
formValues := url.Values{"name": []string{roomdb.NoticeNews.String()}, "id": id, "title": title, "content": content, "language": language}
resp = ts.Client.PostForm(u, formValues)
a.Equal(http.StatusTemporaryRedirect, resp.Code)
a.Equal(http.StatusSeeOther, resp.Code)
webassert.HasFlashMessages(t, ts.Client, ts.URLTo(router.AdminDashboard), "NoticeUpdated")
}
// Verifies that the "add a translation" page contains all the required form fields (id/title/content/language)
@ -158,3 +159,193 @@ func TestNoticeEditFormIncludesAllFields(t *testing.T) {
{Tag: "textarea", Name: "content"},
})
}
func TestNoticesRoleRightsEditing(t *testing.T) {
ts := newSession(t)
dashboardURL := ts.URLTo(router.AdminDashboard)
editURL := ts.URLTo(router.AdminNoticeEdit, "id", 1)
saveURL := ts.URLTo(router.AdminNoticeSave)
formValues := url.Values{
"id": []string{"1"},
"title": []string{"SSB Breaking News: This Test Is Great"},
"content": []string{"Absolutely Thrilling Content"},
"language": []string{"en-GB"},
}
canSeeEditForm := func(t *testing.T, shouldWork bool) {
a := assert.New(t)
doc, resp := ts.Client.GetHTML(editURL)
if shouldWork {
a.Equal(http.StatusOK, resp.Code, "unexpected status code")
form := doc.Find("form")
action, has := form.Attr("action")
a.True(has, "no action on a form?!")
a.Equal(saveURL.String(), action)
} else {
a.Equal(http.StatusSeeOther, resp.Code, "unexpected status code")
webassert.HasFlashMessages(t, ts.Client, dashboardURL, "ErrorNotAuthorized")
}
}
totalSaveCallCount := 0
canSaveNotice := func(t *testing.T, shouldWork bool) {
a := assert.New(t)
// POST a correct request to the save handler, and verify that the save was handled using the mock database)
resp := ts.Client.PostForm(saveURL, formValues)
a.Equal(http.StatusSeeOther, resp.Code, "should have redirected")
var wantLabel string
if shouldWork {
totalSaveCallCount++
wantLabel = "NoticeUpdated"
} else {
wantLabel = "ErrorNotAuthorized"
}
webassert.HasFlashMessages(t, ts.Client, dashboardURL, wantLabel)
a.Equal(totalSaveCallCount, ts.NoticeDB.SaveCallCount(), "call count missmatch")
}
memberUser := roomdb.Member{
ID: 7331,
Role: roomdb.RoleMember,
PubKey: generatePubKey(),
}
modUser := roomdb.Member{
ID: 9001,
Role: roomdb.RoleModerator,
PubKey: generatePubKey(),
}
adminUser := roomdb.Member{
ID: 1337,
Role: roomdb.RoleAdmin,
PubKey: generatePubKey(),
}
/* test invite creation under various restricted mode with the roles member, mod, admin */
for _, mode := range roomdb.AllPrivacyModes {
t.Run(mode.String(), func(t *testing.T) {
ts.ConfigDB.GetPrivacyModeReturns(mode, nil)
// members can only invite in community rooms
t.Run("members", func(t *testing.T) {
ts.User = memberUser
canSeeEditForm(t, mode == roomdb.ModeCommunity)
canSaveNotice(t, mode == roomdb.ModeCommunity)
})
// mods & admins can always invite
t.Run("mods", func(t *testing.T) {
ts.User = modUser
canSeeEditForm(t, true)
canSaveNotice(t, true)
})
t.Run("admins", func(t *testing.T) {
ts.User = adminUser
canSeeEditForm(t, true)
canSaveNotice(t, true)
})
})
}
}
func TestNoticesRoleRightsAddingTranslation(t *testing.T) {
ts := newSession(t)
dashboardURL := ts.URLTo(router.AdminDashboard)
draftTrURL := ts.URLTo(router.AdminNoticeDraftTranslation, "name", "NoticeNews")
addTrURL := ts.URLTo(router.AdminNoticeAddTranslation)
formValues := url.Values{
"id": []string{"1"},
"title": []string{"SSB Breaking News: This Test Is Great"},
"content": []string{"Absolutely Thrilling Content"},
"language": []string{"en-GB"},
"name": []string{"NoticeNews"},
}
canSeeAddTranslationForm := func(t *testing.T, shouldWork bool) {
a := assert.New(t)
doc, resp := ts.Client.GetHTML(draftTrURL)
if shouldWork {
a.Equal(http.StatusOK, resp.Code, "unexpected status code")
form := doc.Find("form")
action, has := form.Attr("action")
a.True(has, "no action on a form?!")
a.Equal(addTrURL.String(), action)
} else {
a.Equal(http.StatusSeeOther, resp.Code, "unexpected status code")
webassert.HasFlashMessages(t, ts.Client, dashboardURL, "ErrorNotAuthorized")
}
}
totalAddCallCount := 0
canAddNewTranslation := func(t *testing.T, shouldWork bool) {
a := assert.New(t)
// POST a correct request to the save handler, and verify that the save was handled using the mock database)
resp := ts.Client.PostForm(addTrURL, formValues)
a.Equal(http.StatusSeeOther, resp.Code, "should have redirected")
var wantLabel string
if shouldWork {
totalAddCallCount++
wantLabel = "NoticeUpdated"
} else {
wantLabel = "ErrorNotAuthorized"
}
webassert.HasFlashMessages(t, ts.Client, dashboardURL, wantLabel)
a.Equal(totalAddCallCount, ts.PinnedDB.SetCallCount(), "call count missmatch")
}
memberUser := roomdb.Member{
ID: 7331,
Role: roomdb.RoleMember,
PubKey: generatePubKey(),
}
modUser := roomdb.Member{
ID: 9001,
Role: roomdb.RoleModerator,
PubKey: generatePubKey(),
}
adminUser := roomdb.Member{
ID: 1337,
Role: roomdb.RoleAdmin,
PubKey: generatePubKey(),
}
/* test invite creation under various restricted mode with the roles member, mod, admin */
for _, mode := range roomdb.AllPrivacyModes {
t.Run(mode.String(), func(t *testing.T) {
ts.ConfigDB.GetPrivacyModeReturns(mode, nil)
// members can only invite in community rooms
t.Run("members", func(t *testing.T) {
ts.User = memberUser
canSeeAddTranslationForm(t, mode == roomdb.ModeCommunity)
canAddNewTranslation(t, mode == roomdb.ModeCommunity)
})
// mods & admins can always invite
t.Run("mods", func(t *testing.T) {
ts.User = modUser
canSeeAddTranslationForm(t, true)
canAddNewTranslation(t, true)
})
t.Run("admins", func(t *testing.T) {
ts.User = adminUser
canSeeAddTranslationForm(t, true)
canAddNewTranslation(t, true)
})
})
}
}

View File

@ -6,6 +6,7 @@ import (
"bytes"
"context"
"crypto/rand"
"fmt"
"net/http"
"net/url"
"os"
@ -145,11 +146,18 @@ func newSession(t *testing.T) *testSession {
testFuncs["list_languages"] = func(*url.URL, string) string { return "" }
testFuncs["member_is_elevated"] = func() bool { return ts.User.Role == roomdb.RoleAdmin || ts.User.Role == roomdb.RoleModerator }
testFuncs["member_is_admin"] = func() bool { return ts.User.Role == roomdb.RoleAdmin }
testFuncs["member_can_invite"] = func() bool {
pm, _ := ts.ConfigDB.GetPrivacyMode(context.TODO())
memberElevated := ts.User.Role == roomdb.RoleAdmin || ts.User.Role == roomdb.RoleModerator
memberCanInvite := ts.User.Role == roomdb.RoleMember && (pm == roomdb.ModeCommunity || pm == roomdb.ModeOpen)
return memberElevated || memberCanInvite
testFuncs["member_can"] = func(what string) (bool, error) {
actionCheck, has := members.AllowedActions(what)
if !has {
return false, fmt.Errorf("unrecognized action: %s", what)
}
pm, err := ts.ConfigDB.GetPrivacyMode(context.TODO())
if err != nil {
return false, err
}
return actionCheck(pm, ts.User.Role), nil
}
testFuncs["list_languages"] = func(*url.URL, string) string { return "" }
testFuncs["relative_time"] = func(when time.Time) string { return humanize.Time(when) }

View File

@ -124,31 +124,6 @@ func New(
}
}),
render.InjectTemplateFunc("member_can_invite", func(r *http.Request) interface{} {
return func() (bool, error) {
member := members.FromContext(r.Context())
if member == nil {
return false, nil
}
pm, err := dbs.Config.GetPrivacyMode(r.Context())
if err != nil {
return false, err
}
switch pm {
case roomdb.ModeOpen:
return true, nil
case roomdb.ModeCommunity:
return member.Role > roomdb.RoleUnknown && member.Role <= roomdb.RoleAdmin, nil
case roomdb.ModeRestricted:
return member.Role == roomdb.RoleAdmin || member.Role == roomdb.RoleModerator, nil
default:
return false, nil
}
}
}),
render.InjectTemplateFunc("language_count", func(r *http.Request) interface{} {
return func() int {
return len(locHelper.ListLanguages())
@ -195,7 +170,7 @@ func New(
}
renderOpts = append(renderOpts, locHelper.GetRenderFuncs()...)
renderOpts = append(renderOpts, members.TemplateHelpers()...)
renderOpts = append(renderOpts, members.TemplateHelpers(dbs.Config)...)
r, err := render.New(web.Templates, renderOpts...)
if err != nil {

View File

@ -133,11 +133,11 @@ func TestNoticesEditButtonVisible(t *testing.T) {
a.EqualValues(1, doc.Find(editButtonSelector).Length())
}
func TestNoticesCreateOnlyModsAndHigher(t *testing.T) {
func TestNoticesCreateOnlyModsAndHigherInRestricted(t *testing.T) {
ts := setup(t)
a := assert.New(t)
ts.ConfigDB.GetPrivacyModeReturns(roomdb.ModeCommunity, nil)
ts.ConfigDB.GetPrivacyModeReturns(roomdb.ModeRestricted, nil)
// first, we confirm that we can't access the page when not logged in
draftNotice := ts.URLTo(router.AdminNoticeDraftTranslation, "name", roomdb.NoticeNews)
@ -194,11 +194,11 @@ func TestNoticesCreateOnlyModsAndHigher(t *testing.T) {
doc, resp = ts.Client.GetHTML(draftNotice)
a.Equal(http.StatusSeeOther, resp.Code)
dashboardURL := ts.URLTo(router.AdminDashboard)
a.Equal(dashboardURL.Path, resp.Header().Get("Location"))
noticeListURL := ts.URLTo(router.CompleteNoticeList)
a.Equal(noticeListURL.String(), resp.Header().Get("Location"))
a.True(len(resp.Result().Cookies()) > 0, "got a cookie")
webassert.HasFlashMessages(t, ts.Client, dashboardURL, "ErrorNotAuthorized")
webassert.HasFlashMessages(t, ts.Client, noticeListURL, "ErrorNotAuthorized")
// also shouldnt be allowed to save/post
id := []string{"1"}
@ -213,9 +213,9 @@ func TestNoticesCreateOnlyModsAndHigher(t *testing.T) {
a.Equal(http.StatusSeeOther, resp.Code, "POST should work")
a.Equal(0, ts.NoticeDB.SaveCallCount(), "noticedb should not save the notice")
a.Equal(dashboardURL.Path, resp.Header().Get("Location"))
a.Equal(noticeListURL.String(), resp.Header().Get("Location"))
a.True(len(resp.Result().Cookies()) > 0, "got a cookie")
webassert.HasFlashMessages(t, ts.Client, dashboardURL, "ErrorNotAuthorized")
webassert.HasFlashMessages(t, ts.Client, noticeListURL, "ErrorNotAuthorized")
}

View File

@ -150,6 +150,8 @@ AdminInvitesCreatorColumn = "Erstellt von"
AdminInvitesActionColumn = "Aktion"
AdminInviteRevoke = "Widerrufen"
InviteRevoked = "Einladug wurde Widerrufen."
AdminInviteRevokeConfirmTitle = "Widerruf der Einladung bestätigen"
AdminInviteRevokeConfirmWelcome = "Sind Sie sicher, dass Sie diese Einladung entfernen möchten? Wenn Sie sie bereits gesendet haben, können sie sie nicht verwenden."

View File

@ -157,6 +157,8 @@ AdminInvitesCreatorColumn = "Created by"
AdminInvitesActionColumn = "Action"
AdminInviteRevoke = "Revoke"
InviteRevoked = "Invite Revoked."
AdminInviteRevokeConfirmTitle = "Confirm invite revocation"
AdminInviteRevokeConfirmWelcome = "Are you sure you want to remove this invite? If you already sent it out, they will not be able to use it."

View File

@ -5,6 +5,7 @@ package members
import (
"context"
"fmt"
"net/http"
"go.mindeco.de/http/auth"
@ -101,7 +102,9 @@ func ContextInjecter(mdb roomdb.MembersService, withPassword *auth.Handler, with
// {{ member_is_admin }} is a shortcut for {{ member_has_role "RoleAdmin" }}
//
// {{ member_is_elevated }} is a shortcut for {{ or member_has_role "RoleAdmin" member_has_role "RoleModerator"}}
func TemplateHelpers() []render.Option {
//
// {{ member_can "action" }} returns true if a member can execute a certain action. Actions are "invite" and "remove-denied-key". See allowedActions to add more.
func TemplateHelpers(roomCfg roomdb.RoomConfig) []render.Option {
return []render.Option{
render.InjectTemplateFunc("is_logged_in", func(r *http.Request) interface{} {
@ -159,5 +162,116 @@ func TemplateHelpers() []render.Option {
return member.Role == roomdb.RoleAdmin || member.Role == roomdb.RoleModerator
}
}),
render.InjectTemplateFunc("member_can", func(r *http.Request) interface{} {
// evaluate member and privacy mode first to reduce some churn for multiple calls to this helper
// works fine since they are not changing during one request
member := FromContext(r.Context())
if member == nil {
return func(_ string) bool { return false }
}
pm, err := roomCfg.GetPrivacyMode(r.Context())
if err != nil {
return func(_ string) (bool, error) { return false, err }
}
// now return the template func which closes over pm and the member
return func(what string) (bool, error) {
actionCheck, has := allowedActionsMap[what]
if !has {
return false, fmt.Errorf("unrecognized action: %s", what)
}
return actionCheck(pm, member.Role), nil
}
}),
}
}
// AllowedFunc returns true if a member role is allowed to do a thing under the passed mode
type AllowedFunc func(mode roomdb.PrivacyMode, role roomdb.Role) bool
// AllowedActions exposes check function by name. It exists to protected against changes of the map
func AllowedActions(name string) (AllowedFunc, bool) {
fn, has := allowedActionsMap[name]
return fn, has
}
// member actions
const (
ActionInviteMember = "invite"
ActionChangeDeniedKeys = "change-denied-keys"
ActionRemoveMember = "remove-member"
ActionChangeNotice = "change-notice"
)
var allowedActionsMap = map[string]AllowedFunc{
ActionInviteMember: func(pm roomdb.PrivacyMode, role roomdb.Role) bool {
switch pm {
case roomdb.ModeOpen:
return true
case roomdb.ModeCommunity:
return role > roomdb.RoleUnknown && role <= roomdb.RoleAdmin
case roomdb.ModeRestricted:
return role == roomdb.RoleAdmin || role == roomdb.RoleModerator
default:
return false
}
},
ActionChangeDeniedKeys: func(pm roomdb.PrivacyMode, role roomdb.Role) bool {
switch pm {
case roomdb.ModeCommunity:
return true
case roomdb.ModeOpen:
fallthrough
case roomdb.ModeRestricted:
return role == roomdb.RoleAdmin || role == roomdb.RoleModerator
default:
return false
}
},
ActionRemoveMember: func(_ roomdb.PrivacyMode, role roomdb.Role) bool {
return role == roomdb.RoleAdmin || role == roomdb.RoleModerator
},
ActionChangeNotice: func(pm roomdb.PrivacyMode, role roomdb.Role) bool {
switch pm {
case roomdb.ModeCommunity:
return true
case roomdb.ModeOpen:
fallthrough
case roomdb.ModeRestricted:
return role == roomdb.RoleAdmin || role == roomdb.RoleModerator
default:
return false
}
},
}
// CheckAllowed retreives the member from the passed context and lookups the current privacy mode from the passed cfg to determain if the action is okay or not.
// If it's not it returns an error. For convenience it also returns the member if the action is okay.
func CheckAllowed(ctx context.Context, cfg roomdb.RoomConfig, action string) (*roomdb.Member, error) {
member := FromContext(ctx)
if member == nil {
return nil, weberrors.ErrNotAuthorized
}
pm, err := cfg.GetPrivacyMode(ctx)
if err != nil {
return nil, err
}
allowed, ok := AllowedActions(action)
if !ok {
return nil, fmt.Errorf("unknown action: %s: %w", action, weberrors.ErrNotAuthorized)
}
if !allowed(pm, member.Role) {
return nil, weberrors.ErrNotAuthorized
}
return member, nil
}

View File

@ -22,31 +22,31 @@
{{ .csrfField }}
<div id="denied-keys-input-container" class="flex flex-row items-center h-12">
<input
{{ if member_is_elevated }} {{ else }} disabled {{ end }}
{{ if member_can "change-denied-keys" }} {{ else }} disabled {{ end }}
type="text"
name="pub_key"
placeholder="{{i18n "PubKeyRefPlaceholder"}}"
class="p-1 rounded font-mono truncate w-1/2 mr-2 tracking-wider h-12 shadow text-gray-900 focus:outline-none focus:ring-1
focus:ring-green-500 focus:border-transparent placeholder-gray-300
{{ if member_is_elevated }} {{ else }} shadow ring-1 ring-gray-300 opacity-50 bg-gray-200 cursor-not-allowed {{ end }}
{{ if member_can "change-denied-keys" }} {{ else }} shadow ring-1 ring-gray-300 opacity-50 bg-gray-200 cursor-not-allowed {{ end }}
"
>
<input
{{ if member_is_elevated }} {{ else }} disabled {{ end }}
{{ if member_can "change-denied-keys" }} {{ else }} disabled {{ end }}
type="text"
name="comment"
placeholder="{{i18n "AdminDeniedKeysComment"}}"
class="p-1 rounded font-mono truncate w-1/2 mr-2 tracking-wider h-12 shadow text-gray-900 focus:outline-none focus:ring-1
focus:ring-green-500 focus:border-transparent placeholder-gray-300
{{ if member_is_elevated }} {{ else }} shadow ring-1 ring-gray-300 opacity-50 bg-gray-200 cursor-not-allowed {{ end }}
{{ if member_can "change-denied-keys" }} {{ else }} shadow ring-1 ring-gray-300 opacity-50 bg-gray-200 cursor-not-allowed {{ end }}
"
>
<input
{{ if member_is_elevated }} {{ else }} disabled {{ end }}
{{ if member_can "change-denied-keys" }} {{ else }} disabled {{ end }}
type="submit"
value="{{i18n "AdminDeniedKeysAdd"}}"
class="pl-4 w-20 py-2 text-center font-bold bg-transparent disabled:opacity-50
{{ if member_is_elevated }} text-green-500 hover:text-green-600 cursor-pointer {{ else }} text-gray-200 cursor-not-allowed {{ end }}
{{ if member_can "change-denied-keys" }} text-green-500 hover:text-green-600 cursor-pointer {{ else }} text-gray-200 cursor-not-allowed {{ end }}
"
>
</div>
@ -62,8 +62,8 @@
>{{.Comment}}</span>
<a
href="{{urlTo "admin:denied-keys:remove:confirm" "id" .ID}}"
class="pl-4 w-20 py-2 text-center text-gray-400 hover:text-red-600 font-bold cursor-pointer"
href="{{if member_can "change-denied-keys"}}{{urlTo "admin:denied-keys:remove:confirm" "id" .ID}}{{else}}#{{end}}"
class="pl-4 w-20 py-2 text-center {{if member_can "change-denied-keys"}}text-gray-400 hover:text-red-600 font-bold cursor-pointer{{else}} text-gray-200 line-through cursor-not-allowed {{end}}"
>{{i18n "AdminDeniedKeysRemove"}}</a>
</li>
{{end}}

View File

@ -28,10 +28,10 @@
>
{{ .csrfField }}
<button
{{ if not member_can_invite }} disabled {{ end }}
{{ if member_can "invite" }} {{else}} disabled {{ end }}
type="submit"
class="shadow rounded px-3 py-1.5 ring-1 focus:outline-none focus:ring-2
{{ if member_can_invite }}
{{ if member_can "invite" }}
text-green-600 ring-green-400 bg-white hover:bg-green-500 hover:text-gray-100 focus:ring-green-400
{{ else }}
text-gray-500 ring-gray-200 bg-gray-300 cursor-not-allowed