Skip to content

test: cover licensing, the firewall and session recording end to end - #7390

Merged
gustavosbarreto merged 16 commits into
masterfrom
test/e2e-recording
Oct 8, 2026
Merged

gustavosbarreto merged 16 commits into
masterfrom
test/e2e-recording

Conversation

@otavio

@otavio otavio commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Summary

Covers three domains of shellhub-io/team#243 end to end and runs them in a new CI job: Licensing (D21), the firewall items of SSH Legacy Mode (D12) and Session Recording (D15). The tests found four product bugs, and each fix is its own commit next to the case that caught it.

This PR consolidates what was the stack #7377, #7379, #7384 and #7390 so the four review together. The commits keep the stack's order: licensing harness and tests, the license accept fix, firewall, recording.

Enterprise harness and CI

An enterprise stack could only run under the one real license SHELLHUB_LICENSE_FILE named, so expired, limited or feature-less licenses were out of reach. A run now signs its own:

StartRun(IssuingLicenses(issuer))      key in tests/.stack/license-issuer.pem, reused across runs
  server image                         go build -ldflags "$GO_LDFLAGS"
    -X github.com/shellhub-io/cloud/pkg/lic.PublicKey=<issuer public key>
  Up(enterprise stack)
    WithLicense(l) | FullLicense()     signed, written to .stack/licenses/<stack>.dat, mounted
    WithoutLicense()                   no file, empty ADMIN_API_LICENSE_FILE
    WithEveryAddressIn("BR")           mmdbwriter GeoLite2 DBs placing ::/0 in BR
  UseLicense / InstalledLicense / RemoveLicenses / LicenseRows       admin API + licenses table
  WriteLicenseFile / RestartServer / RestartFailingServer            startup loading

The production build leaves GO_LDFLAGS empty. Runs without an issuer, the Playwright suite among them, still read SHELLHUB_LICENSE_FILE.

 qa.yml
   validate (tests - postgres)      go test -skip ^TestEnterprise ./...
+  validate (tests - enterprise)    cloud checkout, branch-matched like ui-e2e.yml
+                                   go test -run ^TestEnterprise ./...   (no license secret)

The firewall and recording layers add their own helpers:

 tests/environment/
+  firewall.go        FirewallRule, CreateFirewallRule, DeleteFirewallRule, FirewallRuleCount
+  recording.go       SetSessionRecording, Get/DeleteSessionRecord, SessionEventCountOfType,
+                     AwaitSessionEventOfType, SessionConverted, AwaitSessionConverted,
+                     RecordingObjects/RemoveRecordingObject (mc inside MinIO),
+                     Break/RepairSessionPtyRequest
   docker_compose.go  EditSSHAccessMode, one stack switching legacy → identity → legacy
   utils.go, stack.go ServiceObjectStorage, the MinIO of an enterprise or cloud stack
   cron.go
-                     RunCron refused a spec more than one job shares (ErrCronAmbiguous)
+                     RunCron enqueues every job on the spec
 tests/
+  license_*_test.go           TestEnterpriseLicensing (31 cases), TestEnterpriseWithoutLicenseFile
+  firewall_test.go            TestEnterpriseFirewall: 14 cases on one stack and one device
+  session_recording_test.go   TestEnterpriseSessionRecording: 14 cases on one stack and one device
   ssh_connection_test.go      requireAccessDenied, shared by the license and firewall tests
   identity_policy_test.go     createTag, replacing two inline copies
 ui/apps/console/e2e/terminal.spec.ts
+  Firewall Rules › a deny rule created in the console keeps the ssh client out
+  Session Recording › a session that printed nothing leaves no browser recording

RunCron had to change because the recording archive job and the active-session reaper both run on * * * * *. asynq names a cron task with a random UUID, so the spec is all that tells them apart.

