feat: Users API, ownership transfer and removal of the legacy role string

This commit is contained in:
Florian Sylvain committed 2026-10-27 03:08:15 +01:00
1 parent 094df63a7f
commit 80f7037d3a
124 files changed
+4644 -824

No files matched your search

+19 -18
View File
@@ -111,7 +111,7 @@ func (m *inviteFakeRepo) AcceptWithUser(id uint32, at time.Time, newUser user.Us
var _ invite.Repository = &inviteFakeRepo{}
func inviteAt(email string, createdAt time.Time, expiresAt time.Time) invite.Invite {
return invite.Invite{Email: email, HashedCode: "hashed:seed", InvitedBy: 7, CreatedAt: createdAt, ExpiresAt: expiresAt}
return invite.Invite{Email: email, HashedCode: "hashed:seed", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: createdAt, ExpiresAt: expiresAt}
}
func pendingInvite(email string) invite.Invite {
@@ -126,7 +126,7 @@ func expiredInvite(email string) invite.Invite {
func inviteInviterRepo(displayName string) *mockUserRepo {
repo := newMockUserRepo()
repo.users[7] = user.User{ID: 7, Username: "marta", DisplayName: displayName, Email: "marta@renew.example", IsVerified: true, Role: user.RoleOwner}
repo.users[7] = user.User{ID: 7, Username: "marta", DisplayName: displayName, Email: "marta@renew.example", IsVerified: true, RoleID: ownerRoleID}
repo.nextID = 7
return repo
}
@@ -152,10 +152,10 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
}
getUser := useCases.NewGetUserUseCase(f.users)
handler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder),
nil,
nil,
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites, newSeededMockRoleRepo()),
)
f.router = chi.NewRouter()
f.router.Route("/v1/user", func(r chi.Router) {
@@ -165,6 +165,7 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", handler.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", handler.CreateInvite)
@@ -238,7 +239,7 @@ func TestInvitationsHttp(t *testing.T) {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
}
decoded := decodeKeys(t, rec.Body.Bytes())
assertExactKeys(t, decoded, "id", "kind", "email", "status", "created_at", "expires_at")
assertExactKeys(t, decoded, "id", "kind", "email", "role", "status", "created_at", "expires_at")
if decodedString(t, decoded, "kind") != "invite" || decodedString(t, decoded, "email") != "sam.oliveira@renew.example" || decodedString(t, decoded, "status") != "pending" {
t.Errorf("unexpected body: %s", rec.Body.String())
}
@@ -271,7 +272,7 @@ func TestInvitationsHttp(t *testing.T) {
t.Run("The email names the inviter by display name when there is one", func(t *testing.T) {
f := newInviteFixture(t, 100)
f.users.users[7] = user.User{ID: 7, Username: "ines", DisplayName: "Ines Duarte", IsVerified: true}
f.users.users[7] = user.User{ID: 7, Username: "ines", DisplayName: "Ines Duarte", IsVerified: true, RoleID: ownerRoleID}
rec := f.post(t, `{"email": "lea.martin@renew.example"}`)
@@ -493,7 +494,7 @@ func TestInvitationsHttp(t *testing.T) {
t.Errorf("item %d unexpected: %v", index, item)
}
if want.kind == "invite" {
assertExactKeys(t, item, "id", "kind", "email", "status", "created_at")
assertExactKeys(t, item, "id", "kind", "email", "role", "status", "created_at")
} else {
assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at")
}
@@ -536,7 +537,7 @@ func TestInvitationsDoNotChangeRegistration(t *testing.T) {
hasher := &stubHasher{}
handler := auth.NewLoginHandler(
authtoken.New([]byte("test-secret")),
useCases.NewCreateUserUseCase(users, hasher, mail, invitePublicURL, discardRecorder),
useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), hasher, mail, invitePublicURL, discardRecorder),
useCases.NewAuthenticateUserUseCase(users, hasher, discardRecorder),
true,
)
@@ -601,12 +602,12 @@ func TestInvitationsDoNotChangeRegistration(t *testing.T) {
func TestCreateInviteUseCase(t *testing.T) {
newUseCase := func() (*useCases.CreateInviteUseCase, *mockUserRepo, *inviteFakeRepo, *mockMailRepo, *stubHasher) {
users, invites, mail, hasher := inviteInviterRepo("Marta A"), newInviteFakeRepo(), newMockMailRepo(), &stubHasher{}
return useCases.NewCreateInviteUseCase(users, invites, hasher, mail, invitePublicURL, discardRecorder), users, invites, mail, hasher
return useCases.NewCreateInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, mail, invitePublicURL, discardRecorder), users, invites, mail, hasher
}
t.Run("lower-cases the email and returns the stored invite", func(t *testing.T) {
uc, _, _, _, _ := newUseCase()
got, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "A@B.Example", InvitedBy: 7})
got, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "A@B.Example", InvitedBy: 7})
if err != nil || got.Email != "a@b.example" || got.ID == 0 {
t.Fatalf("unexpected result %+v, %v", got, err)
}
@@ -614,7 +615,7 @@ func TestCreateInviteUseCase(t *testing.T) {
t.Run("unknown inviter", func(t *testing.T) {
uc, _, invites, _, _ := newUseCase()
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 99})
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 99})
if !errors.Is(err, user.ErrUserNotFound) || len(invites.invites) != 0 {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
@@ -623,7 +624,7 @@ func TestCreateInviteUseCase(t *testing.T) {
t.Run("existing account is refused case-insensitively", func(t *testing.T) {
uc, users, _, _, _ := newUseCase()
users.users[8] = user.User{ID: 8, Email: "Jonas@Renew.example"}
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "jonas@renew.example", InvitedBy: 7})
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "jonas@renew.example", InvitedBy: 7})
if !errors.Is(err, invite.ErrEmailHasAccount) {
t.Errorf("expected ErrEmailHasAccount, got %v", err)
}
@@ -632,7 +633,7 @@ func TestCreateInviteUseCase(t *testing.T) {
t.Run("pending invite is refused", func(t *testing.T) {
uc, _, invites, _, _ := newUseCase()
invites.seed(pendingInvite("a@b.example"))
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7})
if !errors.Is(err, invite.ErrPendingInvite) {
t.Errorf("expected ErrPendingInvite, got %v", err)
}
@@ -645,7 +646,7 @@ func TestCreateInviteUseCase(t *testing.T) {
accepted.AcceptedAt = &acceptedAt
invites.seed(accepted)
invites.seed(pendingInvite("other@b.example"))
if _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}); err != nil {
if _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7}); err != nil {
t.Errorf("unexpected error %v", err)
}
if len(invites.invites) != 3 {
@@ -656,7 +657,7 @@ func TestCreateInviteUseCase(t *testing.T) {
t.Run("hash failure stores and sends nothing", func(t *testing.T) {
uc, _, invites, mail, hasher := newUseCase()
hasher.hashErr = errors.New("hash boom")
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7})
if err == nil || len(invites.invites) != 0 || len(mail.SentEmails) != 0 {
t.Errorf("expected failure without side effects, got %v", err)
}
@@ -666,7 +667,7 @@ func TestCreateInviteUseCase(t *testing.T) {
uc, _, invites, mail, _ := newUseCase()
invites.seed(expiredInvite("a@b.example"))
invites.fail.failNext("Delete")
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7})
if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 {
t.Errorf("expected repository failure, got %v", err)
}
@@ -676,7 +677,7 @@ func TestCreateInviteUseCase(t *testing.T) {
uc, _, invites, mail, _ := newUseCase()
mail.fail.failNextWith("Send", errors.New("smtp down"))
invites.fail.failNextWith("Delete", errors.New("cleanup down"))
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7})
if !errors.Is(err, invite.ErrInviteEmailNotSent) || !strings.Contains(err.Error(), "smtp down") || !strings.Contains(err.Error(), "cleanup down") {
t.Errorf("unexpected error %v", err)
}
@@ -742,7 +743,7 @@ func TestInvitationsHandlerUsesPublicUserSafeShape(t *testing.T) {
func TestListUsersAndInvitesUseCase(t *testing.T) {
t.Run("empty repositories give an empty non-nil list", func(t *testing.T) {
got := useCases.NewListUsersAndInvitesUseCase(newMockUserRepo(), newInviteFakeRepo()).ListUsersAndInvites()
got, _ := useCases.NewListUsersAndInvitesUseCase(newMockUserRepo(), newInviteFakeRepo(), newSeededMockRoleRepo()).ListUsersAndInvites()
if got == nil || len(got) != 0 {
t.Errorf("expected empty non-nil slice, got %#v", got)
}