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("GetByName") 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()) } }