Product fixes

  • Accepting a device answered 500 when the license could not be read. The fail-open contract never held against the database. validateDeviceAcceptance ran inside UpdateDeviceStatus's transaction, and the enterprise store reads the license through it, so a failed read aborted the transaction and the following DeviceUpdate failed. The license is now evaluated before WithTransaction opens and passed in as a bool. All three accept paths go through updateDeviceStatusOwnedBy: PATCH /api/devices/:uid/accept, enrollment auto-accept and pairing. A merge now costs one license read whose answer it ignores.

     updateDeviceStatusOwnedBy
    +  licensed := licenseAllowsAcceptance(ctx)       evaluator error -> true
       WithTransaction
         updateDeviceStatus(req, ownerID, licensed)
           validateDeviceAcceptance
    -        licenseEvaluator.CanAcceptDevice(ctx)     read joins the tx; a failure aborts it
    +        if !licensed: ErrDeviceLicenseLimit
  • The firewall rule form told users the opposite order. Its priority hint said "Higher values are evaluated first", while the store lists rules ORDER BY priority ASC and the first match decides. It now says "Lower values are evaluated first", and a RuleModal unit test pins it.

  • Cloud answered 500 for a session recording that does not exist. ErrSessionNotRecorded carried ErrCodeStore. It answers 404 now, as the OpenAPI spec documents. The change is shellhub-io/cloud#2634, on test/e2e-recording, the same branch name, so the enterprise job checks it out. It must merge with or before this PR, or the playback and deletion cases fail against cloud master.

  • A browser recording whose file failed to close stayed on disk. finish() promised to remove the partial file but only logged. It removes it now.

  • The browser kept a recording of a session that printed nothing. finish() counted resizes as events. A browser-key login registers the resize handler on the signature challenge and the terminal refits, so a rejected login left a "Session recorded" notice and a cast that replays a blank screen. Only output counts now.

Issue lines covered

D21, Licensing

Line Item Test
754 Firewall routes blocked without feature TestEnterpriseLicensing/feature gating/firewall routes answer 402 without the feature
755 Firewall routes allowed with feature TestEnterpriseLicensing/feature gating/firewall routes answer with the feature
756 Session recording routes blocked without feature TestEnterpriseLicensing/feature gating/session recording routes answer 402 without the feature
757 Session recording routes allowed with feature TestEnterpriseLicensing/feature gating/session recording routes answer with the feature
760 Expired license blocks feature-gated routes TestEnterpriseLicensing/feature gating/an expired license answers 402 on every gated route
761 License endpoint itself skips license check TestEnterpriseLicensing/feature gating/the license endpoint answers under an expired license, .../the license endpoint installs a license when none is installed
764 Accept device within license limit TestEnterpriseLicensing/device acceptance/accepting a device within the license limit succeeds
765 Accept device at exact limit blocked TestEnterpriseLicensing/device acceptance/accepting a device with the fleet at the license limit is refused
766 Accept device with unlimited license TestEnterpriseLicensing/device acceptance/accepting a device under an unlimited license succeeds
767 Accept device with no license blocked TestEnterpriseLicensing/device acceptance/accepting a device with no license installed is refused
768 Accept device with expired license blocked TestEnterpriseLicensing/device acceptance/accepting a device under an expired license is refused
769 Accept device with not-yet-started license blocked TestEnterpriseLicensing/device acceptance/accepting a device under a license not yet started is refused
770 Accept device fails open on store error TestEnterpriseLicensing/device acceptance/accepting a device succeeds when the license cannot be read. BreakLicenseStore renames the licenses table after installing a license that would refuse the device, so only fail-open answers 200
773 SSH connect within license limit TestEnterpriseLicensing/device connection/connecting with the fleet within the license limit succeeds
774 SSH connect over license limit blocked TestEnterpriseLicensing/device connection/connecting with the fleet over the license limit is refused
775 SSH connect with unlimited license TestEnterpriseLicensing/device connection/connecting under an unlimited license succeeds
776 SSH connect with no license blocked TestEnterpriseLicensing/device connection/connecting with no license installed is refused
777 SSH connect with expired license blocked TestEnterpriseLicensing/device connection/connecting under an expired license is refused
778 SSH connect with not-yet-started license allowed TestEnterpriseLicensing/device connection/connecting under a license not yet started succeeds
779 Community edition skips license check already on master: every community SSH test connects with no license and no evaluator, e.g. TestSSHConnectionBehaviour/each cipher the server offers is the one negotiated
785 Allowed region passes TestEnterpriseLicensing/device connection/connecting from a region the license allows succeeds
786 Disallowed region blocked TestEnterpriseLicensing/device connection/connecting from a region the license does not allow is refused
787 Empty allowed_regions means global TestEnterpriseLicensing/device connection/connecting under a license that names no region succeeds from anywhere
790 Valid RSA signature accepted TestEnterpriseLicensing/signature/a license the issuer signed is installed
791 Tampered license body rejected TestEnterpriseLicensing/signature/a license whose body changed after signing is refused
792 Truncated license rejected TestEnterpriseLicensing/signature/a license shorter than its signature is refused
793 Invalid base64 rejected TestEnterpriseLicensing/signature/a license that is not base64 is refused
796 License file loaded on startup TestEnterpriseLicensing/license file/the server installs the license file on startup
797 License file replaces different DB license TestEnterpriseLicensing/license file/a restart with another license file replaces the installed license
798 License file same as DB is no-op TestEnterpriseLicensing/license file/a restart with the same license file installs nothing
799 Empty license path is no-op TestEnterpriseWithoutLicenseFile
800 Invalid license file rejected on startup TestEnterpriseLicensing/license file/an invalid license file stops the server from starting

