215 lines
9.0 KiB
Go
215 lines
9.0 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/internal/domain/audit"
|
|
"RenewCMS/internal/domain/contenttype"
|
|
"RenewCMS/internal/domain/role"
|
|
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
|
"RenewCMS/internal/infrastructure/useCases"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
func TestRoleRepositoryRolesAPIHardening(t *testing.T) {
|
|
t.Run("ReassignMembers refuses a target that does not exist and moves nobody", func(t *testing.T) {
|
|
db := openMigratedRolesDB(t)
|
|
repo := persistence.NewRoleRepository(db)
|
|
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
|
createUserRow(t, db, 1, &source.ID)
|
|
moved, err := repo.ReassignMembers(source.ID, 9999)
|
|
if !errors.Is(err, role.ErrInvalidReassign) || moved != 0 {
|
|
t.Fatalf("expected ErrInvalidReassign, got %d %v", moved, err)
|
|
}
|
|
if roleIDOf(userRow(t, db, 1)) != source.ID {
|
|
t.Fatal("the member must stay")
|
|
}
|
|
})
|
|
|
|
t.Run("ReassignMembers from an empty role to an existing one is a plain zero", func(t *testing.T) {
|
|
db := openMigratedRolesDB(t)
|
|
repo := persistence.NewRoleRepository(db)
|
|
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
|
writer := storedRole(t, db, "Writer")
|
|
moved, err := repo.ReassignMembers(source.ID, writer.ID)
|
|
if err != nil || moved != 0 {
|
|
t.Fatalf("expected 0 nil, got %d %v", moved, err)
|
|
}
|
|
})
|
|
|
|
t.Run("Create re-checks the name case-insensitively", func(t *testing.T) {
|
|
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
|
|
rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
|
for _, name := range []string{"contributor", "CONTRIBUTOR", " Contributor "} {
|
|
_, err := repo.Create(role.Role{Name: name, Version: 1})
|
|
if !errors.Is(err, role.ErrRoleNameTaken) {
|
|
t.Errorf("%q: expected ErrRoleNameTaken, got %v", name, err)
|
|
}
|
|
}
|
|
listed, _ := repo.List()
|
|
if len(listed) != 4 {
|
|
t.Fatalf("no role may be added, got %d", len(listed))
|
|
}
|
|
})
|
|
|
|
t.Run("Save re-checks the name against other roles only", func(t *testing.T) {
|
|
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
|
|
first := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
|
second := rolesAPICreateRole(t, repo, "Intern", "content.read=own")
|
|
clash := second
|
|
clash.Name = "contributor"
|
|
if _, err := repo.Save(clash); !errors.Is(err, role.ErrRoleNameTaken) {
|
|
t.Fatalf("expected ErrRoleNameTaken, got %v", err)
|
|
}
|
|
if stored, _ := repo.Get(second.ID); stored.Name != "Intern" || stored.Version != 1 {
|
|
t.Fatalf("the role must be unchanged: %+v", stored)
|
|
}
|
|
recased := first
|
|
recased.Name = "CONTRIBUTOR"
|
|
if saved, err := repo.Save(recased); err != nil || saved.Name != "CONTRIBUTOR" {
|
|
t.Fatalf("a role may change the case of its own name, got %+v %v", saved, err)
|
|
}
|
|
})
|
|
|
|
t.Run("Delete with the row lock still removes a memberless role and refuses one with a member", func(t *testing.T) {
|
|
db := openMigratedRolesDB(t)
|
|
repo := persistence.NewRoleRepository(db)
|
|
kept := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
|
gone := rolesAPICreateRole(t, repo, "Intern", "content.read=own")
|
|
createUserRow(t, db, 1, &kept.ID)
|
|
if err := repo.Delete(kept.ID); !errors.Is(err, role.ErrRoleHasMembers) {
|
|
t.Fatalf("expected ErrRoleHasMembers, got %v", err)
|
|
}
|
|
if err := repo.Delete(gone.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRolesAPIHardeningUseCases(t *testing.T) {
|
|
t.Run("stale typed rows never show in a view and a GET body stays a valid PUT body", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
stale, _ := world.roles.Create(role.Role{Name: "Stale", Version: 1, Grants: grantRows("content.read=all", "content.update:ghost=all", "content.update:events=own")})
|
|
got, err := world.container.GetRoleUseCase.Get(stale.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []string{"content.read=all", "content.update:events=own"}
|
|
if !reflect.DeepEqual(rolesAPIGrantKeys(got.Grants), want) {
|
|
t.Fatalf("unexpected grants %v", rolesAPIGrantKeys(got.Grants))
|
|
}
|
|
listed, err := world.container.ListRolesUseCase.List()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, item := range listed {
|
|
if item.ID == stale.ID && !reflect.DeepEqual(rolesAPIGrantKeys(item.Grants), want) {
|
|
t.Fatalf("unexpected listed grants %v", rolesAPIGrantKeys(item.Grants))
|
|
}
|
|
}
|
|
updated, err := world.container.UpdateRoleUseCase.Update(context.Background(), useCases.UpdateRoleCommand{Caller: world.caller(raAdmin), ID: stale.ID, Name: "Stale", Grants: got.Grants, Version: got.Version})
|
|
if err != nil {
|
|
t.Fatalf("a GET body must be accepted, got %v", err)
|
|
}
|
|
if updated.Version != 1 || len(world.recorder.events) != 0 {
|
|
t.Fatalf("a body identical to the view is a no-op: version %d events %d", updated.Version, len(world.recorder.events))
|
|
}
|
|
})
|
|
|
|
t.Run("a failed content type create does not bump any role", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
delete(world.types.types, raEventsTypeID)
|
|
world.types.fail.failNext("Create")
|
|
_, err := world.container.CreateContentTypeUseCase.Create(useCases.CreateContentTypeCommand{Key: "events", Name: "Events", Fields: validContentTypeFields()})
|
|
if err == nil {
|
|
t.Fatal("expected the create to fail")
|
|
}
|
|
if world.roles.calls["RemoveTypedGrants"] != 0 || world.role("Events editor").Version != 1 {
|
|
t.Fatal("no role may be touched by a failed create")
|
|
}
|
|
})
|
|
|
|
t.Run("a failed cleanup after a create is reported", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
delete(world.types.types, raEventsTypeID)
|
|
world.roles.fail.failNext("RemoveTypedGrants")
|
|
_, err := world.container.CreateContentTypeUseCase.Create(useCases.CreateContentTypeCommand{Key: "events", Name: "Events", Fields: validContentTypeFields()})
|
|
if err == nil {
|
|
t.Fatal("expected the failure to be reported")
|
|
}
|
|
})
|
|
|
|
t.Run("a target that vanished is an invalid reassign and records nothing", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
world.roles.fail.failNextWith("ReassignMembers", role.ErrInvalidReassign)
|
|
moved, err := world.reassign(raAdmin, "Contributor", world.ids["Writer"])
|
|
if !errors.Is(err, role.ErrInvalidReassign) || moved != 0 || len(world.recorder.events) != 0 {
|
|
t.Fatalf("unexpected result %d %v events=%d", moved, err, len(world.recorder.events))
|
|
}
|
|
})
|
|
|
|
t.Run("a reassign failure event carries from, to and a zero moved", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
world.roles.fail.failNext("ReassignMembers")
|
|
_, _ = world.reassign(raAdmin, "Contributor", world.ids["Writer"])
|
|
params := world.recorder.events[0].Params
|
|
if params[audit.ParamFrom] != world.ids["Contributor"] || params[audit.ParamTo] != world.ids["Writer"] || params[audit.ParamMoved] != 0 {
|
|
t.Fatalf("unexpected params %+v", params)
|
|
}
|
|
})
|
|
|
|
t.Run("a member count failure while deleting a role is reported and deletes nothing", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
world.roles.fail.failNext("MemberCounts")
|
|
err := world.remove(raAdmin, "Intern")
|
|
if !errors.Is(err, errRepositoryFailure) {
|
|
t.Fatalf("expected the repository failure, got %v", err)
|
|
}
|
|
if world.roles.calls["Delete"] != 0 {
|
|
t.Fatal("nothing may be deleted")
|
|
}
|
|
})
|
|
|
|
t.Run("a content type that references itself can be deleted but one referenced by another cannot", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
selfRef := validContentTypeFields()
|
|
selfRef = append(selfRef, contenttype.Field{Key: "parent", Label: "Parent", Type: contenttype.TypeRelation, Options: []string{}, Relation: &contenttype.Relation{ContentType: "events", Kind: contenttype.RelationOneToMany}})
|
|
events := world.types.types[raEventsTypeID]
|
|
events.Fields = selfRef
|
|
world.types.types[raEventsTypeID] = events
|
|
if err := world.container.DeleteContentTypeUseCase.Delete("events"); err != nil {
|
|
t.Fatalf("a self reference must not block the delete, got %v", err)
|
|
}
|
|
venues := world.types.types[raVenuesTypeID]
|
|
venues.Fields = append(validContentTypeFields(), contenttype.Field{Key: "owner", Label: "Owner", Type: contenttype.TypeRelation, Options: []string{}, Relation: &contenttype.Relation{ContentType: "other", Kind: contenttype.RelationOneToMany}})
|
|
world.types.types[raVenuesTypeID] = venues
|
|
world.types.types[7] = contenttype.ContentType{ID: 7, Key: "other", Name: "Other", Fields: validContentTypeFields()}
|
|
if err := world.container.DeleteContentTypeUseCase.Delete("other"); !errors.Is(err, contenttype.ErrReferenced) {
|
|
t.Fatalf("expected ErrReferenced, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestEscalationErrorMessage(t *testing.T) {
|
|
err := role.EscalationError{Key: "content.update:events"}
|
|
if err.Error() != role.ErrEscalation.Error()+": content.update:events" || !errors.Is(err, role.ErrEscalation) {
|
|
t.Fatalf("unexpected error %q", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestRolesAPIHardeningQuietSuccess(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
var sink bytes.Buffer
|
|
log.SetOutput(&sink)
|
|
defer log.SetOutput(os.Stderr)
|
|
rec := world.do(t, raAdmin, http.MethodGet, "/v1/role", "")
|
|
if rec.Code != http.StatusOK || sink.Len() != 0 {
|
|
t.Fatalf("a served response must not log: %d %q", rec.Code, sink.String())
|
|
}
|
|
}
|