mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-11-09 05:52:52 -05:00
feat(cli): add report command for users per role
Add `opencloud report`, listing the users of a running instance grouped by their assigned role, with the number that signed in within the last --days days (31 by default) per role. Reads the libregraph API as the configured service account. Output is a table by default, -o/--output also takes json, csv and yaml. Sign-in counts are reported as N/A when the instance records no sign-in timestamps at all, e.g. with a read-only identity backend.
This commit is contained in:
1 parent
369d386e6b
commit
fb9e9df8d2
2 files changed
+866
No files matched your search
@@ -0,0 +1,474 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/opencloud-eu/opencloud/opencloud/pkg/register"
|
||||
"github.com/opencloud-eu/opencloud/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/pkg/config/configlog"
|
||||
"github.com/opencloud-eu/opencloud/pkg/config/parser"
|
||||
mregistry "github.com/opencloud-eu/opencloud/pkg/registry"
|
||||
graphparser "github.com/opencloud-eu/opencloud/services/graph/pkg/config/parser"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
_reportOutputFlagName = "output"
|
||||
_reportDaysFlagName = "days"
|
||||
|
||||
// _reportDefaultDays is the default sign-in window in days.
|
||||
_reportDefaultDays = 31
|
||||
|
||||
// _reportNoRole is the bucket for users without any assigned role.
|
||||
_reportNoRole = "(no role assigned)"
|
||||
|
||||
// _reportRequestTimeout caps the whole report run.
|
||||
_reportRequestTimeout = 1 * time.Minute
|
||||
|
||||
_reportOutputTable = "table"
|
||||
_reportOutputJSON = "json"
|
||||
_reportOutputCSV = "csv"
|
||||
_reportOutputYAML = "yaml"
|
||||
)
|
||||
|
||||
// roleCount is the number of users sharing one assigned role.
|
||||
type roleCount struct {
|
||||
Role string `json:"role" yaml:"role"`
|
||||
// Users counts every user holding the role, whether or not a sign-in is
|
||||
// known for them.
|
||||
Users int `json:"users" yaml:"users"`
|
||||
// SignedIn counts the subset that signed in within the reported window. It
|
||||
// is nil when the instance does not record sign-ins at all, to tell "not
|
||||
// available" apart from "nobody signed in".
|
||||
SignedIn *int `json:"signedIn" yaml:"signedIn"`
|
||||
}
|
||||
|
||||
// userReport is the result of the report command.
|
||||
type userReport struct {
|
||||
GeneratedAt time.Time `json:"generatedAt" yaml:"generatedAt"`
|
||||
Days int `json:"days" yaml:"days"`
|
||||
ActiveSince time.Time `json:"activeSince" yaml:"activeSince"`
|
||||
TotalUsers int `json:"totalUsers" yaml:"totalUsers"`
|
||||
// ActiveUsers is the number of users that signed in within the reported
|
||||
// window. Like roleCount.SignedIn it is nil when the instance does not
|
||||
// record sign-ins at all.
|
||||
ActiveUsers *int `json:"activeUsers" yaml:"activeUsers"`
|
||||
// UsersWithSignInData is the number of users that have a recorded sign-in
|
||||
// at all, regardless of the reported window. It is 0 when the deployment
|
||||
// does not store sign-in timestamps.
|
||||
UsersWithSignInData int `json:"usersWithSignInData" yaml:"usersWithSignInData"`
|
||||
Roles []roleCount `json:"roles" yaml:"roles"`
|
||||
}
|
||||
|
||||
// ReportCommand is the entrypoint for the report command.
|
||||
func ReportCommand(cfg *config.Config) *cobra.Command {
|
||||
reportCmd := &cobra.Command{
|
||||
Use: "report",
|
||||
Short: "report the users of this instance grouped by their assigned role",
|
||||
Long: "Report the users of OpenCloud grouped by their assigned role.\n\n" +
|
||||
"Every user is counted, and each role additionally shows how many of its\n" +
|
||||
"users signed in successfully within the last --days days. Users whose\n" +
|
||||
"sign-in is unknown still count towards their role.\n" +
|
||||
"The data is read from the libregraph API of a running OpenCloud instance,\n" +
|
||||
"so the instance needs to be up while the report is generated.",
|
||||
GroupID: CommandGroupServer,
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Parse base config
|
||||
if err := parser.ParseConfig(cfg, true); err != nil {
|
||||
return configlog.ReturnError(err)
|
||||
}
|
||||
|
||||
// Parse graph config, the report talks to the graph service
|
||||
cfg.Graph.Commons = cfg.Commons
|
||||
return configlog.ReturnError(graphparser.ParseConfig(cfg.Graph))
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
days, err := cmd.Flags().GetInt(_reportDaysFlagName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if days < 1 {
|
||||
return fmt.Errorf("--%s must be at least 1", _reportDaysFlagName)
|
||||
}
|
||||
|
||||
format, err := cmd.Flags().GetString(_reportOutputFlagName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Contains([]string{_reportOutputTable, _reportOutputJSON, _reportOutputCSV, _reportOutputYAML}, format) {
|
||||
return fmt.Errorf("unknown output format %q, supported formats are: %s, %s, %s, %s",
|
||||
format, _reportOutputTable, _reportOutputJSON, _reportOutputCSV, _reportOutputYAML)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), _reportRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
report, err := userRoleReport(ctx, cfg, days)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return renderUserReport(os.Stdout, report, format)
|
||||
},
|
||||
}
|
||||
|
||||
reportCmd.Flags().IntP(_reportDaysFlagName, "d", _reportDefaultDays,
|
||||
"window in days for counting users who signed in successfully")
|
||||
reportCmd.Flags().StringP(_reportOutputFlagName, "o", _reportOutputTable,
|
||||
fmt.Sprintf("output format, one of: %s, %s, %s, %s",
|
||||
_reportOutputTable, _reportOutputJSON, _reportOutputCSV, _reportOutputYAML))
|
||||
|
||||
return reportCmd
|
||||
}
|
||||
|
||||
func init() {
|
||||
register.AddCommand(ReportCommand)
|
||||
}
|
||||
|
||||
// userRoleReport collects all users from the libregraph API and groups the ones
|
||||
// that signed in within the last `days` days by their assigned role.
|
||||
func userRoleReport(ctx context.Context, cfg *config.Config, days int) (userReport, error) {
|
||||
client, err := newLibregraphServiceAccountClient(ctx, cfg)
|
||||
if err != nil {
|
||||
return userReport{}, err
|
||||
}
|
||||
|
||||
roleNames, err := appRoleNames(ctx, client)
|
||||
if err != nil {
|
||||
return userReport{}, err
|
||||
}
|
||||
|
||||
users, err := listUsersWithRoleAssignments(ctx, client)
|
||||
if err != nil {
|
||||
return userReport{}, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
activeSince := now.AddDate(0, 0, -days)
|
||||
roles, activeUsers, withSignInData := countUsersByRole(users, roleNames, activeSince)
|
||||
|
||||
return userReport{
|
||||
GeneratedAt: now,
|
||||
Days: days,
|
||||
ActiveSince: activeSince,
|
||||
TotalUsers: len(users),
|
||||
ActiveUsers: activeUsers,
|
||||
UsersWithSignInData: withSignInData,
|
||||
Roles: roles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// countUsersByRole groups every user by the display names of their assigned
|
||||
// roles, counting for each role both its total number of users and the subset
|
||||
// that signed in at or after activeSince. Users whose sign-in is unknown are
|
||||
// still counted towards their role. It returns the counts ordered by size, the
|
||||
// number of users that signed in within the window and the number of users
|
||||
// that have a recorded sign-in at all. Users with multiple roles are counted
|
||||
// once per role.
|
||||
//
|
||||
// When the instance holds no sign-in data at all the sign-in counts are
|
||||
// returned as nil rather than 0, so that callers can report them as not
|
||||
// available instead of claiming nobody signed in.
|
||||
func countUsersByRole(users []libregraph.User, roleNames map[string]string, activeSince time.Time) ([]roleCount, *int, int) {
|
||||
totals := make(map[string]int, len(roleNames)+1)
|
||||
signedIn := make(map[string]int, len(roleNames)+1)
|
||||
activeUsers := 0
|
||||
withSignInData := 0
|
||||
for _, u := range users {
|
||||
signInActivity := u.GetSignInActivity()
|
||||
lastSignIn, hasSignIn := signInActivity.GetLastSuccessfulSignInDateTimeOk()
|
||||
if hasSignIn {
|
||||
withSignInData++
|
||||
}
|
||||
active := hasSignIn && !lastSignIn.Before(activeSince)
|
||||
if active {
|
||||
activeUsers++
|
||||
}
|
||||
|
||||
for _, name := range assignedRoleNames(u, roleNames) {
|
||||
totals[name]++
|
||||
if active {
|
||||
signedIn[name]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// without a single recorded sign-in we cannot tell an inactive user from
|
||||
// one whose sign-in was never stored
|
||||
signInKnown := withSignInData > 0 || len(users) == 0
|
||||
|
||||
roles := make([]roleCount, 0, len(totals))
|
||||
for name, total := range totals {
|
||||
roles = append(roles, roleCount{
|
||||
Role: name,
|
||||
Users: total,
|
||||
SignedIn: optionalCount(signedIn[name], signInKnown),
|
||||
})
|
||||
}
|
||||
// biggest role first, alphabetically for equal counts
|
||||
slices.SortFunc(roles, func(a, b roleCount) int {
|
||||
if c := cmp.Compare(b.Users, a.Users); c != 0 {
|
||||
return c
|
||||
}
|
||||
return cmp.Compare(a.Role, b.Role)
|
||||
})
|
||||
|
||||
return roles, optionalCount(activeUsers, signInKnown), withSignInData
|
||||
}
|
||||
|
||||
// optionalCount returns a pointer to count, or nil when the count is not known.
|
||||
func optionalCount(count int, known bool) *int {
|
||||
if !known {
|
||||
return nil
|
||||
}
|
||||
return &count
|
||||
}
|
||||
|
||||
// assignedRoleNames resolves the roles of a user to their display names. Users
|
||||
// without any assignment get a bucket of their own, so that they still show up
|
||||
// in the report.
|
||||
func assignedRoleNames(user libregraph.User, roleNames map[string]string) []string {
|
||||
assignments := user.GetAppRoleAssignments()
|
||||
if len(assignments) == 0 {
|
||||
return []string{_reportNoRole}
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(assignments))
|
||||
for _, a := range assignments {
|
||||
name, ok := roleNames[a.GetAppRoleId()]
|
||||
if !ok {
|
||||
// an assignment pointing to a role the application no longer
|
||||
// exposes, report the raw id so it doesn't vanish silently
|
||||
name = a.GetAppRoleId()
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// newLibregraphServiceAccountClient builds a libregraph client authenticated as
|
||||
// the configured service account. The graph endpoint is looked up in the
|
||||
// service registry, so this only works against a running instance.
|
||||
func newLibregraphServiceAccountClient(ctx context.Context, cfg *config.Config) (*libregraph.APIClient, error) {
|
||||
// Initialize registry to make service lookup work
|
||||
reg := mregistry.GetRegistry()
|
||||
|
||||
gatewaySelector, err := pool.GatewaySelector(cfg.Graph.Reva.Address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gatewayClient, err := gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, err := utils.GetServiceUserToken(
|
||||
ctx,
|
||||
gatewayClient,
|
||||
cfg.Graph.ServiceAccount.ServiceAccountID,
|
||||
cfg.Graph.ServiceAccount.ServiceAccountSecret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not authenticate the service account: %w", err)
|
||||
}
|
||||
|
||||
serviceName := cfg.Graph.HTTP.Namespace + "." + cfg.Graph.Service.Name
|
||||
services, err := reg.GetService(serviceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not look up the '%s' service, is OpenCloud running? %w", serviceName, err)
|
||||
}
|
||||
|
||||
var baseURL string
|
||||
for _, s := range services {
|
||||
for _, n := range s.Nodes {
|
||||
protocol := n.Metadata["protocol"]
|
||||
if protocol == "" {
|
||||
protocol = "http"
|
||||
}
|
||||
baseURL = fmt.Sprintf("%s://%s%s", protocol, n.Address, cfg.Graph.HTTP.Root)
|
||||
break
|
||||
}
|
||||
if baseURL != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("no running instance of the '%s' service found", serviceName)
|
||||
}
|
||||
|
||||
lgconf := libregraph.NewConfiguration()
|
||||
lgconf.Servers = libregraph.ServerConfigurations{{URL: baseURL}}
|
||||
lgconf.DefaultHeader = map[string]string{revactx.TokenHeader: token}
|
||||
lgconf.HTTPClient = &http.Client{Timeout: _reportRequestTimeout}
|
||||
|
||||
return libregraph.NewAPIClient(lgconf), nil
|
||||
}
|
||||
|
||||
// appRoleNames maps the app role ids of the OpenCloud application to their
|
||||
// display names, e.g. "Admin" or "Space Admin".
|
||||
func appRoleNames(ctx context.Context, client *libregraph.APIClient) (map[string]string, error) {
|
||||
applications, resp, err := client.ApplicationsApi.ListApplications(ctx).Execute()
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not list applications: %w", err)
|
||||
}
|
||||
|
||||
names := map[string]string{}
|
||||
for _, application := range applications.GetValue() {
|
||||
for _, role := range application.GetAppRoles() {
|
||||
name := role.GetDisplayName()
|
||||
if name == "" {
|
||||
name = role.GetId()
|
||||
}
|
||||
names[role.GetId()] = name
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// listUsersWithRoleAssignments returns all users including their app role assignments.
|
||||
func listUsersWithRoleAssignments(ctx context.Context, client *libregraph.APIClient) ([]libregraph.User, error) {
|
||||
users, resp, err := client.UsersApi.ListUsers(ctx).Expand([]string{"appRoleAssignments"}).Execute()
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not list users: %w", err)
|
||||
}
|
||||
return users.GetValue(), nil
|
||||
}
|
||||
|
||||
// renderUserReport writes the report in the requested output format.
|
||||
func renderUserReport(w io.Writer, report userReport, format string) error {
|
||||
switch format {
|
||||
case _reportOutputJSON:
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(report)
|
||||
case _reportOutputYAML:
|
||||
enc := yaml.NewEncoder(w)
|
||||
defer enc.Close()
|
||||
return enc.Encode(report)
|
||||
case _reportOutputCSV:
|
||||
cw := csv.NewWriter(w)
|
||||
if err := cw.Write([]string{
|
||||
"days", "activeSince", "totalUsers", "activeUsers", "usersWithSignInData",
|
||||
"role", "users", "signedIn",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// the report context is repeated per row so that a single flat table
|
||||
// carries the totals as well, even when there is no role at all
|
||||
prefix := []string{
|
||||
strconv.Itoa(report.Days),
|
||||
report.ActiveSince.Format(time.RFC3339),
|
||||
strconv.Itoa(report.TotalUsers),
|
||||
csvCount(report.ActiveUsers),
|
||||
strconv.Itoa(report.UsersWithSignInData),
|
||||
}
|
||||
if len(report.Roles) == 0 {
|
||||
// a placeholder row keeps the totals readable for a consumer that
|
||||
// only looks at the rows; its sign-in cell follows availability
|
||||
noSignedIn := csvCount(optionalCount(0, report.ActiveUsers != nil))
|
||||
if err := cw.Write(append(prefix, "", "0", noSignedIn)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, r := range report.Roles {
|
||||
row := append(prefix, r.Role, strconv.Itoa(r.Users), csvCount(r.SignedIn))
|
||||
if err := cw.Write(row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
default:
|
||||
fmt.Fprintf(w, "Sign-in window: last %s (since %s)\n\n",
|
||||
pluralDays(report.Days), report.ActiveSince.Format(time.RFC3339))
|
||||
|
||||
signedInLabel := "Signed in within the last " + pluralDays(report.Days)
|
||||
totalLabel := "Users in total:"
|
||||
activeLabel := signedInLabel + ":"
|
||||
knownLabel := "Users with a recorded sign-in:"
|
||||
labelWidth := max(len(totalLabel), len(activeLabel), len(knownLabel))
|
||||
fmt.Fprintf(w, "%-*s %d\n", labelWidth, totalLabel, report.TotalUsers)
|
||||
fmt.Fprintf(w, "%-*s %s\n", labelWidth, activeLabel, tableCount(report.ActiveUsers))
|
||||
fmt.Fprintf(w, "%-*s %d\n\n", labelWidth, knownLabel, report.UsersWithSignInData)
|
||||
|
||||
table := tablewriter.NewTable(w, tablewriter.WithConfig(tablewriter.Config{
|
||||
Header: tw.CellConfig{
|
||||
Formatting: tw.CellFormatting{AutoFormat: tw.Off},
|
||||
},
|
||||
Row: tw.CellConfig{
|
||||
Alignment: tw.CellAlignment{
|
||||
PerColumn: []tw.Align{tw.AlignLeft, tw.AlignRight, tw.AlignRight},
|
||||
},
|
||||
},
|
||||
}))
|
||||
table.Header([]string{"Role", "Users", signedInLabel})
|
||||
for _, r := range report.Roles {
|
||||
row := []string{r.Role, strconv.Itoa(r.Users), tableCount(r.SignedIn)}
|
||||
if err := table.Append(row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := table.Render(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if report.TotalUsers > 0 && report.UsersWithSignInData == 0 {
|
||||
fmt.Fprint(w, "\nWarning: not a single user has a recorded sign-in, so the sign-in\n"+
|
||||
"counts are reported as N/A rather than 0. This instance does not store\n"+
|
||||
"sign-in timestamps. They are only recorded when the identity backend\n"+
|
||||
"is writable, see OC_LDAP_SERVER_WRITE_ENABLED, and when the directory\n"+
|
||||
"holds the OpenCloud LDAP schema.\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// tableCount renders a sign-in count for human readable output, spelling out
|
||||
// that it is not available rather than showing a misleading 0.
|
||||
func tableCount(count *int) string {
|
||||
if count == nil {
|
||||
return "N/A"
|
||||
}
|
||||
return strconv.Itoa(*count)
|
||||
}
|
||||
|
||||
// csvCount renders a sign-in count for csv output, leaving the cell empty when
|
||||
// the count is not available.
|
||||
func csvCount(count *int) string {
|
||||
if count == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(*count)
|
||||
}
|
||||
|
||||
// pluralDays renders a day count for human readable output.
|
||||
func pluralDays(days int) string {
|
||||
if days == 1 {
|
||||
return "1 day"
|
||||
}
|
||||
return strconv.Itoa(days) + " days"
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
)
|
||||
|
||||
const (
|
||||
adminRoleID = "1ecb83a2-d7b2-4b67-9d81-3d3e4f8a9b0c"
|
||||
userRoleID = "d7beeea8-8ff4-4b0e-9d7e-1c1d3c3bd0e1"
|
||||
spaceAdminRoleID = "9f4f8f3f-3e34-4a1a-9a7e-26f1b0a0a3b1"
|
||||
)
|
||||
|
||||
func testRoleNames() map[string]string {
|
||||
return map[string]string{
|
||||
adminRoleID: "Admin",
|
||||
userRoleID: "User",
|
||||
spaceAdminRoleID: "Space Admin",
|
||||
}
|
||||
}
|
||||
|
||||
func count(c int) *int {
|
||||
return &c
|
||||
}
|
||||
|
||||
// testUser builds a user with the given last successful sign-in and role
|
||||
// assignments. A nil lastSignIn means the user never signed in.
|
||||
func testUser(name string, lastSignIn *time.Time, roleIDs ...string) libregraph.User {
|
||||
u := libregraph.User{DisplayName: name, OnPremisesSamAccountName: name}
|
||||
if lastSignIn != nil {
|
||||
u.SetSignInActivity(libregraph.SignInActivity{LastSuccessfulSignInDateTime: lastSignIn})
|
||||
}
|
||||
for _, id := range roleIDs {
|
||||
u.AppRoleAssignments = append(u.AppRoleAssignments, libregraph.AppRoleAssignment{AppRoleId: id})
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestCountUsersByRole(t *testing.T) {
|
||||
activeSince := time.Date(2026, 9, 8, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
recent := activeSince.Add(24 * time.Hour)
|
||||
onCutoff := activeSince
|
||||
stale := activeSince.Add(-time.Second)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
users []libregraph.User
|
||||
expectedRoles []roleCount
|
||||
expectedActive *int
|
||||
expectedWithSignInfo int
|
||||
}{
|
||||
{
|
||||
name: "no users",
|
||||
users: nil,
|
||||
expectedRoles: []roleCount{},
|
||||
expectedActive: count(0),
|
||||
expectedWithSignInfo: 0,
|
||||
},
|
||||
{
|
||||
name: "roles are ordered by count, then by name",
|
||||
users: []libregraph.User{
|
||||
testUser("einstein", &recent, userRoleID),
|
||||
testUser("marie", &recent, userRoleID),
|
||||
testUser("moss", &recent, spaceAdminRoleID),
|
||||
testUser("admin", &recent, adminRoleID),
|
||||
},
|
||||
expectedRoles: []roleCount{
|
||||
{Role: "User", Users: 2, SignedIn: count(2)},
|
||||
{Role: "Admin", Users: 1, SignedIn: count(1)},
|
||||
{Role: "Space Admin", Users: 1, SignedIn: count(1)},
|
||||
},
|
||||
expectedActive: count(4),
|
||||
expectedWithSignInfo: 4,
|
||||
},
|
||||
{
|
||||
name: "users that never signed in still count towards their role",
|
||||
users: []libregraph.User{
|
||||
testUser("einstein", &recent, userRoleID),
|
||||
testUser("marie", nil, userRoleID),
|
||||
},
|
||||
expectedRoles: []roleCount{{Role: "User", Users: 2, SignedIn: count(1)}},
|
||||
expectedActive: count(1),
|
||||
expectedWithSignInfo: 1,
|
||||
},
|
||||
{
|
||||
name: "users outside the window still count towards their role",
|
||||
users: []libregraph.User{
|
||||
testUser("einstein", &onCutoff, userRoleID),
|
||||
testUser("marie", &stale, userRoleID),
|
||||
},
|
||||
expectedRoles: []roleCount{{Role: "User", Users: 2, SignedIn: count(1)}},
|
||||
expectedActive: count(1),
|
||||
expectedWithSignInfo: 2,
|
||||
},
|
||||
{
|
||||
name: "sign-in counts are unknown when no sign-in was ever recorded",
|
||||
users: []libregraph.User{
|
||||
testUser("einstein", nil, userRoleID),
|
||||
testUser("admin", nil, adminRoleID),
|
||||
},
|
||||
expectedRoles: []roleCount{
|
||||
{Role: "Admin", Users: 1, SignedIn: nil},
|
||||
{Role: "User", Users: 1, SignedIn: nil},
|
||||
},
|
||||
expectedActive: nil,
|
||||
expectedWithSignInfo: 0,
|
||||
},
|
||||
{
|
||||
name: "users without a role land in their own bucket",
|
||||
users: []libregraph.User{testUser("einstein", &recent)},
|
||||
expectedRoles: []roleCount{{Role: _reportNoRole, Users: 1, SignedIn: count(1)}},
|
||||
expectedActive: count(1),
|
||||
expectedWithSignInfo: 1,
|
||||
},
|
||||
{
|
||||
name: "users with multiple roles are counted for each of them",
|
||||
users: []libregraph.User{testUser("einstein", &recent, adminRoleID, spaceAdminRoleID)},
|
||||
expectedRoles: []roleCount{
|
||||
{Role: "Admin", Users: 1, SignedIn: count(1)},
|
||||
{Role: "Space Admin", Users: 1, SignedIn: count(1)},
|
||||
},
|
||||
expectedActive: count(1),
|
||||
expectedWithSignInfo: 1,
|
||||
},
|
||||
{
|
||||
name: "unknown roles are reported by their id",
|
||||
users: []libregraph.User{testUser("einstein", &recent, "some-unknown-role-id")},
|
||||
expectedRoles: []roleCount{{Role: "some-unknown-role-id", Users: 1, SignedIn: count(1)}},
|
||||
expectedActive: count(1),
|
||||
expectedWithSignInfo: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
roles, active, withSignInfo := countUsersByRole(tc.users, testRoleNames(), activeSince)
|
||||
|
||||
if tableCount(active) != tableCount(tc.expectedActive) {
|
||||
t.Errorf("expected %s active users, got %s", tableCount(tc.expectedActive), tableCount(active))
|
||||
}
|
||||
if withSignInfo != tc.expectedWithSignInfo {
|
||||
t.Errorf("expected %d users with sign-in data, got %d", tc.expectedWithSignInfo, withSignInfo)
|
||||
}
|
||||
if len(roles) != len(tc.expectedRoles) {
|
||||
t.Fatalf("expected %d roles, got %d", len(tc.expectedRoles), len(roles))
|
||||
}
|
||||
for i, expected := range tc.expectedRoles {
|
||||
got := roles[i]
|
||||
if got.Role != expected.Role || got.Users != expected.Users ||
|
||||
tableCount(got.SignedIn) != tableCount(expected.SignedIn) {
|
||||
t.Errorf("role %d: expected %s/%d/%s, got %s/%d/%s",
|
||||
i, expected.Role, expected.Users, tableCount(expected.SignedIn),
|
||||
got.Role, got.Users, tableCount(got.SignedIn))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderUserReport(t *testing.T) {
|
||||
report := userReport{
|
||||
GeneratedAt: time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC),
|
||||
Days: 31,
|
||||
ActiveSince: time.Date(2026, 9, 8, 12, 0, 0, 0, time.UTC),
|
||||
TotalUsers: 5,
|
||||
ActiveUsers: count(3),
|
||||
UsersWithSignInData: 4,
|
||||
Roles: []roleCount{
|
||||
{Role: "User", Users: 3, SignedIn: count(2)},
|
||||
{Role: "Admin", Users: 1, SignedIn: count(1)},
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
format string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
format: _reportOutputTable,
|
||||
expected: []string{
|
||||
"Sign-in window: last 31 days (since 2026-09-08T12:00:00Z)",
|
||||
"Users in total: 5",
|
||||
"Signed in within the last 31 days: 3",
|
||||
"Users with a recorded sign-in: 4",
|
||||
"Role", "Users", "Admin", "User",
|
||||
},
|
||||
},
|
||||
{
|
||||
format: _reportOutputJSON,
|
||||
expected: []string{
|
||||
`"activeSince": "2026-09-08T12:00:00Z"`,
|
||||
`"totalUsers": 5`,
|
||||
`"activeUsers": 3`,
|
||||
`"usersWithSignInData": 4`,
|
||||
`"role": "Admin"`,
|
||||
`"users": 1`,
|
||||
`"signedIn": 2`,
|
||||
},
|
||||
},
|
||||
{
|
||||
format: _reportOutputCSV,
|
||||
expected: []string{
|
||||
"days,activeSince,totalUsers,activeUsers,usersWithSignInData,role,users,signedIn\n" +
|
||||
"31,2026-09-08T12:00:00Z,5,3,4,User,3,2\n" +
|
||||
"31,2026-09-08T12:00:00Z,5,3,4,Admin,1,1\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
format: _reportOutputYAML,
|
||||
expected: []string{
|
||||
"days: 31", "totalUsers: 5", "activeUsers: 3", "usersWithSignInData: 4",
|
||||
"- role: User", "users: 3", "signedIn: 2",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.format, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := renderUserReport(&buf, report, tc.format); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// collapse the padding the table format uses to align its labels
|
||||
actual := regexp.MustCompile(` +`).ReplaceAllString(buf.String(), " ")
|
||||
for _, expected := range tc.expected {
|
||||
if !strings.Contains(actual, expected) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", expected, buf.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderUserReportNobodySignedInRecently makes sure the role grouping is
|
||||
// still reported in full when nobody signed in within the reported window, and
|
||||
// that a genuine 0 stays a 0.
|
||||
func TestRenderUserReportNobodySignedInRecently(t *testing.T) {
|
||||
report := userReport{
|
||||
GeneratedAt: time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC),
|
||||
Days: 1,
|
||||
ActiveSince: time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC),
|
||||
TotalUsers: 5,
|
||||
ActiveUsers: count(0),
|
||||
UsersWithSignInData: 5,
|
||||
Roles: []roleCount{
|
||||
{Role: "User", Users: 4, SignedIn: count(0)},
|
||||
{Role: "Admin", Users: 1, SignedIn: count(0)},
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
format string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
format: _reportOutputTable,
|
||||
expected: []string{"Signed in within the last 1 day: 0", "User", "Admin"},
|
||||
},
|
||||
{format: _reportOutputJSON, expected: []string{`"activeUsers": 0`, `"users": 4`}},
|
||||
{format: _reportOutputYAML, expected: []string{"activeUsers: 0", "users: 4"}},
|
||||
{
|
||||
format: _reportOutputCSV,
|
||||
expected: []string{
|
||||
"days,activeSince,totalUsers,activeUsers,usersWithSignInData,role,users,signedIn\n" +
|
||||
"1,2026-10-08T12:00:00Z,5,0,5,User,4,0\n" +
|
||||
"1,2026-10-08T12:00:00Z,5,0,5,Admin,1,0\n",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.format, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := renderUserReport(&buf, report, tc.format); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
actual := regexp.MustCompile(` +`).ReplaceAllString(buf.String(), " ")
|
||||
for _, expected := range tc.expected {
|
||||
if !strings.Contains(actual, expected) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", expected, buf.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(actual, "N/A") {
|
||||
t.Errorf("did not expect an N/A count, got:\n%s", buf.String())
|
||||
}
|
||||
if strings.Contains(actual, "Warning:") {
|
||||
t.Errorf("did not expect a warning, got:\n%s", buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderUserReportWithoutUsers makes sure the csv output keeps carrying the
|
||||
// report context when there is not a single user to group.
|
||||
func TestRenderUserReportWithoutUsers(t *testing.T) {
|
||||
report := userReport{
|
||||
GeneratedAt: time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC),
|
||||
Days: 31,
|
||||
ActiveSince: time.Date(2026, 9, 8, 12, 0, 0, 0, time.UTC),
|
||||
ActiveUsers: count(0),
|
||||
Roles: []roleCount{},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := renderUserReport(&buf, report, _reportOutputCSV); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
expected := "days,activeSince,totalUsers,activeUsers,usersWithSignInData,role,users,signedIn\n" +
|
||||
"31,2026-09-08T12:00:00Z,0,0,0,,0,0\n"
|
||||
if buf.String() != expected {
|
||||
t.Errorf("expected %q, got %q", expected, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderUserReportWithoutSignInData covers deployments that never record a
|
||||
// sign-in, e.g. because the identity backend is read only. The users are still
|
||||
// grouped by role there, but the sign-in counts are not available and must not
|
||||
// be rendered as 0.
|
||||
func TestRenderUserReportWithoutSignInData(t *testing.T) {
|
||||
report := userReport{
|
||||
GeneratedAt: time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC),
|
||||
Days: 31,
|
||||
ActiveSince: time.Date(2026, 9, 8, 12, 0, 0, 0, time.UTC),
|
||||
TotalUsers: 6,
|
||||
ActiveUsers: nil,
|
||||
UsersWithSignInData: 0,
|
||||
Roles: []roleCount{
|
||||
{Role: "User", Users: 5, SignedIn: nil},
|
||||
{Role: "Admin", Users: 1, SignedIn: nil},
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
format string
|
||||
expected []string
|
||||
notExpected []string
|
||||
}{
|
||||
{
|
||||
format: _reportOutputTable,
|
||||
expected: []string{
|
||||
"Signed in within the last 31 days: N/A",
|
||||
"Warning: not a single user has a recorded sign-in",
|
||||
// the grouping has to be there regardless of the missing data
|
||||
"Users in total: 6", "User", "Admin", "N/A",
|
||||
},
|
||||
},
|
||||
{
|
||||
format: _reportOutputJSON,
|
||||
expected: []string{`"activeUsers": null`, `"signedIn": null`, `"usersWithSignInData": 0`},
|
||||
notExpected: []string{`"activeUsers": 0`, `"signedIn": 0`},
|
||||
},
|
||||
{
|
||||
format: _reportOutputYAML,
|
||||
expected: []string{"activeUsers: null", "signedIn: null"},
|
||||
notExpected: []string{"activeUsers: 0", "signedIn: 0"},
|
||||
},
|
||||
{
|
||||
format: _reportOutputCSV,
|
||||
expected: []string{
|
||||
"days,activeSince,totalUsers,activeUsers,usersWithSignInData,role,users,signedIn\n" +
|
||||
"31,2026-09-08T12:00:00Z,6,,0,User,5,\n" +
|
||||
"31,2026-09-08T12:00:00Z,6,,0,Admin,1,\n",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.format, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := renderUserReport(&buf, report, tc.format); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
actual := regexp.MustCompile(` +`).ReplaceAllString(buf.String(), " ")
|
||||
for _, expected := range tc.expected {
|
||||
if !strings.Contains(actual, expected) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", expected, buf.String())
|
||||
}
|
||||
}
|
||||
for _, notExpected := range tc.notExpected {
|
||||
if strings.Contains(actual, notExpected) {
|
||||
t.Errorf("expected output not to contain %q, got:\n%s", notExpected, buf.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user