Skip to content

Commit 575b31b

Browse files
committed
test(web): close review gaps in the password-expiry view
Addresses the confirmed findings of the adversarial review of #626. Testability seams. RequireAdmin now resolves its admin check per request, and the App carries two nil-defaulted overrides — adminCheck and expiryResolver — so tests can drive the handler behind the gate. Without them the handler had 0% coverage: the only route test stopped at the 403, because a DN-addressable admin cache entry cannot be built outside the LDAP package. New handler tests cover the 503 no-service-account guard, the admit path, the days/show/sort query-param wiring, and the resolver-error branch. handlePasswordExpiryV2 goes from 0% to 94.7%, RequireAdmin to 100%. Untested guards. Added the empty-admin-group case (a directory returning an empty group entry must not match an unset admin group) and the status-sort column, both previously surviving mutants. Undated rows now sort to the bottom in BOTH directions. The old sentinel kept must-change/never/unknown last only under ascending; under descending they floated to the top above the furthest concrete deadline. sortByDeadline pins them at the bottom regardless of direction, and the dead expiryOrder sentinel is gone. Accessibility. Sort headers now carry aria-sort (ascending/descending/none) so the active column and direction reach assistive technology rather than living only in an aria-hidden arrow — a fix in the shared tableSortHeader helper, so every V2 table gains it. The deadline cell for undated rows pairs the visual em-dash with an sr-only "No expiry date" instead of announcing a lone dash. Documented that AD's adminCount is sticky, so admin access via that marker outlives de-privilege; prefer LDAP_ADMIN_GROUP where access should track current privilege. Not changed: the client-side filter does not announce "no results" via a live region — a pre-existing gap in the shared search JS affecting every V2 list table, out of scope here. Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
1 parent fd72d6e commit 575b31b

8 files changed

Lines changed: 283 additions & 46 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ When no readonly user is configured, the app uses per-user LDAP credentials for
118118

