Skip to content

Commit 658f5b4

Browse files
authored
test: address 17 Copilot-flagged test-rigor concerns from #548 (#552)
Closes #549 Follow-up to #548 (coverage 24.4% → 82.5%). Copilot flagged 17 test-quality concerns that merged alongside the coverage uplift; this PR addresses all of them as a focused test-rigor pass. No production code behaviour changes. ## Per-concern status ### Goroutine lifecycle (7 threads) — fixed - `newAppForCoverage` now calls `registerAppShutdown(t, app)` which installs `t.Cleanup(app.Shutdown)`, shutting down `periodicCacheLogging`, template-cache cleanup, and rate-limiter cleanup goroutines. Every test built on `newAppForCoverage` (`TestNewApp_*`, `TestApp_*`, `TestUserModifyHandler_AuthenticatedPaths`, `TestGroupModifyHandler_*`, `TestModifyHandlers_DirectNoCSRF`, `TestCSRFProtectedModifyHandlers`, `TestAuthenticatedGETHandlers`) now tears down cleanly. The cleanup is defensive: if a test calls Shutdown explicitly first, the cleanup recovers from the `close(stopCacheLog)` panic on second call. - `TestNewApp_WithPersistSessions` keeps its explicit Shutdown because it asserts on the return value; it does not also register the cleanup. ### port 65535 flaky assumption — fixed `TestRunHealthCheck/returns 1 when no server is listening` now uses `net.Listen("tcp", "127.0.0.1:0")` to acquire a known-free ephemeral port, closes it, and reuses the port number. ### `testTemplateRenderError` sad-path assertion — fixed `TestRender_WithCancelledContext` (the cancelled-context render test) now asserts `err != nil` and `errors.Is(err, context.Canceled)` instead of only `t.Logf`-ing on mismatch. A silent nil return from Render with a cancelled context is now a fail. ### Comment/behaviour mismatch (no-op middleware) — fixed Removed the `bare.Use(func(c *fiber.Ctx) error { /* attach cookies */ return c.Next() })` block in `TestModifyHandlers_DirectNoCSRF`. Cookies are attached per-request via `postTo`; no middleware is needed. ### No-assertion test variants — fixed `Users(nil, ...)`, `Groups(nil)`, and `Computers(nil)` render blocks in `render_test.go` now use `require.NoError` + `require.NotZero(buf.Len())` so the empty-state branch is actually verified. ### `-coverpkg` scope too wide — fixed The reusable `netresearch/.github/.github/workflows/go-check.yml` has no hook between `go test` and threshold enforcement, so we cannot post-filter `coverage.out` via inputs. Fix: - Call the reusable workflow with `enable-build-test: false` and `enable-codecov: false`, keeping smoke/fuzz/license/security jobs. - Add an inline `build-test-coverage` job that expands `-coverpkg` to include `internal/web/templates/...`, filters `*_templ.go` lines out of `coverage.raw.out` → `coverage.out`, then enforces the 80% threshold and uploads to Codecov. Authored helpers in `internal/web/templates` (`flash.go` Flashes / SuccessFlash / ErrorFlash / InfoFlash / IsSuccess / IsError / IsInfo / BorderColor, `specializeUsers`/`specializeGroups`/`specializeComputers`, `formatLastLogon`, `getNavbarClasses`) now count toward the 80% floor. The generated `*_templ.go` files do not. `.github/template.yaml` drift note updated. ### `StatusCode == 0` always-true assertions — fixed Replaced the nine `StatusCode == 0` / `NotEqual(0, StatusCode)` checks across `server_coverage_test.go`, `handlers_authenticated_test.go`, `modify_handlers_test.go`, `server_test.go`, and `ldap_integration_test.go` with explicit expected-code lists (200/302/403/404/500/503 as appropriate). `TestApp_RoutesRegistered` now uses a `wantCodes []int` slice per route so each is validated against the code(s) the registered handler actually emits (302 for protected routes without session, 200 for `/login`, 200-or-503 for `/health*`). ### Misleading `cancel()` in ListenCancelled — fixed Renamed `TestApp_ListenCancelled` → `TestApp_ListenGracefulShutdown`. The test no longer passes a cancellable context (Listen forwards to `fiber.Listen` and doesn't observe ctx), and now asserts Listen returns within 2s of `Shutdown()`. ### Stale file-header comment — fixed `login_handler_test.go` header previously advertised "successful login with regenerated session"; no such case exists. Replaced with a header that matches actual contents and notes the success case is in the LDAP integration suite. ## Verification - `go test -race -timeout 120s ./...` passes all packages. - `golangci-lint run --timeout 3m ./...` is clean (0 issues). - Filtered coverage: **82.6%** (up from 82.5% baseline — now includes `flash.go` authored helpers at 100%). ## Test plan - [x] All existing tests pass under `-race` - [x] `golangci-lint` reports 0 issues - [x] Filtered coverage >= 80% threshold - [x] `flash.go` helpers visible in coverage output - [x] `TestApp_RoutesRegistered` asserts specific status codes per route - [x] `TestRender_WithCancelledContext` fails if Render returns nil error - [x] `TestRunHealthCheck` no-server case uses ephemeral port
2 parents d671ac2 + 933edd4 commit 658f5b4

