435 lines
15 KiB
Go
435 lines
15 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/api/handlers/auth"
|
|
userhandler "RenewCMS/api/handlers/user"
|
|
"RenewCMS/api/middleware"
|
|
"RenewCMS/api/pkg/apperr"
|
|
validator "RenewCMS/api/pkg/validator"
|
|
"RenewCMS/internal/domain/user"
|
|
"RenewCMS/internal/infrastructure/useCases"
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"RenewCMS/api/pkg/authtoken"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
const (
|
|
rolesInviteBody = `{"email": "sam.oliveira@renew.example"}`
|
|
rolesClientIP = "203.0.113.20"
|
|
)
|
|
|
|
type userRolesFixture struct {
|
|
users *mockUserRepo
|
|
invites *inviteFakeRepo
|
|
mail *mockMailRepo
|
|
router *chi.Mux
|
|
token *authtoken.Auth
|
|
}
|
|
|
|
func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
|
|
t.Helper()
|
|
validator.InitValidator()
|
|
f := &userRolesFixture{
|
|
users: newMockUserRepo(),
|
|
invites: newInviteFakeRepo(),
|
|
mail: newMockMailRepo(),
|
|
token: authtoken.New([]byte("test-secret")),
|
|
}
|
|
f.invites.users = f.users
|
|
hasher := &stubHasher{}
|
|
getUser := useCases.NewGetUserUseCase(f.users)
|
|
loginHandler := auth.NewLoginHandler(
|
|
f.token,
|
|
useCases.NewCreateUserUseCase(f.users, newSeededMockRoleRepo(), hasher, f.mail, invitePublicURL, discardRecorder),
|
|
useCases.NewAuthenticateUserUseCase(f.users, hasher, discardRecorder),
|
|
true,
|
|
)
|
|
acceptHandler := auth.NewAcceptInviteHandler(
|
|
useCases.NewGetInviteUseCase(f.invites, hasher),
|
|
useCases.NewAcceptInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), hasher, discardRecorder),
|
|
f.token,
|
|
true,
|
|
)
|
|
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()))
|
|
inviteHandler := userhandler.NewInviteHandler(
|
|
useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, f.mail, invitePublicURL, discardRecorder),
|
|
nil,
|
|
nil,
|
|
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites, newSeededMockRoleRepo()),
|
|
)
|
|
|
|
f.router = chi.NewRouter()
|
|
f.router.Route("/v1/auth", func(r chi.Router) {
|
|
r.Post("/register", loginHandler.Register)
|
|
r.Post("/invite/accept", acceptHandler.AcceptInvite)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(authtoken.Verifier(f.token))
|
|
r.Use(authtoken.Authenticator)
|
|
r.Use(middleware.RequireCurrentToken(getUser))
|
|
r.Get("/me", meHandler.Me)
|
|
})
|
|
})
|
|
f.router.Route("/v1/user", func(r chi.Router) {
|
|
r.Use(authtoken.Verifier(f.token))
|
|
r.Use(authtoken.Authenticator)
|
|
r.Use(middleware.RequireCurrentToken(getUser))
|
|
r.Use(middleware.RequireVerifiedUser(getUser))
|
|
r.Get("/", inviteHandler.ListUsers)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequirePermission(getUser, useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), "users.invite"))
|
|
r.Use(middleware.TrustedProxyRealIP(nil))
|
|
r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour))
|
|
r.Post("/invite", inviteHandler.CreateInvite)
|
|
})
|
|
})
|
|
return f
|
|
}
|
|
|
|
func (f *userRolesFixture) seedUser(id uint32, username string, roleID uint32, verified bool) {
|
|
f.users.users[id] = user.User{
|
|
ID: id,
|
|
Username: username,
|
|
Email: username + "@renew.example",
|
|
IsVerified: verified,
|
|
RoleID: roleID,
|
|
}
|
|
if id > f.users.nextID {
|
|
f.users.nextID = id
|
|
}
|
|
}
|
|
|
|
func (f *userRolesFixture) seedWriter(id uint32, username string) {
|
|
f.seedUser(id, username, writerRoleID, true)
|
|
}
|
|
|
|
func (f *userRolesFixture) do(t *testing.T, method, target, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
|
|
req.RemoteAddr = rolesClientIP + ":4321"
|
|
if cookie != nil {
|
|
req.AddCookie(cookie)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
f.router.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func (f *userRolesFixture) as(t *testing.T, userID uint32) *http.Cookie {
|
|
t.Helper()
|
|
return displayNameCookie(t, f.token, userID, 0)
|
|
}
|
|
|
|
func assertNothingInvited(t *testing.T, f *userRolesFixture) {
|
|
t.Helper()
|
|
if len(f.invites.invites) != 0 {
|
|
t.Errorf("expected no invite, got %d", len(f.invites.invites))
|
|
}
|
|
if len(f.mail.SentEmails) != 0 {
|
|
t.Errorf("expected no email, got %d", len(f.mail.SentEmails))
|
|
}
|
|
}
|
|
|
|
func TestUserRolesHttp(t *testing.T) {
|
|
t.Run("The first registered user is the owner", func(t *testing.T) {
|
|
f := newUserRolesFixture(t, 100)
|
|
|
|
rec := f.do(t, http.MethodPost, "/v1/auth/register", `{"username": "marta", "password": "hunter2example12", "email": "marta@renew.example"}`, nil)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(f.users.users) != 1 {
|
|
t.Fatalf("expected one user, got %d", len(f.users.users))
|
|
}
|
|
for _, stored := range f.users.users {
|
|
if stored.Username != "marta" || stored.RoleID != ownerRoleID {
|
|
t.Errorf("unexpected user %+v", stored)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("A user created by accepting an invite takes the role of the invite", func(t *testing.T) {
|
|
f := newUserRolesFixture(t, 100)
|
|
f.seedUser(1, "marta", ownerRoleID, true)
|
|
pending := pendingInvite(acceptEmail)
|
|
pending.HashedCode = "hashed:" + acceptCode
|
|
pending.RoleID = adminRoleID
|
|
f.invites.seed(pending)
|
|
|
|
rec := f.do(t, http.MethodPost, "/v1/auth/invite/accept", acceptBody(acceptCode, "lea", acceptPassword), nil)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
roles := map[string]uint32{}
|
|
for _, stored := range f.users.users {
|
|
roles[stored.Username] = stored.RoleID
|
|
}
|
|
if roles["lea"] != adminRoleID {
|
|
t.Errorf("expected lea admin, got %d", roles["lea"])
|
|
}
|
|
if roles["marta"] != ownerRoleID {
|
|
t.Errorf("expected marta still owner, got %d", roles["marta"])
|
|
}
|
|
})
|
|
|
|
for _, tc := range []struct {
|
|
username string
|
|
role uint32
|
|
name string
|
|
}{{"marta", ownerRoleID, "Owner"}, {"lea", adminRoleID, "Admin"}} {
|
|
t.Run("GET /v1/auth/me returns the role of the session user only in access.role: "+tc.username, func(t *testing.T) {
|
|
f := newUserRolesFixture(t, 100)
|
|
f.seedUser(4, tc.username, tc.role, true)
|
|
|
|
rec := f.do(t, http.MethodGet, "/v1/auth/me", "", f.as(t, 4))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
decoded := decodeKeys(t, rec.Body.Bytes())
|
|
if _, present := decoded["role"]; present {
|
|
t.Errorf("no top-level role expected: %s", rec.Body.String())
|
|
}
|
|
var access struct {
|
|
Role struct {
|
|
Name string `json:"name"`
|
|
} `json:"role"`
|
|
}
|
|
if err := json.Unmarshal(decoded["access"], &access); err != nil || access.Role.Name != tc.name {
|
|
t.Errorf("expected access.role %q, got %q (%v)", tc.name, access.Role.Name, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("An unverified owner can read /auth/me", func(t *testing.T) {
|
|
f := newUserRolesFixture(t, 100)
|
|
f.seedUser(5, "bob", ownerRoleID, false)
|
|
|
|
rec := f.do(t, http.MethodGet, "/v1/auth/me", "", f.as(t, 5))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
decoded := decodeKeys(t, rec.Body.Bytes())
|
|
if string(decoded["is_verified"]) != "false" {
|
|
t.Errorf("unexpected body %s", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("The users list carries email and role on accepted users", func(t *testing.T) {
|
|
f := newUserRolesFixture(t, 100)
|
|
day := func(d int) time.Time { return time.Date(2026, 3, d, 9, 0, 0, 0, time.UTC) }
|
|
f.users.users[1] = user.User{ID: 1, Username: "marta", Email: "marta@renew.example", IsVerified: true, RoleID: ownerRoleID, CreatedAt: day(1)}
|
|
f.users.users[2] = user.User{ID: 2, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, RoleID: adminRoleID, CreatedAt: day(2)}
|
|
f.users.users[3] = user.User{ID: 3, Username: "ines", DisplayName: "Inès Duarte", Email: "ines.duarte@renew.example", IsVerified: true, RoleID: adminRoleID, CreatedAt: day(3)}
|
|
f.invites.seed(pendingInvite("sam.oliveira@renew.example"))
|
|
|
|
rec := f.do(t, http.MethodGet, "/v1/user/", "", f.as(t, 2))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
items := decodeList(t, rec.Body.Bytes())
|
|
if len(items) != 4 {
|
|
t.Fatalf("expected 4 items, got %d", len(items))
|
|
}
|
|
wantRoles := map[string][2]string{
|
|
"marta": {"marta@renew.example", "Owner"},
|
|
"jonas": {"jonas@renew.example", "Admin"},
|
|
"ines": {"ines.duarte@renew.example", "Admin"},
|
|
}
|
|
for _, item := range items {
|
|
if decodedString(t, item, "kind") == "invite" {
|
|
assertExactKeys(t, item, "id", "kind", "email", "role", "status", "created_at")
|
|
continue
|
|
}
|
|
assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "role_assigned_by", "status", "created_at")
|
|
want := wantRoles[decodedString(t, item, "username")]
|
|
var ref struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal(item["role"], &ref); err != nil || decodedString(t, item, "email") != want[0] || ref.Name != want[1] {
|
|
t.Errorf("unexpected user item %v", item)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{"password", "password_reset_code", "verification_code", "verification_expiration", "password_reset_expiration", "token_version", "code", "hashed_code", "invited_by", "accepted_at"} {
|
|
for _, item := range items {
|
|
if _, present := item[forbidden]; present {
|
|
t.Errorf("item carries forbidden key %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("The owner can invite", func(t *testing.T) {
|
|
f := newUserRolesFixture(t, 100)
|
|
f.seedUser(1, "marta", ownerRoleID, true)
|
|
|
|
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 1))
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(f.invites.invites) != 1 || len(f.mail.SentEmails) != 1 || f.mail.SentEmails[0].Template != "invitation" {
|
|
t.Errorf("expected one invite and one invitation email, got %d and %+v", len(f.invites.invites), f.mail.SentEmails)
|
|
}
|
|
})
|
|
|
|
t.Run("An admin can invite", func(t *testing.T) {
|
|
f := newUserRolesFixture(t, 100)
|
|
f.seedUser(2, "jonas", adminRoleID, true)
|
|
|
|
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 2))
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("A writer cannot invite", func(t *testing.T) {
|
|
f := newUserRolesFixture(t, 100)
|
|
f.seedWriter(2, "jonas")
|
|
|
|
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 2))
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403, got %d", rec.Code)
|
|
}
|
|
if rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n" {
|
|
t.Errorf("unexpected body %q", rec.Body.String())
|
|
}
|
|
assertNothingInvited(t, f)
|
|
})
|
|
|
|
for _, body := range []string{`{"email": "not-an-email"}`, `not json`} {
|
|
t.Run("The permission check comes before validation and the rate limiter: "+body, func(t *testing.T) {
|
|
f := newUserRolesFixture(t, 100)
|
|
f.seedWriter(2, "jonas")
|
|
|
|
rec := f.do(t, http.MethodPost, "/v1/user/invite", body, f.as(t, 2))
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403, got %d", rec.Code)
|
|
}
|
|
assertNothingInvited(t, f)
|
|
})
|
|
}
|
|
|
|
t.Run("A writer's refused invites do not use up the rate limit", func(t *testing.T) {
|
|
f := newUserRolesFixture(t, 2)
|
|
f.seedWriter(2, "jonas")
|
|
|
|
for i := 0; i < 3; i++ {
|
|
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 2))
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("request %d: expected 403, got %d", i, rec.Code)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("The role is read on every request, not from the token", func(t *testing.T) {
|
|
f := newUserRolesFixture(t, 100)
|
|
f.seedUser(2, "jonas", ownerRoleID, true)
|
|
cookie := f.as(t, 2)
|
|
stored := f.users.users[2]
|
|
stored.RoleID = writerRoleID
|
|
f.users.users[2] = stored
|
|
|
|
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, cookie)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403, got %d", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("An admin can still list users", func(t *testing.T) {
|
|
f := newUserRolesFixture(t, 100)
|
|
f.seedUser(2, "jonas", adminRoleID, true)
|
|
|
|
rec := f.do(t, http.MethodGet, "/v1/user/", "", f.as(t, 2))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("A jwt for a since-deleted account is refused with 401 on invite", func(t *testing.T) {
|
|
f := newUserRolesFixture(t, 100)
|
|
f.seedUser(1, "marta", ownerRoleID, true)
|
|
cookie := f.as(t, 1)
|
|
delete(f.users.users, 1)
|
|
|
|
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, cookie)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", rec.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUserRolesUseCases(t *testing.T) {
|
|
t.Run("CreateUser returns an owner", func(t *testing.T) {
|
|
users := newMockUserRepo()
|
|
uc := useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), invitePublicURL, discardRecorder)
|
|
|
|
created, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{Username: "marta", Password: "hunter2example12", Email: "marta@renew.example"})
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if created.RoleID != ownerRoleID {
|
|
t.Errorf("expected owner, got %d", created.RoleID)
|
|
}
|
|
})
|
|
|
|
t.Run("AcceptInvite returns a user with the role of the invite", func(t *testing.T) {
|
|
users := newMockUserRepo()
|
|
invites := newInviteFakeRepo()
|
|
invites.users = users
|
|
pending := pendingInvite(acceptEmail)
|
|
pending.HashedCode = "hashed:" + acceptCode
|
|
pending.RoleID = adminRoleID
|
|
invites.seed(pending)
|
|
uc := useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), &stubHasher{}, discardRecorder)
|
|
|
|
created, err := uc.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword})
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if created.RoleID != adminRoleID {
|
|
t.Errorf("expected admin, got %d", created.RoleID)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRequirePermissionRepositoryFailure(t *testing.T) {
|
|
t.Run("An unexpected repository error is a 500, not a 403", func(t *testing.T) {
|
|
token := authtoken.New([]byte("test-secret"))
|
|
repo := newMockUserRepo()
|
|
repo.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, RoleID: ownerRoleID}
|
|
r := chi.NewRouter()
|
|
r.Use(authtoken.Verifier(token))
|
|
r.Use(authtoken.Authenticator)
|
|
r.Use(middleware.RequirePermission(useCases.NewGetUserUseCase(repo), useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), "users.invite"))
|
|
r.Post("/", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
|
repo.fail.failNext("Get")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
|
req.AddCookie(displayNameCookie(t, token, 1, 0))
|
|
rec := httptest.NewRecorder()
|
|
r.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500, got %d", rec.Code)
|
|
}
|
|
})
|
|
}
|