Skip to content

test(e2e): cover install key enrollment and access policy decisions - #7079

Merged
otavio merged 2 commits into
masterfrom
test/e2e-install-key-access-policy
Sep 12, 2026
Merged

test(e2e): cover install key enrollment and access policy decisions#7079
otavio merged 2 commits into
masterfrom
test/e2e-install-key-access-policy

Conversation

@gustavosbarreto

@gustavosbarreto gustavosbarreto commented Sep 11, 2026

Copy link
Copy Markdown
Member

Install keys and the identity access mode had no end-to-end coverage. Every agent in
the suite registered with a tenant id, so the install key path was never exercised on
the wire, and the identity model was covered only by "a password is not offered" and
"an enrolled identity connects" — nothing about what the Access Policies decide.

Install key enrollment (tests/install_key_test.go). The agent enrols with a key
and no tenant id, which is what proves the key resolves the namespace on its own.

  • an automatic key lands the device accepted
  • a manual key leaves it pending
  • an enrolment charges exactly one use
  • a key the namespace never issued enrols nothing

Access policy decisions (tests/identity_access_test.go).

  • an unknown key is held for approval, and confirming it binds the key as an identity
    with source: approval and resumes the login
  • a member with a valid identity that no policy grants is refused, with no_grant
  • a deny beats the allow that would otherwise let the owner in
  • a namespace with every policy deleted admits nobody

Each case stands up its own stack: access mode, policies and identities are all
namespace-wide state, and the case that deletes every policy would otherwise decide
the outcome of its neighbours. The eight cases cost 232s locally, ~30s each —
RemoveImagesAll drops the image tags but the BuildKit cache keeps the layers.
test_timeout goes 45m → 60m, since it sat exactly at the length of the job.

NewMember is new in the environment package: the API route raises an invitation
instead of a membership on editions without direct membership, so it goes through
admin namespace member add.

@gustavosbarreto
gustavosbarreto requested a review from a team as a code owner September 11, 2026 19:45
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown

Claude finished @gustavosbarreto's task in 28m 56s —— View job


Code Review Complete

Reviewed 4 files. 10 inline issues posted — Standards: 1 violation, 5 judgement calls. Spec: axis did not run. Correctness: 4.

Standards

tests/install_key_test.go:89assert.Equal(t, models.DeviceStatusAccepted, devices[0].Status) is an assertion that cannot fail: the list was fetched with ?status=accepted, which WithDeviceStatus turns into a real WHERE status = ?, so every element already carries the queried status. tdd's "No tautological assertions" names exactly this. Same at line 104.

Correctness

tests/identity_access_test.go:235 — the fourth subtest asserts only require.Error, with no check on why the dial failed. Subtests 2 and 3 both pair it with awaitServerLogContains, and 5eac475 explains why that's necessary: "Asserting on the log is the only way to tell 'refused for the right reason' from 'refused because the tunnel was not up yet'." A tunnel that isn't up passes this subtest, so a default-deny regression would need a second failure to show up. reason=no_grant is the string to wait on.

The other three: the approval wait loop never reads dialed, so a pre-approval handshake failure is reported as "the gateway never offered an approval code" after burning 60s (identity_access_test.go:133-143); a missing if !assert.NoError guard lets state.Running run on a failed agent.State — panicking in testify's spawned goroutine, or passing spuriously on a zero value (install_key_test.go:151); and ssh.ClientConfig.Timeout is inert once you call NewClientConn directly, so the 15s bound doesn't exist (identity_access_test.go:77).

Spec

The Spec axis did not run: the PR body carries no issue reference and neither commit has a Fixes: trailer, so there is no spec to review against. Reconstructing one from the PR title would test the change against itself, so it was skipped rather than approximated.

Additional notes

  • Two judgement calls were dropped by the 5-per-review cap: the undocumented non-blocking banner send and its magic buffer of 8 (identity_access_test.go:80-89), and createAccessPolicy being the file's only single-caller helper (identity_access_test.go:47-53). A third — the misleading 10*time.Millisecond tick, whose real cadence is ~1s because the condition blocks a second — is folded into the correctness comment on the same lines, since one restructure fixes both.
  • Two agent findings were discarded in validation as wrong: the NewAgentContainerWithInstallKey doc comment "omitting" that it sets the key (the sibling option constructors carry no comment at all, so it already exceeds the convention), and the qa.yml comment dropping "45-minute" (it had to go — the timeout it named moved to 60m).
  • Not a defect, but worth your attention before merge: this takes the suite from 3 full compose-stack lifecycles to 11, each Down running RemoveImagesAll, while the budget goes 45m → 60m. Your 232s local measurement is the right basis, but a cold BuildKit cache on a CI runner is the case it doesn't cover.
  • 5eac475 says "Three things the code cannot say" and then lists four.

Verified as correct, for the record: every response shape (all four endpoints return bare arrays, not paginated envelopes), every asserted 200, the auth on compose.R(ctx), the namespace member add arg order behind NewMember, the [2-9A-Z]{8} approval-code alphabet, and that reason=no_grant/denied_by_policy can only come from auther.go:209. No cross-repo impact — the diff touches only tests/ and .github/.


