Skip to content

test: cover the session lifecycle, timeline, list and retention - #7360

Merged
otavio merged 5 commits into
test/e2e-device-enrollmentfrom
test/e2e-sessions
Oct 7, 2026
Merged

otavio merged 5 commits into
test/e2e-device-enrollmentfrom
test/e2e-sessions

Conversation

@otavio

@otavio otavio commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Summary

Covers Domain 14 (Sessions) of shellhub-io/team#243 with testcontainers tests. 14 of the 17 open items now have a test that asserts the outcome the item names. The other three are below, with the reason each is left open.

tests/
├── session_lifecycle_test.go   # TestSessionLifecycle: disconnect, keepalive, authenticated, web, namespace scope
├── session_events_test.go      # + window-change, exit-status and seat cases
├── session_list_test.go        # TestSessionList: device_uid / active / closed filters, started_at order
├── session_retention_test.go   # TestSessionRetention: the real retention job, cascade to events
└── environment/
    ├── cron.go                 # RunCron: enqueue a cron job the server registered, by its spec
    └── docker_compose.go       # BaseURL, AgeSession, SessionEventCount
docker-compose.test.yml         # pass SHELLHUB_SESSION_RETENTION_DAYS, publish redis
Item Test Asserts
Session becomes inactive after disconnect TestSessionLifecycle active is true while connected, false after conn.Close(), with no close endpoint involved
Web terminal session flagged web=true TestSessionLifecycle a session opened through /ws/ssh reads web: true; one opened over SSH reads false
Session authenticated flag set after auth TestSessionLifecycle an accepted key gives authenticated: true; a password the device refuses leaves the session authenticated: false and never active
Session events recorded for window-change TestSessionDetailSaysWhatTheSessionDid the event carries the new size, 132×50
Session events recorded for exit-status TestSessionDetailSaysWhatTheSessionDid the event carries status 3
Session keepalive updates last_seen TestSessionLifecycle last_seen moves past an earlier reading while the session stays active
Multi-seat session tracks seat numbers TestSessionDetailSaysWhatTheSessionDid two channels on one connection give seats [0, 1], each command recorded on its own seat
Session scoped to namespace TestSessionLifecycle another namespace gets 404 for the session and an empty list
Filter sessions by device_uid TestSessionList eq and ne list exactly the expected sessions
Filter sessions by active status TestSessionList active true and false
Filter sessions by closed status TestSessionList closed true and false
Sessions sorted by started_at descending TestSessionList the exact newest-first sequence, with each started_at no later than the one before
Expired sessions listed by retention window TestSessionRetention with a 1-day window: an aged, finished session is deleted; a recent one and an aged one still open are kept
Expired sessions deleted with cascade to events TestSessionRetention the deleted session's rows in session_events drop from more than zero to zero

Retention runs the real job. The community compose file never passed SHELLHUB_SESSION_RETENTION_DAYS to the server, and the job fires only at 01:00:

TestSessionRetention
  stack up with SHELLHUB_SESSION_RETENTION_DAYS=1, redis published on a reserved port
  await server log "session retention enabled"
  AgeSession: move started_at back 48h          # stands in for the days; nothing is deleted by hand
  RunCron("0 1 * * *")
    asynq scheduler entries in redis -> the task registered on that spec
    enqueue it on the server's "cron" queue     # the server's SessionCleanup handler runs it
  await 404 on the expired session, and server log "pruned sessions past the retention window"

TestSessionDetailSaysWhatTheSessionDid now closes each connection and waits for the session to go inactive before it reads the timeline. The gateway drains its event batch when a session finishes, so the timeline is then complete. This applies to the four existing cases too.