D12, firewall rule evaluation

Line Item Test
399 Connection allowed when no firewall rules exist TestEnterpriseFirewall/a connection is let through when the namespace has no rules
400 Allow rule matches by source IP, username, hostname TestEnterpriseFirewall/an allow rule matching the source address, the user and the hostname lets the connection through, over a deny-all rule so only a full match lets the client in
401 Deny rule blocks connection by source IP TestEnterpriseFirewall/a deny rule matching the source address blocks the connection, .../a deny rule for another source address lets the connection through
402 Deny rule blocks connection by username TestEnterpriseFirewall/a deny rule matching the user blocks the connection, .../a deny rule for another user lets the connection through
403 Deny rule blocks by hostname filter TestEnterpriseFirewall/a deny rule matching the hostname blocks the connection, .../a deny rule for another hostname lets the connection through
404 Deny rule blocks by tag filter TestEnterpriseFirewall/a deny rule matching the tag blocks the connection, .../a deny rule for a tag the device does not carry lets the connection through
405 Priority ordering: lower priority wins TestEnterpriseFirewall/an allow rule with a lower priority value wins over a matching deny rule, .../a deny rule with a lower priority value wins over a matching allow rule
406 Inactive rule is skipped TestEnterpriseFirewall/an inactive deny rule is skipped
407 Firewall not evaluated in identity mode TestEnterpriseFirewall/a namespace in the identity mode is not held to its firewall rules
408 License device limit blocks connection enterprise: covered by D21 line 774. The license check runs before the firewall, so the firewall's own device limit check never decides an enterprise connection. Cloud: not covered, see below
452 End-to-end: create firewall deny rule in UI, then verify SSH blocked terminal.spec.ts › Firewall Rules › a deny rule created in the console keeps the ssh client out

D15, Session Recording

Line Item Test
553 PTY session is recorded when recording on already covered by terminal.spec.ts › Session Recording › the server keeps the recording after the session ends. TestEnterpriseSessionRecording/which sessions are recorded/an interactive shell is recorded adds the native SSH client and is the positive control for the negative cases below
554 Exec session is NOT recorded .../which sessions are recorded/a command run without a terminal is not recorded. An exec with a pty is recorded: the server decides by pty, not by request type
555 Session not recorded when recording off .../which sessions are recorded/a shell is not recorded while the namespace records nothing
556 SCP/SFTP sessions not recorded .../which sessions are recorded/an SFTP transfer is not recorded, .../an scp copy is not recorded
561 Empty session produces no recording terminal.spec.ts › Session Recording › a session that printed nothing leaves no browser recording, plus recordings.test.ts
564 Session retention prunes server-side recordings .../retention/a session past the retention window takes its archived recording with it
567 Cron converts closed sessions to asciinema in S3 .../archive/the archive job moves a closed session's terminal output to object storage
568 Cron deletes pty-output events after archival same case: no pty-output left, the timeline kept, playback served from the bucket
569 Cron skips failed sessions and retries .../archive/a session that fails to archive is skipped and retried on the next run
573 Multi-seat session records each seat independently .../playback/each seat of a connection plays back only its own terminal, .../archive/each seat is archived to an object of its own
574 Playback of specific seat same two cases, reading seat 1 before and after archival
577 GetSessionAsciinema on unrecorded session returns error .../playback/a session that was not recorded has no recording to play, 404
578 GetSessionAsciinema on no-PTY session returns error .../playback/a session without a terminal has no recording to play, 404
579 DeleteSessionAsciinema clears Recorded flag .../deletion/deleting a recording removes its object and clears the recorded flag
580 DeleteSessionAsciinema when object already gone .../deletion/deleting a recording whose object is already gone clears the recorded flag