10 files changed

Lines changed: 247 additions & 92 deletions

File tree

.github/template.yaml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ template: go-app
55
intentional-drift:
66
- path: .github/workflows/ci.yml
77
reason: 'TypeScript/templ frontend assets must be built via bun before go test;
8-
requires setup-bun + pre-build-cmd at caller level. Custom test-flags pass
9-
-coverpkg to exclude the auto-generated internal/web/templates package from
10-
the coverage calculation (its *_templ.go files contain framework-internal
11-
defer/error branches unreachable from unit tests).'
8+
requires setup-bun + pre-build-cmd at caller level. The reusable go-check
9+
workflow has no hook between "go test" and coverage threshold enforcement,
10+
so we disable its build-test job and run an inline build-test-coverage job
11+
that expands -coverpkg to include internal/web/templates/... and
12+
post-filters generated *_templ.go lines from coverage.out before enforcing
13+
the 80% threshold. This keeps authored helpers in the templates package
14+
(flash.go, specializeUsers/Groups/Computers, formatLastLogon) in the
15+
numerator while excluding the framework-internal defer/error branches of
16+
generated templ code.'

.github/workflows/ci.yml

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,100 @@ permissions:
1515
contents: read
1616

1717
jobs:
18+
# Primary quality pipeline. We disable the reusable workflow's
19+
# build-test job (including coverage threshold enforcement) because
20+
# the reusable workflow has no hook between "go test" and threshold
21+
# enforcement, and we need to post-filter *_templ.go lines from
22+
# coverage.out. The build-test-coverage job below replaces it.
1823
go-check:
1924
uses: netresearch/.github/.github/workflows/go-check.yml@main
2025
with:
26+
enable-build-test: false
2127
enable-smoke-fast-feedback: true
2228
enable-fuzz: true
2329
enable-license-check: true
24-
enable-codecov: true
25-
# Exclude the auto-generated internal/web/templates package from the
26-
# coverage calculation: the *_templ.go files are produced by the templ
27-
# CLI and contain many framework-internal defer/error branches that
28-
# cannot be reached from unit tests. Authored-code coverage is the
29-
# metric that matters for the fleet-wide 80% floor.
30-
test-flags: "-race -covermode=atomic -coverprofile=coverage.out -coverpkg=github.com/netresearch/ldap-manager/cmd/...,github.com/netresearch/ldap-manager/internal/ldap_cache/...,github.com/netresearch/ldap-manager/internal/options/...,github.com/netresearch/ldap-manager/internal/retry/...,github.com/netresearch/ldap-manager/internal/version/...,github.com/netresearch/ldap-manager/internal/web"
30+
enable-codecov: false
3131
setup-bun: true
3232
pre-build-cmd: "go install github.com/a-h/templ/cmd/templ@latest && bun install --frozen-lockfile && bun run build:assets"
3333
permissions:
3434
contents: read
3535
security-events: write
3636
secrets:
3737
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
38+
39+
# Replacement for the reusable workflow's build-test + coverage
40+
# enforcement, with post-filtering of generated *_templ.go lines so
41+
# authored helpers in internal/web/templates (flash.go,
42+
# specializeUsers/Groups/Computers, formatLastLogon, etc.) count
43+
# toward the fleet-wide 80% coverage floor, while framework-internal
44+
# defer/error branches in the generated templ code do not.
45+
build-test-coverage:
46+
name: Build & Test (with templ-filtered coverage)
47+
runs-on: ubuntu-latest
48+
timeout-minutes: 10
49+
permissions:
50+
contents: read
51+
env:
52+
COVERAGE_THRESHOLD: "80"
53+
steps:
54+
- name: Checkout
55+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
56+
57+
- name: Setup Go
58+
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
59+
with:
60+
go-version-file: go.mod
61+
cache: true
62+
63+
- name: Setup Bun
64+
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
65+
with:
66+
bun-version: latest
67+
68+
- name: Pre-build (templ + frontend assets)
69+
run: |
70+
go install github.com/a-h/templ/cmd/templ@latest
71+
bun install --frozen-lockfile
72+
bun run build:assets
73+
74+
- name: go vet
75+
run: go vet ./...
76+
77+
- name: go build
78+
run: go build -v ./...
79+
80+
- name: go test (with coverage)
81+
run: |
82+
go test -v -race -covermode=atomic -coverprofile=coverage.raw.out \
83+
-coverpkg=github.com/netresearch/ldap-manager/cmd/...,github.com/netresearch/ldap-manager/internal/ldap_cache/...,github.com/netresearch/ldap-manager/internal/options/...,github.com/netresearch/ldap-manager/internal/retry/...,github.com/netresearch/ldap-manager/internal/version/...,github.com/netresearch/ldap-manager/internal/web/... \
84+
./...
85+
86+
- name: Filter generated *_templ.go lines from coverage
87+
run: |
88+
set -euo pipefail
89+
# Generated *_templ.go files contain many framework-internal
90+
# defer/error branches that unit tests cannot reasonably reach.
91+
# Filter them out here so the coverage total reflects only
92+
# authored code, including hand-written helpers inside the
93+
# internal/web/templates package.
94+
grep -v '_templ\.go:' coverage.raw.out > coverage.out
95+
echo "Before filter: $(wc -l <coverage.raw.out) lines"
96+
echo "After filter: $(wc -l <coverage.out) lines"
97+
98+
- name: Enforce coverage threshold
99+
env:
100+
THRESHOLD: ${{ env.COVERAGE_THRESHOLD }}
101+
run: |
102+
set -euo pipefail
103+
TOTAL=$(go tool cover -func=coverage.out | awk '/^total:/ {gsub("%","",$3); print $3}')
104+
echo "Total coverage (authored code only): ${TOTAL}% (threshold ${THRESHOLD}%)"
105+
awk -v t="$THRESHOLD" -v a="$TOTAL" \
106+
'BEGIN { if (a+0 < t+0) { printf "::error::Coverage %s%% is below threshold %s%%\n", a, t; exit 1 } }'
107+
108+
- name: Upload coverage to Codecov
109+
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
110+
with:
111+
token: ${{ secrets.CODECOV_TOKEN }}
112+
files: coverage.out
113+
flags: unittests
114+
fail_ci_if_error: false

