package test import ( "RenewCMS/internal/domain/role" "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "errors" "reflect" "sync" "testing" "time" ) func rolesAPIGrantKeys(grants []role.Grant) []string { keys := []string{} for _, grant := range grants { keys = append(keys, grant.Key+"="+string(grant.Scope)) } return keys } func rolesAPICreateRole(t *testing.T, repo *persistence.RoleRepository, name string, rows ...string) role.Role { t.Helper() created, err := repo.Create(role.Role{Name: name, Version: 1, Grants: grantRows(rows...)}) if err != nil { t.Fatal(err) } return created } func TestRoleRepositoryRolesAPI(t *testing.T) { t.Run("List returns the grants of every role in one pass", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewRoleRepository(db) rolesAPICreateRole(t, repo, "Contributor", "content.read=all", "content.update:events=own") listed, err := repo.List() if err != nil { t.Fatal(err) } if len(listed) != 4 { t.Fatalf("expected 4 roles, got %d", len(listed)) } for index, expected := range []int{34, 29, 9, 2} { if len(listed[index].Grants) != expected { t.Errorf("role %s: expected %d grants, got %d", listed[index].Name, expected, len(listed[index].Grants)) } } }) t.Run("seeded roles have no editor and no edit time", func(t *testing.T) { repo := persistence.NewRoleRepository(openMigratedRolesDB(t)) listed, _ := repo.List() for _, seeded := range listed { if seeded.UpdatedBy != nil || !seeded.UpdatedAt.IsZero() { t.Errorf("role %s: unexpected editor %v at %v", seeded.Name, seeded.UpdatedBy, seeded.UpdatedAt) } } }) t.Run("Save writes the editor and the time and bumps the version", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewRoleRepository(db) created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all") editor := uint32(5) edited := created edited.Description = "changed" edited.UpdatedAt = time.Date(2026, time.October, 5, 11, 2, 0, 0, time.UTC) edited.UpdatedBy = &editor saved, err := repo.Save(edited) if err != nil { t.Fatal(err) } if saved.Version != 2 || saved.UpdatedBy == nil || *saved.UpdatedBy != 5 || !saved.UpdatedAt.Equal(edited.UpdatedAt) { t.Fatalf("unexpected saved role %+v", saved) } }) t.Run("Save is a compare-and-swap on the version", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewRoleRepository(db) created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all") first := created first.Description = "first" second := created second.Description = "second" if _, err := repo.Save(first); err != nil { t.Fatal(err) } if _, err := repo.Save(second); !errors.Is(err, role.ErrRoleConflict) { t.Fatalf("expected ErrRoleConflict, got %v", err) } stored, _ := repo.Get(created.ID) if stored.Description != "first" || stored.Version != 2 { t.Fatalf("unexpected stored role %+v", stored) } }) t.Run("two concurrent saves of version 1 give exactly one winner", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewRoleRepository(db) created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all") results := make([]error, 2) var group sync.WaitGroup for index := range results { group.Add(1) go func() { defer group.Done() attempt := created attempt.Description = []string{"a", "b"}[index] _, results[index] = repo.Save(attempt) }() } group.Wait() successes := 0 for _, err := range results { if err == nil { successes++ } else if !errors.Is(err, role.ErrRoleConflict) { t.Fatalf("unexpected error %v", err) } } if successes != 1 { t.Fatalf("expected exactly one winner, got %d", successes) } }) t.Run("Delete removes the role and its grants", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewRoleRepository(db) created := rolesAPICreateRole(t, repo, "Intern", "content.read=own") rowsBefore := countRows(t, db, &models.RolePermission{}) if err := repo.Delete(created.ID); err != nil { t.Fatal(err) } if _, err := repo.Get(created.ID); !errors.Is(err, role.ErrRoleNotFound) { t.Fatalf("expected the role to be gone, got %v", err) } if countRows(t, db, &models.RolePermission{}) != rowsBefore-1 { t.Error("the grant rows were not removed") } }) t.Run("Delete refuses a role that still has members", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewRoleRepository(db) created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all") createUserRow(t, db, 1, "", &created.ID) if err := repo.Delete(created.ID); !errors.Is(err, role.ErrRoleHasMembers) { t.Fatalf("expected ErrRoleHasMembers, got %v", err) } if _, err := repo.Get(created.ID); err != nil { t.Fatalf("the role must still exist: %v", err) } }) t.Run("Delete reports a missing role", func(t *testing.T) { repo := persistence.NewRoleRepository(openMigratedRolesDB(t)) if err := repo.Delete(9999); !errors.Is(err, role.ErrRoleNotFound) { t.Fatalf("expected ErrRoleNotFound, got %v", err) } }) t.Run("ReassignMembers counts the moved users and touches only role_id", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewRoleRepository(db) source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all") writer := storedRole(t, db, "Writer") createUserRow(t, db, 1, "", &source.ID) createUserRow(t, db, 2, "", &source.ID) createUserRow(t, db, 3, "", &writer.ID) before := userRow(t, db, 1) moved, err := repo.ReassignMembers(source.ID, writer.ID) if err != nil || moved != 2 { t.Fatalf("expected 2 moved, got %d %v", moved, err) } after := userRow(t, db, 1) if roleIDOf(after) != writer.ID { t.Errorf("user 1 was not moved") } after.RoleID, before.RoleID = nil, nil if !reflect.DeepEqual(before, after) { t.Errorf("only role_id may change: %+v vs %+v", before, after) } if moved, _ := repo.ReassignMembers(source.ID, writer.ID); moved != 0 { t.Errorf("a second move must move nobody, got %d", moved) } }) t.Run("RemoveTypedGrants removes the five typed forms of one type only and bumps affected roles once", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewRoleRepository(db) affected := rolesAPICreateRole(t, repo, "Events editor", "content.read=all", "content.update:events=own", "content.delete:events=none", "content.read:events=all", "content.create:events=all", "content.publish:events=own", "content.update:venues=all") untouched := rolesAPICreateRole(t, repo, "Venues only", "content.update:venues=all", "content.read=all") removed, err := repo.RemoveTypedGrants("events") if err != nil || removed != 1 { t.Fatalf("expected 1 affected role, got %d %v", removed, err) } stored, _ := repo.Get(affected.ID) if !reflect.DeepEqual(rolesAPIGrantKeys(stored.Grants), []string{"content.read=all", "content.update:venues=all"}) || stored.Version != 2 { t.Fatalf("unexpected affected role %v version %d", rolesAPIGrantKeys(stored.Grants), stored.Version) } other, _ := repo.Get(untouched.ID) if other.Version != 1 || len(other.Grants) != 2 { t.Fatalf("the other role must be untouched: %+v", other) } if again, _ := repo.RemoveTypedGrants("events"); again != 0 { t.Errorf("a second removal must affect nothing, got %d", again) } }) t.Run("a content type key that prefixes another is not matched", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewRoleRepository(db) kept := rolesAPICreateRole(t, repo, "Long key", "content.update:events_extra=all") if removed, _ := repo.RemoveTypedGrants("events"); removed != 0 { t.Fatalf("expected nothing removed, got %d", removed) } stored, _ := repo.Get(kept.ID) if len(stored.Grants) != 1 { t.Fatal("the grant of events_extra must be kept") } }) t.Run("the new methods fail on a broken database", func(t *testing.T) { for name, call := range map[string]func(*persistence.RoleRepository) error{ "Delete": func(r *persistence.RoleRepository) error { return r.Delete(4) }, "ReassignMembers": func(r *persistence.RoleRepository) error { _, err := r.ReassignMembers(4, 3); return err }, "RemoveTypedGrants": func(r *persistence.RoleRepository) error { _, err := r.RemoveTypedGrants("events"); return err }, } { db := openMigratedRolesDB(t) for _, table := range []string{"role_permissions", "users"} { if err := db.Migrator().DropTable(table); err != nil { t.Fatal(err) } } sqlDB, _ := db.DB() _ = sqlDB.Close() if err := call(persistence.NewRoleRepository(db)); err == nil || errors.Is(err, role.ErrRoleNotFound) { t.Errorf("%s: expected a storage failure, got %v", name, err) } } }) t.Run("the role columns exist after Migrate", func(t *testing.T) { db := openMigratedRolesDB(t) for _, column := range []string{"updated_at", "updated_by"} { if !db.Migrator().HasColumn(&models.Role{}, column) { t.Errorf("roles.%s is missing", column) } } }) }