119119
`/password-expiry` lists accounts whose LDAP password is expiring, resolved live via [simple-ldap-go](https://github.com/netresearch/simple-ldap-go)'s expiry API. It is **admin-only** and needs the service account.
120120

121-
An admin is a member of `LDAP_ADMIN_GROUP` **or** an account carrying Active Directory's `adminCount=1`. On OpenLDAP there is no `adminCount`, so `LDAP_ADMIN_GROUP` is the only way to grant access — without it, the roster is reachable by no one. Group membership is read from the user's `memberOf`, which Active Directory populates automatically; an OpenLDAP deployment must have the `memberof` overlay enabled for the group gate to work.
121+
An admin is a member of `LDAP_ADMIN_GROUP` **or** an account carrying Active Directory's `adminCount=1`. Note that `adminCount` is *sticky*: Active Directory sets it when an account joins a protected group and never clears it on removal, so an account that was ever privileged keeps roster access. Prefer `LDAP_ADMIN_GROUP` membership where you want access to track current privilege. On OpenLDAP there is no `adminCount`, so `LDAP_ADMIN_GROUP` is the only way to grant access — without it, the roster is reachable by no one. Group membership is read from the user's `memberOf`, which Active Directory populates automatically; an OpenLDAP deployment must have the `memberof` overlay enabled for the group gate to work.
122122

123123
The default view shows accounts due within a window (`?days=`, default 30, capped at 366); a **Show all accounts** toggle adds the never-expires and unknown accounts with a status badge. On OpenLDAP, expiry needs the `ppolicy` overlay; accounts the directory reports nothing about show as `unknown`.
124124

internal/web/admin.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,23 @@ func (a *App) userIsAdmin(user *ldap.User) bool {
4747
return false
4848
}
4949

50+
// resolveAdminCheck returns the admin predicate: the injected one in tests,
51+
// otherwise the real cache-backed isAdmin.
52+
func (a *App) resolveAdminCheck() func(string) bool {
53+
if a.adminCheck != nil {
54+
return a.adminCheck
55+
}
56+
57+
return a.isAdmin
58+
}
59+
5060
// RequireAdmin gates a route to administrators. It must sit behind RequireAuth,
5161
// which populates the viewer DN into c.Locals. A non-admin gets 403 rather than
5262
// a redirect: they are authenticated, just not permitted.
5363
func (a *App) RequireAdmin() fiber.Handler {
5464
return func(c *fiber.Ctx) error {
5565
userDN := GetUserDN(c)
56-
if userDN == "" || !a.isAdmin(userDN) {
66+
if userDN == "" || !a.resolveAdminCheck()(userDN) {
5767
log.Warn().
5868
Str("userDN", userDN).
5969
Str("path", c.Path()).

internal/web/admin_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ func TestUserIsAdmin(t *testing.T) {
4545
user: ldap.User{Groups: []string{adminGroup}},
4646
want: false,
4747
},
48+
{
49+
// The adminGroupDN != "" guard exists for exactly this: a directory
50+
// that returns an empty-string group entry must not match an unset
51+
// admin group, since IsMemberOf("") would otherwise be true.
52+
name: "empty group entry with no admin group configured is not admin",
53+
user: ldap.User{Groups: []string{""}},
54+
want: false,
55+
},
4856
}
4957

5058
for _, tt := range tests {

internal/web/password_expiry_v2_handler.go

Lines changed: 58 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ func (a *App) handlePasswordExpiryV2(c *fiber.Ctx) error {
3838
return res
3939
}
4040

41-
if a.ldapReadonly == nil {
41+
resolver := a.effectiveExpiryResolver()
42+
if resolver == nil {
4243
// No service account => no enumeration path. RequireAdmin already
4344
// returns false here, so this is defence in depth.
4445
return c.Status(fiber.StatusServiceUnavailable).
@@ -51,7 +52,7 @@ func (a *App) handlePasswordExpiryV2(c *fiber.Ctx) error {
5152
ctx := c.UserContext()
5253
window := time.Duration(days) * 24 * time.Hour
5354

54-
rows, err := collectExpiryRows(ctx, a.ldapReadonly, window, showAll)
55+
rows, err := collectExpiryRows(ctx, resolver, window, showAll)
5556
if err != nil {
5657
return handle500(c, err)
5758
}
@@ -68,6 +69,22 @@ func (a *App) handlePasswordExpiryV2(c *fiber.Ctx) error {
6869
return page.Render(c.UserContext(), c.Response().BodyWriter())
6970
}
7071

72+
// effectiveExpiryResolver returns the roster's data source: the injected test
73+
// resolver, else the service-account client, else nil when neither is set.
74+
// Returning the concrete *ldap.LDAP only when non-nil avoids the typed-nil
75+
// interface trap — a nil *ldap.LDAP boxed into the interface would not compare
76+
// equal to nil and would panic on first use.
77+
func (a *App) effectiveExpiryResolver() expiryResolver {
78+
if a.expiryResolver != nil {
79+
return a.expiryResolver
80+
}
81+
if a.ldapReadonly != nil {
82+
return a.ldapReadonly
83+
}
84+
85+
return nil
86+
}
87+
7188
// expiryResolver is the slice of the LDAP client the roster needs. Depending
7289
// on an interface rather than *ldap.LDAP lets the row-collection logic — the
7390
// due/show-all split, the disabled-skip, error propagation — be tested with a
@@ -167,10 +184,19 @@ func parseWindowDays(raw string) int {
167184
}
168185

169186
// sortExpiryRows orders the rows in place by the requested column. The default
170-
// is by deadline ascending, so the most urgent accounts lead. Rows without a
171-
// concrete deadline (must-change, never, unknown) sort after dated ones on the
172-
// expires key, since they have no moment to compare.
187+
// is by deadline ascending, so the most urgent accounts lead.
188+
//
189+
// On the deadline column, rows without a concrete date (must-change, never,
190+
// unknown) always sort to the bottom regardless of direction — reversing the
191+
// direction reverses only the dated rows, keeping the undated ones out of the
192+
// way rather than letting them jump to the top under desc.
173193
func sortExpiryRows(rows []templates.ExpiryRow, key, dir string) {
194+
if key != "name" && key != "status" {
195+
sortByDeadline(rows, dir)
196+
197+
return
198+
}
199+
174200
less := expiryLess(key)
175201
sort.SliceStable(rows, func(i, j int) bool {
176202
if dir == "desc" {
@@ -181,35 +207,38 @@ func sortExpiryRows(rows []templates.ExpiryRow, key, dir string) {
181207
})
182208
}
183209

184-
// expiryLess returns the ascending comparator for a sort column.
185-
func expiryLess(key string) func(a, b templates.ExpiryRow) bool {
186-
switch key {
187-
case "name":
188-
return func(a, b templates.ExpiryRow) bool {
189-
return lowerCN(a) < lowerCN(b)
210+
// sortByDeadline sorts dated rows by their deadline in the given direction and
211+
// pins undated rows to the bottom in both directions.
212+
func sortByDeadline(rows []templates.ExpiryRow, dir string) {
213+
sort.SliceStable(rows, func(i, j int) bool {
214+
a, b := rows[i], rows[j]
215+
if a.HasDeadline != b.HasDeadline {
216+
// The dated row always precedes the undated one.
217+
return a.HasDeadline
190218
}
191-
case "status":
192-
return func(a, b templates.ExpiryRow) bool {
193-
return a.Status < b.Status
219+
if !a.HasDeadline {
220+
return false // both undated: keep stable order
194221
}
195-
default: // "expires"
196-
return func(a, b templates.ExpiryRow) bool {
197-
return expiryOrder(a) < expiryOrder(b)
222+
if dir == "desc" {
223+
return a.ExpiresAt > b.ExpiresAt
198224
}
199-
}
225+
226+
return a.ExpiresAt < b.ExpiresAt
227+
})
200228
}
201229

202-
// expiryOrder maps a row to a sortable deadline. Dated rows sort by their
203-
// timestamp; undated rows (must-change, never, unknown) sort last, keeping the
204-
// concrete deadlines — the ones an admin acts on — at the top.
205-
func expiryOrder(r templates.ExpiryRow) int64 {
206-
if r.HasDeadline {
207-
return r.ExpiresAt
230+
// expiryLess returns the ascending comparator for the non-deadline columns.
231+
// The deadline column is handled by sortByDeadline, which needs direction- and
232+
// undated-aware ordering the plain comparator cannot express.
233+
func expiryLess(key string) func(a, b templates.ExpiryRow) bool {
234+
if key == "status" {
235+
return func(a, b templates.ExpiryRow) bool {
236+
return a.Status < b.Status
237+
}
208238
}
209239

210-
return 1<<62 - 1
211-
}
212-
213-
func lowerCN(r templates.ExpiryRow) string {
214-
return strings.ToLower(r.CN)
240+
// "name"
241+
return func(a, b templates.ExpiryRow) bool {
242+
return strings.ToLower(a.CN) < strings.ToLower(b.CN)
243+
}
215244
}

internal/web/password_expiry_v2_handler_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ package web
33
import (
44
"context"
55
"errors"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
610
"testing"
711
"time"
812

@@ -194,6 +198,52 @@ func TestSortExpiryRows_ByDeadlineUndatedLast(t *testing.T) {
194198
}
195199
}
196200

201+
// Undated rows must stay at the bottom under BOTH directions — reversing the
202+
// deadline sort reverses only the dated rows, it must not float must-change /
203+
// never / unknown accounts to the top.
204+
func TestSortExpiryRows_UndatedStayLastUnderDesc(t *testing.T) {
205+
rows := []templates.ExpiryRow{
206+
{CN: "must", Status: "must-change"},
207+
{CN: "late", Status: "expires", HasDeadline: true, ExpiresAt: 2000},
208+
{CN: "soon", Status: "expires", HasDeadline: true, ExpiresAt: 1000},
209+
{CN: "never", Status: "never-expires"},
210+
}
211+
212+
sortExpiryRows(rows, "expires", "desc")
213+
214+
// Dated rows first, newest deadline leading; undated rows keep their order
215+
// at the bottom.
216+
want := []string{"late", "soon", "must", "never"}
217+
for i, cn := range want {
218+
if rows[i].CN != cn {
219+
t.Errorf("position %d = %q, want %q", i, rows[i].CN, cn)
220+
}
221+
}
222+
}
223+
224+
func TestSortExpiryRows_ByStatus(t *testing.T) {
225+
rows := []templates.ExpiryRow{
226+
{CN: "c", Status: "unknown"},
227+
{CN: "a", Status: "expires"},
228+
{CN: "b", Status: "must-change"},
229+
}
230+
231+
sortExpiryRows(rows, "status", "asc")
232+
233+
// Alphabetical by status string: expires < must-change < unknown.
234+
want := []string{"expires", "must-change", "unknown"}
235+
for i, s := range want {
236+
if rows[i].Status != s {
237+
t.Errorf("position %d status = %q, want %q", i, rows[i].Status, s)
238+
}
239+
}
240+
241+
sortExpiryRows(rows, "status", "desc")
242+
if rows[0].Status != "unknown" {
243+
t.Errorf("desc first = %q, want unknown", rows[0].Status)
244+
}
245+
}
246+
197247
func TestSortExpiryRows_NameDescending(t *testing.T) {
198248
rows := []templates.ExpiryRow{
199249
{CN: "Alice"}, {CN: "carol"}, {CN: "Bob"},
@@ -210,3 +260,105 @@ func TestSortExpiryRows_NameDescending(t *testing.T) {
210260
}
211261

212262
func ptr[T any](v T) *T { return &v }
263+
264+
// The injected admin check lets a request reach the handler behind
265+
// RequireAdmin — otherwise unreachable, since a DN-addressable admin cache
266+
// entry cannot be built outside the LDAP package.
267+
func adminApp(t *testing.T) (*App, []*http.Cookie) {
268+
t.Helper()
269+
270+
app, _ := setupFullTestApp(t)
271+
app.adminCheck = func(string) bool { return true }
272+
cookies := simulatedSession(t, app)
273+
274+
return app, cookies
275+
}
276+
277+
func getExpiry(t *testing.T, app *App, cookies []*http.Cookie, target string) *http.Response {
278+
t.Helper()
279+
280+
req := httptest.NewRequest(http.MethodGet, target, nil)
281+
for _, ck := range cookies {
282+
req.AddCookie(ck)
283+
}
284+
resp, err := app.fiber.Test(req)
285+
if err != nil {
286+
t.Fatalf("request failed: %v", err)
287+
}
288+
289+
return resp
290+
}
291+
292+
// With no service account the roster has no enumeration path; the handler
293+
// returns 503 rather than 500 or a blank page.
294+
func TestHandlePasswordExpiry_NoServiceAccountIs503(t *testing.T) {
295+
app, cookies := adminApp(t)
296+
app.ldapReadonly = nil
297+
app.expiryResolver = nil
298+
299+
resp := getExpiry(t, app, cookies, "/password-expiry")
300+
defer func() { _ = resp.Body.Close() }()
301+
302+
if resp.StatusCode != http.StatusServiceUnavailable {
303+
t.Errorf("status = %d, want 503", resp.StatusCode)
304+
}
305+
}
306+
307+
// An admin reaches the handler and gets the rendered roster. This also
308+
// exercises the RequireAdmin admit path and the days/show/sort wiring.
309+
func TestHandlePasswordExpiry_AdminGetsRoster(t *testing.T) {
310+
app, cookies := adminApp(t)
311+
soon := ldap.PasswordExpiry{Status: ldap.PasswordExpires, At: time.Now().Add(48 * time.Hour)}
312+
app.expiryResolver = &fakeExpiryResolver{
313+
expiring: []ldap.ExpiringUser{{User: ptr(userWith("alice", true)), Expiry: soon}},
314+
}
315+
316+
resp := getExpiry(t, app, cookies, "/password-expiry?days=14")
317+
defer func() { _ = resp.Body.Close() }()
318+
319+
if resp.StatusCode != http.StatusOK {
320+
t.Fatalf("status = %d, want 200", resp.StatusCode)
321+
}
322+
body, _ := io.ReadAll(resp.Body)
323+
html := string(body)
324+
if !strings.Contains(html, "alice") {
325+
t.Error("roster should render the expiring account")
326+
}
327+
if !strings.Contains(html, "expiring within 14 days") {
328+
t.Errorf("count label should reflect ?days=14, got body without it")
329+
}
330+
}
331+
332+
// show=all takes the enumeration path and includes never/unknown rows.
333+
func TestHandlePasswordExpiry_ShowAllIncludesEveryState(t *testing.T) {
334+
app, cookies := adminApp(t)
335+
app.expiryResolver = &fakeExpiryResolver{
336+
users: []ldap.User{userWith("never", true), userWith("quiet", true)},
337+
perUser: map[string]ldap.PasswordExpiry{
338+
"never": {Status: ldap.PasswordNeverExpires},
339+
"quiet": {Status: ldap.PasswordExpiryUnknown},
340+
},
341+
}
342+
343+
resp := getExpiry(t, app, cookies, "/password-expiry?show=all")
344+
defer func() { _ = resp.Body.Close() }()
345+
346+
body, _ := io.ReadAll(resp.Body)
347+
html := string(body)
348+
if !strings.Contains(html, "Never expires") || !strings.Contains(html, "Unknown") {
349+
t.Error("show=all should render never-expires and unknown rows")
350+
}
351+
}
352+
353+
// A resolver error surfaces as a server error, not a blank 200.
354+
func TestHandlePasswordExpiry_ResolverErrorIsHandled(t *testing.T) {
355+
app, cookies := adminApp(t)
356+
app.expiryResolver = &fakeExpiryResolver{expiringErr: errors.New("ldap down")}
357+
358+
resp := getExpiry(t, app, cookies, "/password-expiry")
359+
defer func() { _ = resp.Body.Close() }()
360+
361+
if resp.StatusCode == http.StatusOK {
362+
t.Errorf("status = %d, want a non-200 error for a resolver failure", resp.StatusCode)
363+
}
364+
}

internal/web/server.go

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,29 @@ import (
3434
// When ReadonlyUser is not configured, ldapReadonly and ldapCache are nil;
3535
// all interactive LDAP operations use the logged-in user's own credentials.
3636
type App struct {
37-
ldapConfig ldap.Config
38-
ldapOpts []ldap.Option // LDAP client options (TLS, logging)
39-
ldapReadonly *ldap.LDAP // Service account client (nil when not configured)
40-
ldapCache *ldap_cache.Manager // Background cache (nil when no service account)
41-
adminGroupDN string // Group whose members may see the password-expiry roster (empty => adminCount only)
42-
sessionStore *session.Store
43-
templateCache *TemplateCache
44-
csrfHandler fiber.Handler
45-
fiber *fiber.App
46-
rateLimiter *RateLimiter // Rate limiter for authentication endpoints
47-
stopCacheLog chan struct{} // Stops periodicCacheLogging goroutine
48-
pinnedStore *PinnedStore // Per-user pinned-items store (spec §6.5)
49-
pinnedDB *bolt.DB // Underlying bbolt handle for pinnedStore (nil when in-memory)
37+
ldapConfig ldap.Config
38+
ldapOpts []ldap.Option // LDAP client options (TLS, logging)
39+
ldapReadonly *ldap.LDAP // Service account client (nil when not configured)
40+
ldapCache *ldap_cache.Manager // Background cache (nil when no service account)
41+
adminGroupDN string // Group whose members may see the password-expiry roster (empty => adminCount only)
42+
43+
// adminCheck resolves whether a viewer DN is an admin. Nil in production,
44+
// where isAdmin is used. Tests inject it to exercise the admit path and
45+
// the handler behind RequireAdmin, which is otherwise unreachable because
46+
// a DN-addressable admin cache entry cannot be built outside the LDAP
47+
// package (User.DN is unexported).
48+
adminCheck func(userDN string) bool
49+
// expiryResolver overrides ldapReadonly as the roster's data source. Nil in
50+
// production; tests inject a fake to drive the handler without a directory.
51+
expiryResolver expiryResolver
52+
sessionStore *session.Store
53+
templateCache *TemplateCache
54+
csrfHandler fiber.Handler
55+
fiber *fiber.App
56+
rateLimiter *RateLimiter // Rate limiter for authentication endpoints
57+
stopCacheLog chan struct{} // Stops periodicCacheLogging goroutine
58+
pinnedStore *PinnedStore // Per-user pinned-items store (spec §6.5)
59+
pinnedDB *bolt.DB // Underlying bbolt handle for pinnedStore (nil when in-memory)
5060
}
5161

5262
// pinnedStorePath returns the bbolt file path for the per-user pinned

0 commit comments

Comments
 (0)