cmd/ldap-manager/main_test.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package main
33
// Tests for main.go's CLI helpers.
44

55
import (
6+
"net"
67
"net/http"
78
"net/http/httptest"
89
"net/url"
10+
"strconv"
911
"strings"
1012
"testing"
1113
)
@@ -51,8 +53,18 @@ func TestRunHealthCheck(t *testing.T) {
5153
})
5254

5355
t.Run("returns 1 when no server is listening", func(t *testing.T) {
54-
// Pick an unlikely high port that almost certainly has no server bound.
55-
got := runHealthCheck("65535")
56+
// Acquire a known-free ephemeral port from the OS, close it, and reuse
57+
// the number. This avoids the flaky assumption that a hard-coded port
58+
// (e.g. 65535) is available on shared CI runners.
59+
l, err := net.Listen("tcp", "127.0.0.1:0")
60+
if err != nil {
61+
t.Fatalf("listen: %v", err)
62+
}
63+
64+
port := strconv.Itoa(l.Addr().(*net.TCPAddr).Port)
65+
_ = l.Close()
66+
67+
got := runHealthCheck(port)
5668
if got != 1 {
5769
t.Errorf("expected 1 when no server listening, got %d", got)
5870
}

internal/web/handlers_authenticated_test.go

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,8 @@ func TestUserModifyHandler_AuthenticatedPaths(t *testing.T) {
115115

116116
// Without CSRF token → 403. With invalid session/LDAP → 302 (redirect
117117
// to /login). Either code path is exercised.
118-
if resp.StatusCode == 0 {
119-
t.Error("got zero status code")
118+
if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusFound {
119+
t.Errorf("expected 302 or 403, got %d", resp.StatusCode)
120120
}
121121
})
122122
}
@@ -145,8 +145,9 @@ func TestGroupModifyHandler_AuthenticatedPaths(t *testing.T) {
145145
}
146146
defer func() { _ = resp.Body.Close() }()
147147

148-
if resp.StatusCode == 0 {
149-
t.Error("got zero status code")
148+
// CSRF rejection (403) or redirect to detail page / login (302).
149+
if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusFound {
150+
t.Errorf("expected 302 or 403, got %d", resp.StatusCode)
150151
}
151152
})
152153

