From fb9e9df8d2d84172c563da838397a754c8a498be Mon Sep 17 00:00:00 2001 From: Thomas Schweiger Date: Fri, 9 Oct 2026 18:32:45 +0200 Subject: [PATCH] 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. --- opencloud/pkg/command/report.go | 474 +++++++++++++++++++++++++++ opencloud/pkg/command/report_test.go | 392 ++++++++++++++++++++++ 2 files changed, 866 insertions(+) create mode 100644 opencloud/pkg/command/report.go create mode 100644 opencloud/pkg/command/report_test.go diff --git a/opencloud/pkg/command/report.go b/opencloud/pkg/command/report.go new file mode 100644 index 0000000000..d23627ff40 --- /dev/null +++ b/opencloud/pkg/command/report.go @@ -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" +} diff --git a/opencloud/pkg/command/report_test.go b/opencloud/pkg/command/report_test.go new file mode 100644 index 0000000000..4668806b2f --- /dev/null +++ b/opencloud/pkg/command/report_test.go @@ -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()) + } + } + }) + } +}