To request another review round, comment /review.
· test/e2e-install-key-access-policy

Comment thread tests/identity_access_test.go
Comment thread tests/identity_access_test.go
Comment thread tests/install_key_test.go
Comment thread tests/identity_access_test.go
Comment thread tests/install_key_test.go
Comment thread tests/identity_access_test.go
Comment thread tests/identity_access_test.go
Comment thread tests/install_key_test.go
Comment thread tests/identity_access_test.go
Comment thread tests/install_key_test.go
@otavio

otavio commented Sep 11, 2026

Copy link
Copy Markdown
Member

All 10 inline items are addressed in 85ab415..c4426d4, one commit each.

Won't address from the summary: the non-blocking banner send with its buffer of 8 and the single-caller createAccessPolicy are fine as they are, and the "three things / lists four" slip is in 5eac475's message, which would need a history rewrite to fix.

@otavio
otavio force-pushed the test/e2e-install-key-access-policy branch from c4426d4 to 582f594 Compare September 11, 2026 21:10
@otavio

otavio commented Sep 11, 2026

Copy link
Copy Markdown
Member

/review

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown

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


Code Review Complete

Reviewed 5 files. 5 inline issues posted — Standards: 0 violations, 4 judgement calls. Spec: axis did not run. Correctness: 1.

All ten findings from the previous round hold. I re-derived each fix against the server source rather than taking it on trust, and the two refactors they produced — the startAgent extraction and the table-driven install-key cases — are behaviour-preserving, with one exception noted below.

Standards

No hard violations. Every surviving comment earns its code-style.md exemption, including the new NewMember doc comment, which states an ordering invariant rather than restating its signature. The worst judgement call is tests/install_key_test.go:58 — the table reintroduced "automatic"/"manual"/"accepted"/"pending" as bare literals where models.InstallKeyMode* and models.DeviceStatus* exist. DeviceStatusAccepted was in this file before round 1; removing the tautological assertion took the last reference to it, so the file now spells out a constant it used to name. Same pattern at identity_access_test.go:147,174,176, three lines from the models.ReasonNoGrant that round 1 introduced.

Correctness

tests/ssh_test.go:130compose.AuthUser asserts against dc.t, and in TestSSHIdentityMode that is the parent T, because newSSHEnvironment runs at line 109 outside every t.Run. Calling it from inside the subtest means a non-200 login runs assert.FailNow on the parent while runtime.Goexit unwinds the subtest's goroutine — the case testing's docs rule out. New on this branch: AuthUser had no callers on origin/master. It's also redundant — newSSHEnvironment already calls compose.JWT(auth.Token) for the owner. The three call sites in identity_access_test.go are fine, since each builds its stack inside its own subtest.

Spec

The Spec axis did not run: the PR body references no issue and no commit carries a Fixes: trailer. Reconstructing a spec from the PR title would test the change against itself, so it was skipped rather than approximated.

Additional notes

Four agent findings were dropped in validation, and the reasons are worth stating since three of them re-open settled threads:

  • dialSSH has no handshake bound now that ClientConfig.Timeout is gone. Round 1 named this and explicitly endorsed the fix taken: "Simplest fix is to drop the field, since removing it changes no behaviour." Re-raising it is churn, and the failure mode offered — a refusal taking 90s instead of failing fast — still ends red, via the awaitServerLogContains that follows.
  • The banner buffer of 8 and single-caller createAccessPolicy: you declined both. Not re-litigated.
  • [2-9A-Z]{8} duplicating pairingcode.Alphabet: round 1 verified that regex as correct on the record. Flagging it now would contradict that.
  • Asserting the literal msg="destination device did not pass the connection evaluation" was raised as log-prose coupling. Rejected: the msg= half is load-bearing. It pins the refusal to Session.Evaluate rather than to auth, which is precisely the distinction 8389fea says the fourth subtest is making — error= alone would not.

Verified as correct, for the record, against the source rather than assumed: the log substrings really do match the emitted format (SHELLHUB_LOG_FORMAT=text at .env:13 → zero-value logrus.TextFormatter, sorting on, so error lands first after msg among uid/sshid/error/tenant — that assertion would silently never match if another field sorted ahead of it, and none does); the agent container's SHELLHUB_LOG_FORMAT=json at ssh_test.go:64 is the agent's, not the server's; no log cross-contamination, since each newSSHEnvironment gets its own compose project; NewMember's arg order matches namespace member add <username> <namespace> <role> (server/admin/namespace.go:346) and runAdminCommand fails on a non-zero exit, so it cannot silently no-op; startAgent carries the pre-Start _ = agent.Stop(ctx, nil) across verbatim, closing round 1's divergence note; and test_timeout: "60m" does feed go test -timeout, with no timeout-minutes anywhere in the workflow capping it lower.

One thing that is not a defect but survives from round 1 unchanged: this takes the tests package from 3 compose-stack lifecycles to 11, each Down running RemoveImagesAll, while the budget goes 45m → 60m. A 267% increase in stacks against 33% more time. Your 232s local measurement is the right basis; a cold BuildKit cache on a CI runner is still the case it doesn't cover. Worth watching the first few runs.