@@ -166,8 +167,8 @@ func TestGroupModifyHandler_AuthenticatedPaths(t *testing.T) {
166167
}
167168
defer func() { _ = resp.Body.Close() }()
168169

169-
if resp.StatusCode == 0 {
170-
t.Error("got zero status code")
170+
if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusFound {
171+
t.Errorf("expected 302 or 403, got %d", resp.StatusCode)
171172
}
172173
})
173174

@@ -187,8 +188,8 @@ func TestGroupModifyHandler_AuthenticatedPaths(t *testing.T) {
187188
}
188189
defer func() { _ = resp.Body.Close() }()
189190

190-
if resp.StatusCode == 0 {
191-
t.Error("got zero status code")
191+
if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusFound {
192+
t.Errorf("expected 302 or 403, got %d", resp.StatusCode)
192193
}
193194
})
194195
}
@@ -255,12 +256,9 @@ func TestModifyHandlers_DirectNoCSRF(t *testing.T) {
255256
cookies := simulatedSession(t, app)
256257

257258
// Mount modify handlers behind only RequireAuth (no CSRF) on a bare app
258-
// so session-based auth works but CSRF does not block.
259+
// so session-based auth works but CSRF does not block. Cookies are attached
260+
// per-request below (see postTo); no middleware is needed for that.
259261
bare := fiber.New()
260-
bare.Use(func(c *fiber.Ctx) error {
261-
// Attach the simulated session cookies.
262-
return c.Next()
263-
})
264262
bare.Post("/users/*", app.RequireAuth(), app.userModifyHandler)
265263
bare.Post("/groups/*", app.RequireAuth(), app.groupModifyHandler)
266264

@@ -423,8 +421,8 @@ func TestAuthenticatedGETHandlers(t *testing.T) {
423421

424422
// Post-auth, the LDAP call fails → fiber.StatusUnauthorized
425423
// → handle500 → /login redirect. 302 is the expected outcome.
426-
if resp.StatusCode == 0 {
427-
t.Error("zero status")
424+
if resp.StatusCode != http.StatusFound {
425+
t.Errorf("expected 302 redirect after LDAP failure, got %d", resp.StatusCode)
428426
}
429427
})
430428
}