Not covered here:

  • D21 749 to 751, the License Guard redirects. These are Playwright items, and test(ui): cover licensing and the admin panel end to end #7369 covers them in license.spec.ts.
  • D12 408 on the cloud edition. A cloud server skips the license check, so the firewall's device limit is the only one that applies. No Go test brings up a cloud stack, and the QA job has no Stripe secrets for one. cloud's services/firewall_test.go covers the limit at the unit level.
  • D21 758 and 759, billing routes blocked or allowed by the feature. No request can reach them. cloud registers /api/billing without the license middlewares in internal/billing/routes/routes.go, and has since the monolith conversion in cloud a0681806. The billing branch of LicenseCheckFeature is dead code.
  • D12 440, session timeout on idle. Neither the gateway nor the agent sets an idle timeout. The line should be struck.
  • D12 442, signal forwarding. Blocked by shellhub-io/team#285: the agent drops forwarded signals.
  • D15 570, cron waits for the database migration lock. The line should be struck. HasLock reads locks.exclusive_acquired, which nothing in shellhub or cloud ever writes, so a test could only prove the row it seeded.
  • D14 546, recorded sessions kept until the recording is purged, belongs to another thread. The retention case covers only the objects going with the row.

Evidence

  • Before: the Go suite had no enterprise test, and an enterprise stack refused to start without a real license file. No test created a firewall rule, read a server-side recording or ran the archive job. Accepting a device with an unreadable license answered 500, and so did playback of a missing recording.
  • After: all run locally, in the test runner and the e2e image:
$ go test -run '^(TestEnterprise.*|TestSessionRetention|TestSSHServerRestart)$' -v .
--- PASS: TestEnterpriseLicensing
--- PASS: TestEnterpriseWithoutLicenseFile
--- PASS: TestEnterpriseFirewall                  14 cases
--- PASS: TestEnterpriseSessionRecording          14 cases
--- PASS: TestSessionRetention
--- PASS: TestSSHServerRestart

$ go test -skip '^TestEnterprise' ./...           the community leg as CI runs it
ok  github.com/shellhub-io/shellhub/tests              1698.166s
ok  github.com/shellhub-io/shellhub/tests/environment  37.559s

$ ui/scripts/e2e.sh test --edition=enterprise terminal.spec.ts -g "SSH Approval|Public Keys|Firewall Rules"   5 passed
$ ui/scripts/e2e.sh test terminal.spec.ts -g "Session Recording" --repeat-each=5                             15 passed
$ vitest run src/pages/firewall-rules                                                                       53 passed
$ vitest run src/utils/__tests__/recordings.test.ts src/components/terminal                                58 passed

Each product fix was red first:

device acceptance/accepting a device succeeds when the license cannot be read   expected 200, actual 500
playback/a session that was not recorded has no recording to play               expected 404, actual 500
terminal.spec.ts › a session that printed nothing ...                           the cast held only [0.329,"r","153x35"]
RuleModal hint test                                                             fails with the old text

Each mutation below made the cases it targets fail: deny rules created inactive, the allow rule moved to priority 300, the inactive rule made active, the identity-mode switch removed, the console rule saved as Allow, and finish() keeping empty recordings.

golangci-lint v2.14.0 reports 0 issues on tests/ with --max-issues-per-linter=0 --max-same-issues=0, and on server/ and cloud's internal/cloud/... for the layers that touch them. ESLint, Prettier, tsc and knip are clean on the console, and actionlint passes qa.yml.

Review

Each layer went through /code-review before it was opened, and the bot review after. The last bot findings were all doc and test accuracy, fixed in the commits that introduced them:

  • Up's contract now covers Unlicensed stacks and the cloud edition's docker-compose.yml check, and WithEdition says when Up fails.
  • FirewallRuleFilter says a device matches when it carries any one of the tags, as cloud's evaluator does.
  • SessionEventCount and SessionEventCountOfType share one query and parser.
  • recordings.test.ts parses the cast instead of matching a substring of it.

A /code-review of the consolidated branch then found:

  • finish()'s doc promised a removal the code skipped, fixed above.
  • The enterprise job's cloud branch probe copied the one in .github/actions/paired-branch. It uses the action now, and the community leg's -skip moved to a test_args field go build never sees.
  • Five helpers parsed labels out of psql's table with regexes of their own. Stack.SQLValue returns the bare value instead.
  • StartRun takes the license issuer directly instead of a one-option variadic, licenseFileEnvs became licensingEnvs, and removeArtifacts moved to stack.go since it also removes GeoIP files.
  • The knip ignore for Playwright output moved to chore(ui): keep knip off the Playwright output directory #7400.

Merge Danger

Door: two-way

The production-facing changes are the license read moving out of the accept transaction, one line of console copy, the browser recorder dropping empty casts, and the GO_LDFLAGS build argument in server/Dockerfile, which is empty unless a build passes it. No schema, API or wire format changes.

Blast Radius: enterprise and CI

