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

+24 -24
View File
@@ -48,21 +48,21 @@ func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagement
token: authtoken.New([]byte("test-secret")),
}
f.invites.users = f.users
f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin, RoleID: writerRoleID}
f.users.users[11] = user.User{ID: 11, Username: "unverified", Email: "unverified@renew.example", IsVerified: false, Role: user.RoleAdmin}
f.users.users[12] = user.User{ID: 12, Username: "stale", Email: "stale@renew.example", IsVerified: true, Role: user.RoleOwner, TokenVersion: 2}
f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, RoleID: writerRoleID}
f.users.users[11] = user.User{ID: 11, Username: "unverified", Email: "unverified@renew.example", IsVerified: false, RoleID: adminRoleID}
f.users.users[12] = user.User{ID: 12, Username: "stale", Email: "stale@renew.example", IsVerified: true, RoleID: ownerRoleID, TokenVersion: 2}
f.users.nextID = 12
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),
useCases.NewCancelInviteUseCase(f.invites, discardRecorder),
useCases.NewRenewInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
useCases.NewRenewInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder),
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites, newSeededMockRoleRepo()),
)
acceptHandler := auth.NewAcceptInviteHandler(
useCases.NewGetInviteUseCase(f.invites, f.hasher),
useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher, discardRecorder),
useCases.NewAcceptInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), f.hasher, discardRecorder),
f.token,
true,
)
@@ -145,7 +145,7 @@ func TestInviteManagementHttp(t *testing.T) {
t.Run("A cancelled invite's link stops working and its address can be invited again", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
f.invites.seed(invite.Invite{Email: "sam.oliveira@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)})
f.invites.seed(invite.Invite{Email: "sam.oliveira@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)})
f.asOwner(t, http.MethodDelete, "/v1/user/invite/1")
rec := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil)
@@ -223,7 +223,7 @@ func TestInviteManagementHttp(t *testing.T) {
for _, filler := range []string{"a@renew.example", "b@renew.example", "c@renew.example"} {
f.invites.seed(pendingInvite(filler))
}
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 9, CreatedAt: now.Add(-5 * 24 * time.Hour), ExpiresAt: now.Add(-2 * 24 * time.Hour)})
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 9, RoleID: writerRoleID, CreatedAt: now.Add(-5 * 24 * time.Hour), ExpiresAt: now.Add(-2 * 24 * time.Hour)})
rec := f.asOwner(t, http.MethodPost, "/v1/user/invite/4/renew")
@@ -231,7 +231,7 @@ func TestInviteManagementHttp(t *testing.T) {
t.Fatalf("unexpected %d %q", rec.Code, rec.Header().Get("Cache-Control"))
}
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") != "lea.martin@renew.example" || decodedString(t, decoded, "status") != "pending" {
t.Errorf("unexpected body %s", rec.Body.String())
}
@@ -268,8 +268,8 @@ func TestInviteManagementHttp(t *testing.T) {
t.Run("A renewed pending invite moves to the top of the list and the old link dies", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
f.invites.seed(invite.Invite{Email: "newer@renew.example", HashedCode: "hashed:OTHER", InvitedBy: 7, CreatedAt: time.Now().Add(-24 * time.Hour), ExpiresAt: time.Now().Add(48 * time.Hour)})
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
f.invites.seed(invite.Invite{Email: "newer@renew.example", HashedCode: "hashed:OTHER", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-24 * time.Hour), ExpiresAt: time.Now().Add(48 * time.Hour)})
f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew")
@@ -288,8 +288,8 @@ func TestInviteManagementHttp(t *testing.T) {
t.Run("Accepting with a cancelled or replaced code is not found and creates no user", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
f.invites.seed(invite.Invite{Email: "sam@renew.example", HashedCode: "hashed:CANCELLED", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
f.invites.seed(invite.Invite{Email: "sam@renew.example", HashedCode: "hashed:CANCELLED", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew")
f.asOwner(t, http.MethodDelete, "/v1/user/invite/2")
usersBefore := len(f.users.users)
@@ -363,7 +363,7 @@ func TestInviteManagementHttp(t *testing.T) {
t.Run("The old code of a pending invite works until it is renewed", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
if before := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil); before.Code != http.StatusOK {
t.Fatalf("control expected 200, got %d", before.Code)
@@ -480,9 +480,9 @@ func TestManageInviteUseCases(t *testing.T) {
mail := newMockMailRepo()
seeded := invites.seed(pendingInvite("a@renew.example"))
mail.fail.failNextWith("Send", errors.New("smtp down"))
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder)
renew := useCases.NewRenewInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder)
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7})
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7})
if !errors.Is(err, invite.ErrInviteEmailNotSent) || errors.Is(err, errRepositoryFailure) || invites.invites[1] != seeded {
t.Errorf("unexpected result %v", err)
@@ -495,9 +495,9 @@ func TestManageInviteUseCases(t *testing.T) {
mail := newMockMailRepo()
invites.seed(pendingInvite("a@renew.example"))
mail.fail.failNextWith("Send", errors.New("smtp down"))
renew := useCases.NewRenewInviteUseCase(users, &renewRestoreFailRepo{inviteFakeRepo: invites}, &stubHasher{}, mail, invitePublicURL, discardRecorder)
renew := useCases.NewRenewInviteUseCase(users, &renewRestoreFailRepo{inviteFakeRepo: invites}, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder)
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7})
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7})
if !errors.Is(err, invite.ErrInviteEmailNotSent) || !errors.Is(err, errRepositoryFailure) {
t.Errorf("unexpected result %v", err)
@@ -530,9 +530,9 @@ func TestManageInviteUseCases(t *testing.T) {
mail := newMockMailRepo()
seeded := invites.seed(pendingInvite("a@renew.example"))
invites.fail.failNextWith("Renew", errRepositoryFailure)
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder)
renew := useCases.NewRenewInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder)
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7})
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7})
if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 || invites.invites[1] != seeded {
t.Errorf("unexpected result %v", err)
@@ -544,9 +544,9 @@ func TestManageInviteUseCases(t *testing.T) {
invites := newInviteFakeRepo()
mail := newMockMailRepo()
invites.seed(pendingInvite("a@renew.example"))
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder)
renew := useCases.NewRenewInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder)
if _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}); err != nil {
if _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7}); err != nil {
t.Fatalf("unexpected %v", err)
}
if inviteMailData(t, mail).InvitedBy != "Marta L." {
@@ -565,7 +565,7 @@ func TestInviteRepositoryRenew(t *testing.T) {
}
repo := persistence.NewInviteRepository(db)
now := time.Now().UTC().Truncate(time.Second)
created, err := repo.Create(invite.Invite{Email: "a@renew.example", HashedCode: "old", InvitedBy: 7, CreatedAt: now.Add(-96 * time.Hour), ExpiresAt: now.Add(-24 * time.Hour)})
created, err := repo.Create(invite.Invite{Email: "a@renew.example", HashedCode: "old", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: now.Add(-96 * time.Hour), ExpiresAt: now.Add(-24 * time.Hour)})
if err != nil {
t.Fatalf("create failed: %v", err)
}