Skip to content

Commit df6b083

Browse files
authored
test(e2e): close the skipped-browser-journeys gap (#557)
## Summary - Wire a working `TestMain` under `internal/e2e/` that stands up an OpenLDAP testcontainer (pre-seeded with `ou=users` / `ou=groups`, `admin-user`, `testuser1`, `developers`), installs the Playwright Chromium headless shell, starts ldap-manager in-process on an ephemeral 127.0.0.1 port, and publishes `E2E_*` env vars so the per-test helpers work unchanged. The suite was previously compile- broken (a stale `BrowserNewPageOptions.ViewportSize` field name) and CI never shipped an app + LDAP for it to talk to. - Fix the `ViewportSize` → `Viewport` field name so the file builds under `-tags=e2e`. - Adjust the user-list / 404 assertions to the real templ output (`.list-container` + `[data-search-input]` instead of `<table>`, "does not exist" instead of "404"), and replace the deprecated `WaitForTimeout` poll with a `Locator.WaitFor()` web expect. - Add `TestUserDetailJourney` — the spec explicitly lists "click a user → DN + attributes render" as one of the five required journeys but nothing covered it before. - Flip `enable-e2e-tests: true` on the `netresearch/.github` reusable `go-check` workflow with `e2e-test-packages: ./internal/e2e/...` so the suite runs on every PR. Coverage uploads under the existing `e2e` flag in [`.github/codecov.yml`](.github/codecov.yml). Record the opt-in on the ci.yml intentional-drift entry. ## Browser automation choice **Playwright** (`github.com/playwright-community/playwright-go`). The repo already committed to it — `go.mod` pins `playwright-community/ playwright-go v0.5700.1` and the existing `e2e_helpers.go`/`user_ journey_test.go` are built on its `BrowserType` / `Locator` / `Page` API — so rewriting to chromedp would have meant throwing away ~30 working helpers plus the seven existing journey tests. Playwright's strict-mode Locators + `WaitForURL` web-expects also give us the assertion ergonomics the flows rely on (strict-mode violations surfaced the list-vs-table drift on first run). `playwright.Install` in `TestMain` is idempotent — the first CI run caches ~110 MB of Chromium under `~/.cache/ms-playwright`, every subsequent run is a no-op. ## Journeys covered | # | Requirement | Where | | - | ----------- | ----- | | 1 | Login: `/` → `/login`, submit valid creds, land on user list | `TestLoginJourney` (login_with_valid_credentials_succeeds, logout_works_correctly), `TestProtectedRoutes` | | 2 | User list: seeded user visible, filter/search works | `TestUserListJourney` (user_list_page_loads, user_list_shows_user_data, search_functionality_works) | | 3 | User detail: click a user, DN + attributes render | **new** `TestUserDetailJourney` | | 4 | Logout: back to login, cookie cleared | `TestLoginJourney/logout_works_correctly`, `TestSessionPersistence` | | 5 | Auth negative: wrong password → stay on login with error | `TestLoginJourney/login_with_invalid_credentials_shows_error`, `TestLoginJourney/login_with_empty_credentials_shows_validation` | Plus the suite also exercises 404 rendering, nav visibility, CSRF token presence, password-field masking, aria labels, and mobile / tablet viewport rendering. ## Bootstrap time Local, warm caches: ``` go test -race -tags=e2e -coverprofile=coverage-e2e.out -covermode=atomic ./internal/e2e/... ok github.com/netresearch/ldap-manager/internal/e2e 17.234s coverage: 64.8% of statements ``` Local, cold cache (first pull of osixia/openldap:1.5.0 + Playwright Chromium Headless Shell 143.0.7499.4 download, 109.7 MiB): ~60 s additional. The reusable `go-check` e2e job budgets 30 min (`e2e-timeout-minutes`, default) and the actual test-execution window is 25 min (set by the workflow), so there's plenty of headroom even on the cold path. ## Caveats * **OpenLDAP ACL loosening.** `osixia/openldap`'s default ACL hides the DIT root from non-admin users. Every `FindUsers` / `FindGroups` call via the per-user bind (the one the home page, user list, and group list all make) would return `LDAP Result Code 32 "No Such Object"` and handlers would 500. `TestMain` now applies the same permissive ACL we ship in `dev/acl.ldif` (`by users read`) via `ldapmodify -Y EXTERNAL` before seeding. Production deployments are expected to have a service account with broader read rights; the ACL loosening only affects the test container. * **`simple-ldap-go`'s "example server" heuristic.** The library short-circuits real LDAP ops for any server URI whose host contains `localhost` (among ~15 other substrings). The testcontainers Docker provider returns `localhost` on native Linux runners, so `TestMain` rewrites the host to `127.0.0.1` before handing the URI to `options.Opts`. * **Lint pedantics.** The pre-existing `e2e_helpers.go` has a handful of `errcheck` / `nlreturn` findings. They're invisible to CI because `golangci-lint-action` doesn't pass `-tags=e2e`, so the e2e files stay outside its visible file set. I deliberately didn't touch them — out of scope and would muddy the review. ## Test plan - [ ] `go test -race -tags=e2e -coverprofile=coverage-e2e.out -covermode=atomic ./internal/e2e/...` passes locally - [ ] `enable-e2e-tests: true` job runs green on this PR in CI - [ ] Codecov ingests the `e2e` flag (check the PR comment) - [ ] Template-drift job still passes (drift reason updated in `.github/template.yaml`)
2 parents 1954e1e + 00b1ef1 commit df6b083

4 files changed

Lines changed: 443 additions & 17 deletions

File tree

.github/template.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ intentional-drift:
1212
from coverage.out before enforcing the 80% threshold. This keeps authored helpers
1313
in the templates package (flash.go, specializeUsers/Groups/Computers, formatLastLogon)
1414
in the numerator while excluding the framework-internal defer/error branches of
15-
generated templ code.
15+
generated templ code. Also opts into the reusable workflow's e2e tier
16+
(enable-e2e-tests=true, e2e-test-packages=./internal/e2e/...) so the
17+
browser-level journeys under internal/e2e/ — bootstrapped via a
18+
testcontainers OpenLDAP + Playwright-driven headless Chromium against
19+
an in-process ldap-manager listener — run on every PR with coverage
20+
uploaded under the "e2e" Codecov flag.
1621
- path: .github/workflows/container.yml
1722
reason: "platforms narrowed to linux/amd64,linux/arm64 \u2014 Dockerfile base images\
1823
\ (oven/bun, golang-alpine) don't publish i386/arm-v6/arm-v7 variants, so a 5-platform\

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ jobs:
2323
enable-license-check: true
2424
enable-codecov: true
2525
enable-integration-tests: true
26+
enable-e2e-tests: true
27+
# The e2e suite spins up an OpenLDAP testcontainer, installs Playwright
28+
# browsers, and starts ldap-manager in-process before driving it via a
29+
# headless Chromium. Coverage is reported under the "e2e" Codecov flag
30+
# (see .github/codecov.yml) so unit / integration / e2e numbers remain
31+
# separable. Only the internal/e2e package is gated by the build tag.
32+
e2e-test-packages: "./internal/e2e/..."
2633
# Exclude the auto-generated internal/web/templates package from the
2734
# coverage calculation: the *_templ.go files are produced by the templ
2835
# CLI and contain many framework-internal defer/error branches that

internal/e2e/main_test.go

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
//go:build e2e
2+
3+
package e2e
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"log"
9+
"net"
10+
"net/http"
11+
"os"
12+
"path/filepath"
13+
"strings"
14+
"testing"
15+
"time"
16+
17+
ldap "github.com/netresearch/simple-ldap-go"
18+
"github.com/playwright-community/playwright-go"
19+
"github.com/testcontainers/testcontainers-go"
20+
"github.com/testcontainers/testcontainers-go/wait"
21+
22+
"github.com/netresearch/ldap-manager/internal/options"
23+
"github.com/netresearch/ldap-manager/internal/web"
24+
)
25+
26+
// Bootstrap constants shared between the pre-seeded OpenLDAP container and the
27+
// in-process ldap-manager app under test.
28+
const (
29+
bootstrapBaseDN = "dc=example,dc=com"
30+
bootstrapDomain = "example.com"
31+
bootstrapOrg = "Example Inc"
32+
bootstrapAdminDN = "cn=admin," + bootstrapBaseDN
33+
bootstrapAdminPass = "adminpassword"
34+
35+
// Pre-seeded test user (see seedLDIF). uid is what LoginAsTestUser uses
36+
// because simple-ldap-go falls back from sAMAccountName to uid on OpenLDAP.
37+
bootstrapTestUser = "testuser1"
38+
bootstrapTestPassword = "password1"
39+
)
40+
41+
// aclLDIF loosens the osixia/openldap default access control so any
42+
// authenticated user can read the directory. Without this, simple-ldap-go's
43+
// FindUsers/FindGroups calls from a per-user bind fail with "No Such Object"
44+
// because the default ACL hides the DIT root from non-admin users. The app
45+
// renders the home page via per-user LDAP, so /users and /groups would
46+
// return 500 without this loosening.
47+
const aclLDIF = `dn: olcDatabase={1}mdb,cn=config
48+
changetype: modify
49+
replace: olcAccess
50+
olcAccess: {0}to attrs=userPassword
51+
by self write
52+
by anonymous auth
53+
by * none
54+
olcAccess: {1}to *
55+
by users read
56+
by * none
57+
`
58+
59+
// seedLDIF is piped into ldapadd via the OpenLDAP container exec shell. It
60+
// provisions the entries the e2e journeys exercise:
61+
//
62+
// - ou=users, ou=groups (standard organisational units)
63+
// - uid=admin in ou=users (the user E2E_ADMIN_USER=admin logs in as;
64+
// this entry is what indexHandler/FindUsers matches so the home page
65+
// renders without a 500)
66+
// - uid=testuser1 in ou=users (second user for list-visibility checks)
67+
// - cn=developers in ou=groups (non-empty group detail page)
68+
//
69+
// The osixia/openldap container also owns cn=admin,dc=example,dc=com (root
70+
// DN, password=LDAP_ADMIN_PASSWORD) — that's what the app uses as its
71+
// service account. Login form "admin" resolves via simple-ldap-go's
72+
// uid-fallback to uid=admin,ou=users,dc=example,dc=com.
73+
const seedLDIF = `dn: ou=users,dc=example,dc=com
74+
objectClass: organizationalUnit
75+
ou: users
76+
77+
dn: ou=groups,dc=example,dc=com
78+
objectClass: organizationalUnit
79+
ou: groups
80+
81+
dn: cn=admin-user,ou=users,dc=example,dc=com
82+
objectClass: inetOrgPerson
83+
objectClass: organizationalPerson
84+
objectClass: person
85+
objectClass: top
86+
cn: admin-user
87+
sn: Admin
88+
uid: admin
89+
mail: admin@example.com
90+
userPassword: adminpassword
91+
description: E2E admin user
92+
93+
dn: cn=testuser1,ou=users,dc=example,dc=com
94+
objectClass: inetOrgPerson
95+
objectClass: organizationalPerson
96+
objectClass: person
97+
objectClass: top
98+
cn: testuser1
99+
sn: User
100+
uid: testuser1
101+
mail: testuser1@example.com
102+
userPassword: password1
103+
description: E2E test user
104+
105+
dn: cn=developers,ou=groups,dc=example,dc=com
106+
objectClass: groupOfNames
107+
objectClass: top
108+
cn: developers
109+
member: cn=admin-user,ou=users,dc=example,dc=com
110+
member: cn=testuser1,ou=users,dc=example,dc=com
111+
`
112+
113+
// TestMain boots the e2e harness:
114+
//
115+
// 1. chdir to repo root so NewApp can load internal/web/static/manifest.json
116+
// 2. start a pre-seeded OpenLDAP testcontainer
117+
// 3. install Playwright browsers (no-op if already cached)
118+
// 4. start ldap-manager in-process on 127.0.0.1:<random>, wait for /health/live
119+
// 5. publish E2E_* env vars the per-test helpers pick up
120+
//
121+
// Any step failing aborts the whole suite with a clear message.
122+
func TestMain(m *testing.M) {
123+
if err := chdirToRepoRoot(); err != nil {
124+
log.Fatalf("e2e bootstrap: %v", err)
125+
}
126+
127+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
128+
defer cancel()
129+
130+
ldapContainer, ldapURI, err := startOpenLDAP(ctx)
131+
if err != nil {
132+
log.Fatalf("e2e bootstrap: start openldap: %v", err)
133+
}
134+
defer func() {
135+
termCtx, termCancel := context.WithTimeout(context.Background(), 30*time.Second)
136+
defer termCancel()
137+
_ = ldapContainer.Terminate(termCtx)
138+
}()
139+
140+
if err := seedOpenLDAP(ctx, ldapContainer); err != nil {
141+
log.Fatalf("e2e bootstrap: seed openldap: %v", err)
142+
}
143+
144+
if err := playwright.Install(&playwright.RunOptions{Browsers: []string{"chromium"}}); err != nil {
145+
log.Fatalf("e2e bootstrap: install playwright: %v", err)
146+
}
147+
148+
port, err := freePort()
149+
if err != nil {
150+
log.Fatalf("e2e bootstrap: free port: %v", err)
151+
}
152+
153+
app, err := web.NewApp(&options.Opts{
154+
LDAP: ldap.Config{
155+
Server: ldapURI,
156+
BaseDN: bootstrapBaseDN,
157+
IsActiveDirectory: false,
158+
},
159+
ReadonlyUser: bootstrapAdminDN,
160+
ReadonlyPassword: bootstrapAdminPass,
161+
PersistSessions: false,
162+
SessionDuration: 30 * time.Minute,
163+
CookieSecure: false, // HTTP listener under test
164+
PoolMaxConnections: 5,
165+
PoolMinConnections: 1,
166+
PoolMaxIdleTime: 5 * time.Minute,
167+
PoolMaxLifetime: 30 * time.Minute,
168+
PoolHealthCheckInterval: 30 * time.Second,
169+
PoolConnectionTimeout: 10 * time.Second,
170+
PoolAcquireTimeout: 5 * time.Second,
171+
})
172+
if err != nil {
173+
log.Fatalf("e2e bootstrap: new app: %v", err)
174+
}
175+
176+
appCtx, appCancel := context.WithCancel(context.Background())
177+
serverErr := make(chan error, 1)
178+
go func() { serverErr <- app.Listen(appCtx, fmt.Sprintf("127.0.0.1:%d", port)) }()
179+
180+
baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
181+
if err := waitForReady(ctx, baseURL); err != nil {
182+
appCancel()
183+
log.Fatalf("e2e bootstrap: wait for app: %v", err)
184+
}
185+
186+
_ = os.Setenv("E2E_BASE_URL", baseURL)
187+
_ = os.Setenv("E2E_ADMIN_USER", "admin")
188+
_ = os.Setenv("E2E_ADMIN_PASS", bootstrapAdminPass)
189+
_ = os.Setenv("E2E_TEST_USER", bootstrapTestUser)
190+
_ = os.Setenv("E2E_TEST_USER_PASS", bootstrapTestPassword)
191+
192+
code := m.Run()
193+
194+
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
195+
_ = app.Shutdown(shutdownCtx)
196+
shutdownCancel()
197+
appCancel()
198+
select {
199+
case <-serverErr:
200+
case <-time.After(2 * time.Second):
201+
}
202+
203+
os.Exit(code)
204+
}
205+
206+
// startOpenLDAP brings up an osixia/openldap container on a random host port
207+
// and returns its live ldap:// URI.
208+
func startOpenLDAP(ctx context.Context) (testcontainers.Container, string, error) {
209+
req := testcontainers.ContainerRequest{
210+
Image: "osixia/openldap:1.5.0",
211+
ExposedPorts: []string{"389/tcp"},
212+
Env: map[string]string{
213+
"LDAP_ORGANISATION": bootstrapOrg,
214+
"LDAP_DOMAIN": bootstrapDomain,
215+
"LDAP_ADMIN_PASSWORD": bootstrapAdminPass,
216+
"LDAP_TLS": "false",
217+
},
218+
WaitingFor: wait.ForLog("slapd starting").WithStartupTimeout(90 * time.Second),
219+
}
220+
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
221+
ContainerRequest: req,
222+
Started: true,
223+
})
224+
if err != nil {
225+
return nil, "", fmt.Errorf("generic container: %w", err)
226+
}
227+
228+
host, err := container.Host(ctx)
229+
if err != nil {
230+
return container, "", fmt.Errorf("container host: %w", err)
231+
}
232+
// simple-ldap-go treats server URIs containing "localhost" (among other
233+
// substrings) as an example/test server and short-circuits real LDAP ops.
234+
// The testcontainers Docker provider returns "localhost" on native Linux;
235+
// rewrite it to 127.0.0.1 so the LDAP client actually talks to slapd.
236+
if host == "localhost" {
237+
host = "127.0.0.1"
238+
}
239+
port, err := container.MappedPort(ctx, "389")
240+
if err != nil {
241+
return container, "", fmt.Errorf("mapped port: %w", err)
242+
}
243+
244+
return container, fmt.Sprintf("ldap://%s:%s", host, port.Port()), nil
245+
}
246+
247+
// seedOpenLDAP applies the ACL relaxation via ldapmodify (cn=config) and
248+
// then pipes seedLDIF into ldapadd. Eventual consistency in the container
249+
// means slapd may need a beat after "starting" before it accepts writes,
250+
// so each step retries briefly.
251+
func seedOpenLDAP(ctx context.Context, container testcontainers.Container) error {
252+
if err := execLDAP(ctx, container, "ldapmodify", "-Y", "EXTERNAL", "-H", "ldapi:///", aclLDIF); err != nil {
253+
return fmt.Errorf("apply ACL: %w", err)
254+
}
255+
if err := execLDAP(ctx, container, "ldapadd", "-x", "-D", bootstrapAdminDN, "-w", bootstrapAdminPass, "-H", "ldap://localhost", seedLDIF); err != nil {
256+
return fmt.Errorf("apply seed: %w", err)
257+
}
258+
return nil
259+
}
260+
261+
// execLDAP pipes the given LDIF to the given ldap* utility inside the
262+
// container, retrying up to 30s while slapd finishes its handshake.
263+
func execLDAP(ctx context.Context, container testcontainers.Container, bin string, args ...string) error {
264+
// Last positional argument is the LDIF payload to pipe on stdin.
265+
ldif := args[len(args)-1]
266+
args = args[:len(args)-1]
267+
268+
quoted := make([]string, 0, len(args))
269+
for _, a := range args {
270+
quoted = append(quoted, shellQuote(a))
271+
}
272+
273+
cmd := []string{
274+
"bash", "-c",
275+
fmt.Sprintf(`%s %s <<'LDIF'
276+
%sLDIF
277+
`, bin, strings.Join(quoted, " "), ldif),
278+
}
279+
280+
deadline := time.Now().Add(30 * time.Second)
281+
var lastErr error
282+
for time.Now().Before(deadline) {
283+
exitCode, _, execErr := container.Exec(ctx, cmd)
284+
if execErr == nil && exitCode == 0 {
285+
return nil
286+
}
287+
lastErr = fmt.Errorf("%s exit=%d err=%v", bin, exitCode, execErr)
288+
time.Sleep(500 * time.Millisecond)
289+
}
290+
return lastErr
291+
}
292+
293+
// shellQuote is a minimal POSIX-safe single-quote wrapper.
294+
func shellQuote(s string) string {
295+
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
296+
}
297+
298+
// waitForReady polls /health/live until 200 OK or ctx deadline.
299+
func waitForReady(ctx context.Context, baseURL string) error {
300+
client := &http.Client{Timeout: 2 * time.Second}
301+
deadline := time.Now().Add(30 * time.Second)
302+
url := baseURL + "/health/live"
303+
304+
for time.Now().Before(deadline) {
305+
if ctx.Err() != nil {
306+
return ctx.Err()
307+
}
308+
309+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
310+
if err != nil {
311+
return err
312+
}
313+
resp, err := client.Do(req)
314+
if err == nil {
315+
_ = resp.Body.Close()
316+
if resp.StatusCode == http.StatusOK {
317+
return nil
318+
}
319+
}
320+
time.Sleep(200 * time.Millisecond)
321+
}
322+
return fmt.Errorf("ldap-manager did not become ready at %s", baseURL)
323+
}
324+
325+
// freePort grabs an ephemeral localhost port from the kernel.
326+
func freePort() (int, error) {
327+
l, err := net.Listen("tcp", "127.0.0.1:0")
328+
if err != nil {
329+
return 0, err
330+
}
331+
defer l.Close()
332+
return l.Addr().(*net.TCPAddr).Port, nil
333+
}
334+
335+
// chdirToRepoRoot walks up from the current directory until it finds go.mod.
336+
// Matches the helper used by internal/web tests; NewApp resolves the asset
337+
// manifest relative to the process cwd.
338+
func chdirToRepoRoot() error {
339+
cwd, err := os.Getwd()
340+
if err != nil {
341+
return err
342+
}
343+
for dir := cwd; dir != "/" && dir != "."; dir = filepath.Dir(dir) {
344+
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
345+
return os.Chdir(dir)
346+
}
347+
}
348+
return fmt.Errorf("could not locate repo root from %s", cwd)
349+
}

0 commit comments

Comments
 (0)