Only an enterprise or cloud server runs a license evaluator. Concurrent accepts at the license limit were already racy, because the count was never locked instance-wide, and reading it before the transaction leaves that as it was. The new CI job adds one enterprise build and the stacks of four enterprise tests to QA, using the CLOUD_DISPATCH_APP_* secrets ui-e2e.yml already uses. RunCron now also runs the active-session reaper wherever a test triggers * * * * *.

Merge order: shellhub-io/cloud#2634 first or together.

Part of shellhub-io/team#243.

@otavio
otavio added this pull request to stack #7380 October 7, 2026 21:19
@otavio otavio changed the title test/e2e recording test: cover session recording end to end Oct 7, 2026
@otavio
otavio marked this pull request as ready for review October 7, 2026 21:26
@otavio
otavio requested review from a team as code owners October 7, 2026 21:26
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

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


Code Review Complete

Reviewed 10 files. 2 inline issues posted. Standards: 1 violation, 1 judgement call. Spec: 0 (did not run). Correctness: 0.

Standards

The worst violation is in ui/apps/console/src/utils/__tests__/recordings.test.ts:85. The test checks the cast with a substring match (toContain('"o","hello"')) instead of parsing it, and react-conventions asks for parsed structure. The judgement call is in tests/environment/recording.go:90-104: SessionEventCountOfType duplicates the query and parser of the existing SessionEventCount.

Spec

The Spec axis did not run. The PR closes no issue in this repo, and the only spec it cites, shellhub-io/team#243 Domain 15, is in a repo this run cannot read.

Additional notes

Correctness found no defects. These are the parts it checked, against the cloud/ source where relevant:

  • The psql -v variables and the mc arguments passed as "$@" leave no way to inject SQL or shell commands.
  • Counts read after a session closes are complete, because Session.Finish drains the event writer before it deactivates the session.
  • The order of the skip-and-retry case holds: the session is broken while it is still active.
  • The keys mc ls returns match <uid>/<seat>.asciinema.
  • Retention prunes the bucket objects before it deletes the rows.
  • RunCron enqueueing every job is safe, since the archive handler is unique and takes a per-session lock.
  • finish() still keeps a session that resized and then printed output.

The PR touches no pkg/ or server/api/services symbol, so it has no cross-repo impact on cloud/.


To request another review round, comment /review.

Comment thread ui/apps/console/src/utils/__tests__/recordings.test.ts Outdated
Comment thread tests/environment/recording.go
@otavio
otavio force-pushed the test/e2e-firewall branch from c1eab01 to 49d3fa6 Compare October 8, 2026 14:45
@otavio
otavio force-pushed the test/e2e-recording branch 2 times, most recently from 3a7d5d3 to 6a664c2 Compare October 8, 2026 15:57
@otavio
otavio removed this pull request from stack #7380 October 8, 2026 15:58
@otavio
otavio changed the base branch from test/e2e-firewall to master October 8, 2026 15:58
@otavio
otavio requested a review from a team as a code owner October 8, 2026 15:58
@otavio otavio changed the title test: cover session recording end to end test: cover licensing, the firewall and session recording end to end Oct 8, 2026
@otavio otavio closed this Oct 8, 2026
@otavio otavio reopened this Oct 8, 2026
@otavio

otavio commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

/review

@otavio

otavio commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

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


Code Review Complete

Reviewed 45 files and posted 2 inline issues:

  • Standards: 1 violation, 0 judgement calls.
  • Spec: 0 (did not run).
  • Correctness: 1.

Standards

tests/environment/recording.go:86-88 breaks the code-style.md contract rule. e3dc247 moved Stack.SessionEventCountOfType onto sqlInt/SQLValue, but its doc comment still lists the errors of [Stack.SQL] and the old "psql prints no count" case.

Correctness

In .github/workflows/qa.yml:159-161, the cloud-access gate only checks for forks. Dependabot PRs come from this repo, so they pass the gate, and then run without the CLOUD_DISPATCH_APP_* Actions secrets. create-github-app-token fails, enterprise-tests fails, and the qa job goes red on every Dependabot PR that touches Go. The exception is if those secrets are also stored as Dependabot secrets.

Spec

The Spec axis did not run. The PR closes no issue in this repo, and the spec it cites, shellhub-io/team#243, is in a repo this run cannot read.

Additional notes

I checked the production changes and found no defects:

  • License read moved before WithTransaction. enrollment.go and device-pairing.go both call it outside any transaction. Fail-open still holds, and a merge ignores the answer.
  • GO_LDFLAGS. It is empty by default, so production builds are unchanged.
  • finish() cleanup on a failed close. removeFiles swallows its own errors, so the new call cannot reject.
  • The firewall hint. It now matches cloud's priority ordering.
  • Cross-repo impact. The PR changes no exported pkg/ or server/api/services symbol, so nothing in cloud/ breaks.

