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:
Thomas Schweiger committed 2026-10-09 18:41:02 +02:00
1 parent 369d386e6b
commit fb9e9df8d2
2 files changed
+866

No files matched your search

+474
View File
@@ -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"
}
+392
View File
@@ -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())
}
}
})
}
}