Not covered

  • Session records GeoIP position. Community wires the null locator (server/api/services/service.go:128, geoip.NewNullGeoLite). The MaxMind locator is registered only by cloud/internal/cloud/init.go:45, and community CI runs the community stack. The test client also connects from a private address, which no GeoIP database places.
  • Recorded sessions not deleted until recording purged. The recording pruner is registered only by cloud/internal/cloud/recordings.go:50. Without one, pruneRecordings (server/api/services/session-recording.go) hands every expired UID to the delete, recorded or not, so on community the behaviour this item names does not exist.
  • Invalid filter operator rejected. On this base, GET /api/sessions with an operator the field does not take answers c.NoContent(400) (server/api/routes/session.go:41-42). The OpenAPI spec declares a body for that 400, so the test stack's strict response validator replaces the response with a 500 (response header Content-Type has unexpected value: ""). That is OpenAPI declares a response body for statuses answered with no body #7122, which lists GET /api/sessions among its 400 cases. refactor(server): give the List shape the query contract it serves #7035 moves the route onto the List seam, which answers with the error envelope; feat(server): say what was wrong with a rejected filter #7061 adds a reason to it. Once either is on master the test can assert 400.

#7349 (namespace-bounded session mutations) is already in this base. The namespace-scope test reads through the API, which that PR did not change.

Evidence

  • Before: none of these tests existed. With RunCron removed, TestSessionRetention fails: a finished session past the window is deleted, Condition never satisfied.
  • After: the four session tests pass in the one-off test runner:
--- PASS: TestSessionDetailSaysWhatTheSessionDid (46.11s)   7 cases
--- PASS: TestSessionLifecycle (19.90s)                     6 cases
--- PASS: TestSessionList (20.63s)                          4 cases
--- PASS: TestSessionRetention (20.72s)                     3 checks
ok  	github.com/shellhub-io/shellhub/tests	109.944s

The full tests/ suite passes on the compose change (ok ... 1311.228s, 37 top-level tests). golangci-lint reports 0 issues on tests/.

Merge Danger

Door: two-way

Test code and the test-only compose overlay. Nothing in the product changes.

Blast Radius: e2e-stacks

Every test stack now publishes redis on 127.0.0.1, on a port the daemon picks unless a test reserves one, because compose cannot publish a port conditionally. SHELLHUB_SESSION_RETENTION_DAYS defaults to 0 there, which keeps retention off. Enterprise stacks pass their own value, the same 180 days .env.enterprise already sets.

@otavio
otavio requested a review from a team as a code owner October 6, 2026 22:16
@otavio
otavio added this pull request to stack #7361 October 6, 2026 22:16
@otavio otavio changed the title test/e2e sessions test: cover the session lifecycle, timeline, list and retention Oct 6, 2026
@otavio

otavio commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Claude finished @otavio's task in 5m 30s —— View job


Code Review Complete

I reviewed all 10 changed files and posted 1 inline issue. Standards: 0 violations, 1 judgement call. Spec: did not run. Correctness: 0.

Standards

The worst finding is a judgement call for Duplicated Code at tests/session_lifecycle_test.go:34-59. getSession and awaitSession are the third and fourth copies of the session-detail fetch. requireSessionActive and sessionDetail could both delegate to awaitSession.

The code follows the rules otherwise:

  • Every surviving comment is either a doc comment on an exported symbol or a nolint reason.
  • Stack.RunCron lists every error it returns.
  • The commits carry the reasons for their workarounds.

Spec

The PR closes no issue and its commits carry no Fixes: trailer, so there was nothing to review against. It cites shellhub-io/team#243, but that issue is in another repo, which this run cannot read.

Additional notes

The correctness pass checked these areas against the server source and found no defects:

  • RunCron: it reaches the real handler. The task type and payload are copied from the scheduler entry, the job goes on the cron queue, and the 0 1 * * * spec belongs only to CronSessionCleanup.
  • Redis exposure: the published redis is loopback-only, because SHELLHUB_BIND_ADDRESS is pinned to 127.0.0.1 in tests/environment/edition.go.
  • Server behaviour the tests rely on:
    • the psql variable substitution in AgeSession and SessionEventCount
    • the WebSocket message kinds 4 and 5
    • the active, closed and device_uid filters, and the started_at DESC order
    • retention skipping sessions that are still active
    • the 30s keep-alive interval, which fits inside the 45s wait

I dropped several smaller judgement calls because they did not clear the review bar:

  • matching the cron job by its spec rather than its task type
  • the exact log-line wait after the 404 check
  • table-driving the filter subtests
  • the events= marker parsed with a regex

To request another review round, comment /review.