I left out a few low-value judgement calls:

  • SessionConverted's doc omits strconv.ParseBool's error, but that error cannot happen on a NOT NULL boolean column.
  • The RuleModal hint test.
  • The license table closures.

To request another review round, comment /review.

Comment thread .github/workflows/qa.yml
Comment thread tests/environment/recording.go Outdated
@otavio
otavio force-pushed the test/e2e-recording branch from 50b815a to 152a662 Compare October 8, 2026 17:04
@otavio

otavio commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Claude finished @otavio's task in 6m 11s —— View job


Code Review Complete

Reviewed 45 files and posted 3 inline issues:

  • Standards: 2 violations and 1 judgement call.
  • Spec: 0 (did not run).
  • Correctness: 0.

Standards

The worst violation is the Up doc comment in tests/environment/stack.go:92-94, which breaks the code-style.md contract rule. It leaves out three errors licensingEnvs can return: the issuer fails to encode its public key, signing fails, or filepath.Abs fails. CreateFirewallRule (tests/environment/firewall.go:38-41) has the same kind of gap: its doc doesn't mention that a 200 with an empty ID also fails the test. The judgement call is in server/api/services/device_test.go:2333-2337. The .Once().Maybe() on the merge path's CanAcceptDevice mock makes the license read optional. That means the case doesn't pin the "a merge reads the license and ignores the answer" behaviour that 8c17dc8 says it does.

Spec

The Spec axis did not run. The PR closes no issue in this repo, and the spec it cites, shellhub-io/team#243, is in a repo this run cannot read.

Additional notes

Correctness found no defects. These are the parts it checked:

  • License read moved before WithTransaction. Every accept path goes through updateDeviceStatusOwnedBy: the HTTP route, enrollment, pairing and provisioning keys. None of them runs inside an outer transaction, and fail-open still holds.
  • Cross-repo impact. The changed function is unexported, and cloud/'s evaluator still satisfies CanAcceptDevice.
  • GO_LDFLAGS in server/Dockerfile. It is a no-op when unset, so production builds are unchanged.
  • qa.yml. head_ref only reaches the paired-branch action through env:, so a branch name cannot inject shell code. The cloud checkout sets persist-credentials: false.
  • Test harness. SQL values go through psql -v variables and the mc arguments through "$@", so neither can be injected.

Two findings were dropped after validation:

  • The RuleModal hint test. It is a red-first regression test for a copy bug users could see, and the previous round left it out too.
  • The OPFS .cast polling in terminal.spec.ts. 624d792 explains why: no UI shows that the recorder exists, and the test still ends on what the UI shows.

To request another review round, comment /review.

Comment thread tests/environment/stack.go Outdated
Comment thread tests/environment/firewall.go Outdated
Comment thread server/api/services/device_test.go
otavio added 16 commits October 8, 2026 14:12
An enterprise stack verified licenses against the production key compiled into
cloud's pkg/lic, so a test could only run under the one real license a developer
or CI secret supplied. Expired, limited, region-bound or feature-less licenses
were out of reach, and so was most of the licensing domain.

A run started with a LicenseIssuer now owns a test RSA key. The harness builds
the server image with -X github.com/shellhub-io/cloud/pkg/lic.PublicKey set to
its public half, through a new GO_LDFLAGS build argument that the production
build leaves empty. The key lives in tests/.stack/license-issuer.pem and runs
reuse it. A fresh key per run would change the build argument and recompile the
enterprise server every time. GO_LDFLAGS is declared right before the build
step so the module download layers stay cached, and the harness sets it only
for enterprise and cloud builds.

Each enterprise stack then writes its license to a file of its own and mounts
it. The enterprise test overlay maps an unset SHELLHUB_LICENSE_FILE to
/dev/null and to an empty ADMIN_API_LICENSE_FILE, which is how WithoutLicense
starts a server that loads no license at all. Runs without an issuer, the
Playwright suite among them, keep reading SHELLHUB_LICENSE_FILE as before.

WithEveryAddressIn mounts GeoLite2 country and city databases, written with
mmdbwriter, that place ::/0 in one country. They include reserved networks and
turn IPv4 aliasing off because a test connects from a private address, which a
stock database leaves unlocated. The locator needs both files to start, and
the coordinates do not matter, so they stay at zero. MAXMIND_MIRROR points at
an unreachable host only to make the server enable the locator. The fetcher
skips every database already present, so it never contacts the host.