No cross-repo impact — the diff touches only tests/ and .github/, so cloud/'s replace directive is unaffected. No security findings: the diff adds no routes, so the security.md permission rule and the anonymous allowlist are not in play.


To request another review round, comment /review.
· test/e2e-install-key-access-policy

Comment thread tests/ssh_test.go Outdated
Comment thread tests/install_key_test.go Outdated
Comment thread tests/identity_access_test.go
Comment thread tests/install_key_test.go
Comment thread tests/install_key_test.go Outdated
@otavio
otavio force-pushed the test/e2e-install-key-access-policy branch from 582f594 to 93e4305 Compare September 11, 2026 22:06
otavio
otavio previously approved these changes Sep 11, 2026
The suite registered every agent with a tenant id, so install keys had no
coverage on the wire at all, and the identity model was exercised only for
"a password is not offered" and "an enrolled identity connects". Both
features were validated by hand against a restored production dump; these
cases are that validation, automated.

Each case stands up its own stack. Sharing one would be cheaper, but a
namespace's access mode, its policies and its enrolled identities are all
namespace-wide state, and a case that deletes every policy would decide the
outcome of its neighbours.

Three things the code cannot say:

The subtests take context.Background() rather than t.Context() because Up
captures the context inside the closure it stores as Down, and Down runs
from t.Cleanup, after t.Context() is already cancelled. Every teardown
would fail its require.NoError.

An authorization refusal reaches the SSH client as a bare auth failure: the
reason and the policy name go to the server's log, and the banner carrying
them is only sent to web sessions. Asserting on the log is the only way to
tell "refused for the right reason" from "refused because the tunnel was
not up yet".

The approval code is read from the link in the banner rather than from the
"Security code" line, which groupCode splits with a space, and rather than
from an API, because approvals are only addressable by a code the caller
already holds.

The unknown-key case dials from a goroutine and puts a deadline on both the
banner and the result: ssh.Dial applies its timeout to the TCP connect
only, so a handshake left waiting on an approval would otherwise hang until
the whole test binary hits its timeout.
The install-key and access-policy cases add eight stacks to the suite. They
cost 232s measured locally — around 30s each, because RemoveImagesAll drops
the tags while the BuildKit cache keeps the layers, so the rebuild that
follows is nearly free.

That is a small addition, but test_timeout was exactly 45m on a job already
taking about that long, so it had no room to absorb it.
@otavio
otavio force-pushed the test/e2e-install-key-access-policy branch from 93e4305 to f08b990 Compare September 12, 2026 21:59
@otavio
otavio merged commit 323d5ab into master Sep 12, 2026
38 checks passed
@otavio
otavio deleted the test/e2e-install-key-access-policy branch September 12, 2026 22:01
otavio added a commit that referenced this pull request Sep 12, 2026
A stacked PR never got an automatic review. #7084 was opened against the branch of #7079 at
22:01:33, #7079 merged two seconds later, and GitHub retargeted #7084 onto master at 22:01:37.
The opened event carried the old base, so branches: [master] filtered it out and no run was ever
queued; the retarget arrives as edited, which nothing listened for; and the force-push that
followed is synchronize, which stays excluded so the author drives re-review. The PR fell through
every trigger and only got a review when its author asked for one by hand.

edited covers the retarget, gated on changes.base so editing a title or a description does not
spend a review. ready_for_review closes the sibling hole, where a PR opened as a draft is filtered
by the draft check at open and has nothing to re-trigger it once it is ready.

The same guard has to sit on cancel-in-progress, not only on the job. A run joins the concurrency
group and cancels the one in flight before any job-level if: is evaluated, so gating the job alone
would let a title edit cancel a running review and then skip its own, leaving no review at all —
and it would defeat this change's own case, where a retarget starts a review that the author's
next title edit kills. The other triggers cannot produce an edited action, so their runs keep
cancelling as they do today.

The comment saying the review runs only on open went with the change that made it false.
otavio added a commit that referenced this pull request Sep 12, 2026
A stacked PR never got an automatic review. #7084 was opened against the branch of #7079 at
22:01:33, #7079 merged two seconds later, and GitHub retargeted #7084 onto master at 22:01:37.
The opened event carried the old base, so branches: [master] filtered it out and no run was ever
queued; the retarget arrives as edited, which nothing listened for; and the force-push that
followed is synchronize, which stays excluded so the author drives re-review. The PR fell through
every trigger and only got a review when its author asked for one by hand.

edited covers the retarget, gated on changes.base so editing a title or a description does not
spend a review. ready_for_review closes the sibling hole, where a PR opened as a draft is filtered
by the draft check at open and has nothing to re-trigger it once it is ready.

The same guard has to sit on cancel-in-progress, not only on the job. A run joins the concurrency
group and cancels the one in flight before any job-level if: is evaluated, so gating the job alone
would let a title edit cancel a running review and then skip its own, leaving no review at all —
and it would defeat this change's own case, where a retarget starts a review that the author's
next title edit kills. The other triggers cannot produce an edited action, so their runs keep
cancelling as they do today.

The comment saying the review runs only on open went with the change that made it false.
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