internal/web/ldap_integration_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -368,8 +368,9 @@ func TestLDAPIntegration_UsersHandler(t *testing.T) {
368368
resp := makeLDAPAuthRequest(t, app, "/users", cookies)
369369
defer func() { _ = resp.Body.Close() }()
370370

371-
// Handler connects to real LDAP — success or error page
372-
assert.NotEqual(t, 0, resp.StatusCode)
371+
// Handler connects to real LDAP — 200 on success, 302 on redirect
372+
// to /login if session LDAP bind fails.
373+
assert.Contains(t, []int{http.StatusOK, http.StatusFound}, resp.StatusCode)
373374
if resp.StatusCode == http.StatusOK {
374375
body, _ := io.ReadAll(resp.Body)
375376
assert.Contains(t, resp.Header.Get("Content-Type"), "text/html")
@@ -381,7 +382,7 @@ func TestLDAPIntegration_UsersHandler(t *testing.T) {
381382
resp := makeLDAPAuthRequest(t, app, "/users?show-disabled=1", cookies)
382383
defer func() { _ = resp.Body.Close() }()
383384

384-
assert.NotEqual(t, 0, resp.StatusCode)
385+
assert.Contains(t, []int{http.StatusOK, http.StatusFound}, resp.StatusCode)
385386
})
386387
}
387388

internal/web/login_handler_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package web
22

3-
// Tests for loginHandler POST paths: invalid credentials, rate-limit block,
4-
// successful login with regenerated session.
3+
// Tests for loginHandler POST paths: invalid credentials, empty fields,
4+
// rate-limit block, and logoutHandler session destruction. A live-bind
5+
// "success" case is not present here because it requires a real LDAP server;
6+
// that path is covered by the LDAP integration test suite.
57

68
import (
79
"context"

internal/web/modify_handlers_test.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,11 @@ func TestGroupModifyHandler_DeeperPaths(t *testing.T) {
188188
}
189189
defer func() { _ = resp.Body.Close() }()
190190

191-
if resp.StatusCode == 0 {
192-
t.Error("zero status")
191+
// On example-server modify failure, handle500 renders the 500 page
192+
// (still exercising the target branch). On success it would render
193+
// the group detail page (200) or redirect (302).
194+
if !intInSlice(resp.StatusCode, []int{http.StatusOK, http.StatusFound, http.StatusInternalServerError}) {
195+
t.Errorf("expected 200, 302, or 500, got %d", resp.StatusCode)
193196
}
194197
})
195198

@@ -209,8 +212,8 @@ func TestGroupModifyHandler_DeeperPaths(t *testing.T) {
209212
}
210213
defer func() { _ = resp.Body.Close() }()
211214

212-
if resp.StatusCode == 0 {
213-
t.Error("zero status")
215+
if !intInSlice(resp.StatusCode, []int{http.StatusOK, http.StatusFound, http.StatusInternalServerError}) {
216+
t.Errorf("expected 200, 302, or 500, got %d", resp.StatusCode)
214217
}
215218
})
216219
}
@@ -251,8 +254,11 @@ func TestListHandlers_ExampleServer(t *testing.T) {
251254
}
252255
defer func() { _ = resp.Body.Close() }()
253256

254-
if resp.StatusCode == 0 {
255-
t.Error("zero status")
257+
// Example-server mocked responses render a page (200), redirect to
258+
// /login on auth failure (302), or render the 500 page on a
259+
// downstream LDAP modify failure. Never 0.
260+
if !intInSlice(resp.StatusCode, []int{http.StatusOK, http.StatusFound, http.StatusInternalServerError}) {
261+
t.Errorf("expected 200, 302, or 500, got %d", resp.StatusCode)
256262
}
257263
})
258264
}

0 commit comments

Comments
 (0)