RestartFailingServer waits for the container's on-failure policy to give up
rather than for the API, since a server refusing its license file exits.
UseLicense, RestartServer and DeleteDevice outlive the test's context, because
Go cancels that context before cleanups run and these helpers restore state in
a t.Cleanup. A paid edition now fails early, naming CloudDir's go.mod, when the
cloud source is missing, instead of deep inside the image build.
TestEnterpriseLicensing runs one enterprise stack under licenses the run
signs, and swaps the installed license per case through the admin API.
TestEnterpriseWithoutLicenseFile starts a second stack with no license file.

The tests pin two behaviours that read like accidents and are not.

- The server logs a refusal for a region the license does not allow as a
  firewall block. Only the firewall evaluator checks allowed_regions, so the
  region cases run in a legacy-mode namespace with the firewall feature
  licensed.
- A license that has not started refuses device acceptance but lets a
  connection through. CanConnectDevice skips IsStarted on purpose, so that a
  future start date cannot lock an instance out of the devices it already has.

Each acceptance case deletes the pending device it enrolled when it ends,
because startAcceptedAgent expects exactly one pending device. Their MAC
addresses use 02:00:00:00:21:xx, a locally administered prefix numbered after
the domain, so they never merge with another case's device.

Covers shellhub-io/team#243 Domain 21 Feature Gating, Device Acceptance,
Device Connection, License Regions, License Signature and License File
Loading, except the billing gating and fail-open cases.
The testcontainers suite ran on the community stack only, so the licensing
tests, and the firewall and recording tests that follow them, never ran in
CI. A new job checks out the cloud source the way ui-e2e.yml does. The
paired-branch action matches the PR's branch name and falls back to master,
and the job runs ^TestEnterprise. It needs no license secret, because the run
signs its own licenses.

A pull request from a fork cannot mint the cloud token, so the job skips its
steps and leaves a notice instead of failing every outside contribution. The
community leg skips ^TestEnterprise so a run without the cloud source does
not try to build an enterprise stack. The flag goes in a test_args matrix
field of its own, because extra_args also reaches go build, which has no
-skip.
…ansaction

Accepting a device asks the license evaluator whether the instance may hold
one more, and allows the device when the evaluator fails. That fail-open never
held against the database. The check ran inside UpdateDeviceStatus's
transaction, and the enterprise store reads the license and counts devices
through GetConnection(ctx), which joins it. A failed read aborted the
PostgreSQL transaction. The service logged the warning and went on, the next
DeviceUpdate failed on the aborted transaction, and the request answered 500.
Mocked unit tests could not see it, because the mock store has no transaction
to abort.

The decision is now taken before WithTransaction opens and handed to the
transaction, which still refuses only a device that does not merge into an
accepted one. A merge therefore costs one license read it did not before, and
the merge-path unit test now runs against an evaluator that refuses, to pin
that a merge ignores the answer.
BreakLicenseStore renames the licenses table for the rest of the test, so every
license read fails with a database error rather than finding no license. The
case installs a license that would refuse the device first, so a 200 proves
the acceptance failed open instead of passing the limit.

Covers shellhub-io/team#243 Domain 21 "Accept device fails open on store
error".
Two tests created a tag with the same inline request, and the licensing tests
spelled out the access-denied check (log mark, the banner, the server's log
line) that the firewall tests need too. createTag now sits next to tagDevice,
and requireAccessDenied next to dialForBanner.
The rule form told users that higher priority values are evaluated first. The
server lists a namespace's rules by priority ascending and lets the first match
decide, so a user following the hint would order their rules backwards and a
deny meant to win would lose to the allow it was meant to override.
The firewall rule types live in the private cloud module, which the tests
module cannot import, so the harness carries its own request shape. A rule's
response holds tag objects where the request holds tag names, which is why
CreateFirewallRule reads back only the ID.

EditSSHAccessMode lets one stack serve a case in each access mode: a legacy
namespace can switch to identity and back, where building a second stack
would cost minutes.
Each case asserts the wrapped error text next to the server's log line,
because the server logs "blocked by a firewall rule" both for a rule that
denies and for an evaluation that failed (a bad pattern, a missing license).
Only the error text tells a rule's deny apart.

The source address a rule matches is read from the session the client opens
first, not hardcoded: Docker's port publishing decides what the server sees,
and it differs between rootful and rootless daemons.

Every match case has a miss case beside it, and the two priority cases add
their rules in opposite orders, so neither a matcher that ignores its field nor
evaluation in insertion order passes.

Refs: shellhub-io/team#243
The native client logs in once before the rule exists, so the refusal that
follows can only come from the rule the console created.

Killing the native clients moves to the file's afterEach: a third describe
needed the same cleanup the other two each repeated.

Refs: shellhub-io/team#243
finish() discards a recording that captured no events, but it counted resizes as events. A web
terminal that signs in with a browser key registers its resize handler on the signature
challenge, and the terminal refits as soon as the window settles. So a login the user then
rejected left a recording behind holding only a resize line: an entry in the recordings list,
and a "Session recorded" notice, for a cast that replays a blank screen. Only output now marks a
recording worth keeping; resizes are still written so a real recording reflows on replay.

The eventCount getter had no caller and goes with the counter it exposed.
An enterprise stack now hands the test its MinIO container, and tests/environment/recording.go
reads and drives recordings through it and the API: turning a namespace's recording on or off,
fetching and deleting a seat's recording, counting a session's events of one type, reading the
converted mark the archive job sets, listing and removing the objects a session holds in the
bucket, and breaking or repairing a session's terminal request.

Listing goes through mc inside the MinIO container, since the bucket is not published to the
host. A line mc prints that is not an entry it listed successfully is an error, so an empty
listing cannot come from a failure.

Breaking the terminal request stands in for a session whose archive fails: the archive job builds
the cast header from that event, so prefixing its data makes the header unreadable until the
prefix is taken off again. Nothing in the product can be asked to fail one session's archive.

RunCron now enqueues every job scheduled on the spec instead of refusing an ambiguous one. The
recording archive job and the active-session reaper are both scheduled on "* * * * *", and asynq
names a cron task with a random UUID, so the spec is all that tells jobs apart. Both jobs are
safe to run at any time.
TestEnterpriseSessionRecording runs one enterprise stack and covers which sessions are recorded,
playback by seat, the archive job, deleting a recording, and retention taking the archived
recording with the session.

A count of a session's events read once the session is inactive is complete:
Session.Finish drains the session's event writer before it deactivates the session. So the
negative cases read the terminal output count once after close. Only cases that read events
while the session is still open wait for the batched writes to land.

In the skip-and-retry case the broken session is opened inside the healthy session's connection.
The archive job lists sessions newest first and the server's own every-minute tick keeps
running, so the healthy session must never be archivable while the broken one is intact or
missing: any run that archives the healthy session has tried the broken one first.

Asking for a recording a session does not have expects 404. Cloud answered 500 until
shellhub-io/cloud's test/e2e-recording branch made ErrSessionNotRecorded a not-found error.

The 404 wait TestSessionRetention used moves to awaitSessionDeleted, shared with the retention
case here.
The empty session is a browser-key login that the re-authentication policy holds and the user
rejects: the WebSocket stays open while the approval dialog waits, so the recorder is certainly
created before the session ends, and the session prints nothing. A wrong password does not work
for this. The server closes the socket within milliseconds, so the recorder may not exist yet
when the tab closes, and the test passes whether or not empty recordings are dropped.

No UI shows that the recorder exists, so the test waits for its .cast file in the browser's
origin private file system before rejecting, then requires the file gone and the preferences
page to list no recording.
Stack.SQL returns what psql prints, a table with headers and a row count, so
every helper that read a value selected it behind a label ('events=',
'licenses=', 'converted=', 'digest=') and matched the label with a regex of
its own. Five helpers carried five patterns for the same parsing.

Stack.SQLValue runs the query with --tuples-only --no-align, so psql prints
the bare value, and fails when the query selects no row or more than one
line. The counts, the converted mark and the password digest read through
it, and the session event counts no longer share one query that took an
empty event type to mean every type.
finish() promised that a recording whose file fails to close comes back null
with its partial file removed, but the catch only logged and returned null.
The .cast file create() made stayed in the origin-private file system with
no metadata beside it, so the recordings list never showed it and nothing
ever deleted it. The catch now removes the files the way an empty recording
does.
@otavio
otavio force-pushed the test/e2e-recording branch from 152a662 to 8566c78 Compare October 8, 2026 17:12
@otavio

otavio commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Claude finished @otavio's task in 2m 27s —— View job


Code Review Complete

I reviewed all 45 files for Standards (repo rules, Go and React conventions, tests, code smells) and Correctness (security and runtime defects), and found no new issues.

The Spec axis did not run. The PR closes no issue in this repo, and the spec its commits cite, shellhub-io/team#243, is in a repo this run cannot read.

@gustavosbarreto
gustavosbarreto merged commit 9f453bd into master Oct 8, 2026
56 of 58 checks passed
@gustavosbarreto
gustavosbarreto deleted the test/e2e-recording branch October 8, 2026 18:08
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.

2 participants