Comment thread tests/session_lifecycle_test.go Outdated
@otavio
otavio force-pushed the test/e2e-sessions branch from ba759a6 to c61ced1 Compare October 6, 2026 22:36
@otavio

otavio commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Claude finished @otavio's task in 1m 51s —— View job


Code Review Complete

I reviewed the 11 changed files for Standards (repo rules, Go conventions, test quality, code smells) and Correctness (security and runtime defects). I found no new issues, and the code looks good as it is.

The Spec axis did not run. The PR closes no issue and its commits carry no Fixes: trailer. The one issue it cites, shellhub-io/team#243, is in another repo, which this run cannot read.

@otavio
otavio force-pushed the test/e2e-sessions branch from c61ced1 to 7904bd1 Compare October 7, 2026 13:31
otavio added 5 commits October 7, 2026 11:47
The gateway registers a session before it reaches the device, so a password the device refuses
still leaves a record behind. That record is what proves the authenticated flag is set by the
login, not by the record's creation: it stays false and never joins the active set.

The test agent sends an SSH keepalive every second and the gateway stamps the session on each one,
on top of its own 30-second tick. The last_seen wait is 45 seconds so either source satisfies it.

The web terminal case opens /ws/ssh with the agent's password, so it skips where the agent under
test takes no passwords. The x/net websocket server refuses a handshake without an Origin, hence
the base URL as origin. The terminal names its session in a message of kind 5 and reports a
refusal in kind 4; the kinds are copied because server/ssh/web keeps them unexported.

openSession, finishSession and awaitSession are shared with the list and retention tests that
follow. requireSessionActive and sessionDetail now delegate to awaitSession, so the request for a
session's detail is written once.
Every case now closes its connection and waits for the session to go inactive before reading the
timeline. The gateway writes events in batches every 250ms and drains the queue when the session
finishes, before it deactivates it, so an inactive session has its whole timeline stored. Waiting
for the first event, as before, could read a timeline that still lacked the later ones.

A window-change gets no reply, so the resize case waits until the shell reports the new size
through stty before closing. The gateway records the event before forwarding the request, so a
shell that saw the size means the event was queued.

payload replaces payloadString so one accessor reads string and number fields alike.
The sessions are opened one after another, so the order they were created in is the order of
their started_at, and the newest-first case can assert the exact sequence as well as the
timestamps.
Some behaviour runs only from a cron job, such as session retention at 01:00. RunCron enqueues the
job the server registered on a spec, so the handler runs in the server, through its own queue,
without waiting for the tick.

The asynq scheduler names each job by a UUID minted at boot, so a job is found by its spec in the
scheduler entries asynq writes to redis. A spec two jobs share is refused rather than guessed. The
jobs are enqueued on the "cron" queue, copied from pkg/worker/asynq, which keeps it unexported.

The test reaches redis through a published port. A container address does not work: under
rootless Docker the host network cannot route to it. pasta forwards only ports below the ephemeral
range, so WithCronTrigger reserves one the way the HTTP and SSH ports are reserved. A stack that
does not ask for it still publishes redis, on a port the daemon picks, because compose cannot
publish a port conditionally.
The community compose file never passed SHELLHUB_SESSION_RETENTION_DAYS to the server, so the
retention job was never registered on a test stack. The test compose file now passes it, at 0,
which keeps retention off, unless a test asks for a window.

The window is counted in whole days, so AgeSession moves a session's started_at back in the
database, standing in for the days it would wait to expire. The rows are aged, not deleted: the
deletion is the job's, fired through RunCron after the server logs that retention is enabled.
SessionEventCount reads session_events directly because the API answers 404 for a deleted
session, and the cascade is what it checks.

The aged session that is still open survives because the job skips any session in the active
set. Recordings are not covered: only the cloud registers a recording pruner, and without one the
job deletes recorded sessions as well.
@otavio
otavio force-pushed the test/e2e-sessions branch from 7904bd1 to b132b0d Compare October 7, 2026 14:47
@otavio
otavio merged commit 7169298 into master Oct 7, 2026
55 of 58 checks passed
@otavio
otavio deleted the test/e2e-sessions branch October 7, 2026 16:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant