test(e2e): tighten the install-key and access-policy cases - #7084
test(e2e): tighten the install-key and access-policy cases#7084otavio wants to merge 12 commits into
Conversation
86bb939 to
3f1585f
Compare
|
/review |
|
Claude finished @otavio's task in 15m 55s —— View job Code Review CompleteReviewed 6 files. 2 inline issues posted — Standards: 0 violations, 2 judgement calls. Spec: 0. Correctness: 0. StandardsThe worst is at CorrectnessNo defects. The SpecThe Spec axis did not run: the PR references no issue. Additional notesNo cap dropped anything (2 of 5 judgement-call slots used). Four candidates were dropped because this branch did not introduce them, in case they're wanted as follow-ups: the SSH handshake in I could not verify compilation: To request another review round, comment |
3f1585f to
8d10641
Compare
DenialReason is the value the server logs and queries rely on, so the test now builds the expected "reason=" line from models.ReasonNoGrant and models.ReasonDeniedByPolicy. A rename moves the test with it instead of leaving it waiting on a string the gateway no longer writes.
The wait for the approval code watched only the banners, so a dial that failed before an approval was minted (device offline, the connection dropped by AuthenticableSessionOrDrop, openApproval erroring before the banner) sat out the full 60s and was reported as "the gateway never offered an approval code" while the real error was discarded. The loop now selects on the banners, the dial result and one deadline on the test goroutine. The failure has to be raised there: testify runs an Eventually condition in a goroutine of its own, where require's FailNow would exit that goroutine rather than fail the test. A dial that returns nil without a code is a failure too, since an unknown key must not get in unapproved. This also drops the 10ms tick, which was never the real cadence: testify re-arms its ticker only after the condition returns, and the condition blocked for up to a second.
ssh.ClientConfig.Timeout is read only by ssh.Dial, which hands it to net.DialTimeout for the TCP connect. dialSSH connects with DialContext and passes the conn to ssh.NewClientConn, which neither consults Timeout nor sets a deadline, so the 15s claimed a bound the handshake never had. Removing it changes no behaviour. A real cap would be a conn deadline around NewClientConn with a budget chosen per caller, since the approval case has to outlast the server's 90s approval window.
assert.NoError records a failure without leaving the condition, so a failed agent.State still reached state.Running. A nil state panics inside the goroutine testify spawns for the condition, taking down the whole test binary (and, with Ryuk disabled, leaving every compose stack unreaped); a zero-value state has Running false and makes the subtest pass because Docker failed. Return from the condition on the error instead, as awaitServerLogContains already does.
awaitDevicesWithStatus lists /api/devices?status=<status>, and a non-empty status becomes a real WHERE status = ? in the store, so every device it returns already carries the queried status and the follow-up equality could not fail. The signal is the poll's assert.Len: a device enrolled in the wrong state leaves the filtered list empty and the wait times out. With nothing left reading the devices, the helper no longer returns them.
The helpers reported failures against the T that built the stack, so a failing subtest blamed the parent and the run carried on past it. They now take the caller's T, and the field holding the setup one is named setupT to say which is which. AuthUser takes it in place of the context it used to take, so its callers move with it.
Creating an install key or an access policy, waiting for a device to reach a status or a key to reach a use count, enrolling an identity and reading a service's log were each spelled out inside whichever case needed them first, as a request and an Eventually the next case then repeated. They are things a test does to a running stack, so they belong beside NewUser and NewMember. AwaitLogContains takes any log source, because the agent is a container the test starts itself rather than a compose service. It polls every 2s rather than the 1s the API waits use, since each tick reads the whole log from the start. AwaitDeviceWithStatus asserts through the ?status= filter instead of reading the device back: the filter is a real WHERE clause, so a device in the wrong state leaves the list empty and the wait fails. The cases move onto these in the commits that follow.
The ssh cases built their own stack, logged their own user in and spelled out their own SSHID, so every case repeated the four lines before the one it was actually about. They now go through newSSHEnvironment, startAgent and startAcceptedAgent, and name the gateway through SSHAddress and the device through deviceSSHID rather than concatenating either in place. startAcceptedAgent keeps only the accepting: the container lifecycle it used to own is startAgent's, so a case that wants a pending device can reach for one without getting the acceptance too. newSigner and NewAgentContainerWithInstallKey move here from the cases that happened to need them first, next to the other agent options.
The two mode cases differed only in the mode they created the key with, the status they expected and the uses they expected charged, so they become a table over those three. Creating the key, starting the agent and waiting for either now go through the harness, which leaves the case with the three lines that say what it is about. The unissued-key case waits for the agent to log the refusal before watching it exit, so a container that dies for an unrelated reason no longer reads as the server having turned the key down.
The three refusal cases differed only in what they set up and which server log proved the refusal, so they become a table over those two, with requireLoginRefused doing the dial and the waiting. Enrolling an identity, creating a policy and reading the server log now go through the harness rather than being spelled out per case. The doc comment names the approval case alongside the others, since it is the one subtest the table does not cover.
The case only required the dial to fail, so a tunnel that was not up yet or a transport error passed it just as well as the refusal it is named for, and default-deny regressing into default-allow needed a second failure to go red. It cannot wait for a reason= line the way the other two rows do, because none is logged on this path: Session.Evaluate returns ErrAccessDenied as soon as NamespaceHasAccessPolicies reports false, and Session.authorize, the only place that logs a reason, is never reached. What it waits for instead is the default arm of the switch that reports the failure, plus the error carried with it. That is weaker than a reason, and knowingly so. The message matches any evaluation failure that is neither billing nor firewall, and the two substrings are waited for independently against the whole log, so nothing ties them to one decision. Pinning session.ErrAccessDenied is not on offer: server/ is a separate module, and tests/go.mod requires only the root one.
AwaitInstallKeyUses asserted a different thing depending on the value of an int, and on the zero path it was not a wait at all: EventuallyWithT returns as soon as one tick passes, and a key that has just been created already reports no uses and no stamp, so it returned on its first poll. A server that charged a manual key a tick later would have left the case green, which is the half of "is charged nothing yet" the case exists to prove. AwaitInstallKeyUses now only waits for a count to rise, and requires it. The negative gets RequireInstallKeyUnused, which reads the key once and says so in its name; it is safe there because AwaitDeviceWithStatus has already waited for the enrollment that must not have charged it. The table drops the uses field for a closure per row, the shape the refusal cases already use, so a row names the assertion it wants rather than leaving the call site to read it off an integer.
8d10641 to
cc33d58
Compare
|
/review |
Code Review CompleteThe automated review ran but did not post an updated summary — this usually means no new issues were found since the previous review. If you've pushed changes and want a fresh pass, comment |
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.
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.
What
Twelve commits that tighten what the install-key and access-policy cases assert, how they
fail, and where the verbs they use live. No case is added or removed — the coverage is the
one #7079 landed.
Why
The cases were written against a harness that only knew how to create users and namespaces,
so each one carried its own request-and-
Eventuallyfor install keys, access policies,device statuses and service logs. Several assertions were also softer than they look: a
refusal was matched against a hand-written string, a dial that died before an approval was
offered sat out the full deadline instead of reporting its error, a helper failing inside a
subtest called
FailNowon the parent, and a wait for a key charged nothing returned on itsfirst poll.
Follows #7079, now merged.
Changes
The history is ordered so the mechanical work is separable from the work that changes what
the suite proves: five small fixes, then the harness, then one migration per test file, then
the two assertions that got stronger.
runAdminCommandfailed ondc.t, theTthe environment was created with, and
AuthUserasserted on it directly. When a stack isshared across subtests that
Tis the parent, so a helper failing inside a subtest calledFailNowoff the goroutine running the test — the subtest surfaced as a bareGoexitinstead of its message, and the parent was marked failed with its other subtests still
running. Both take the caller's
Tnow, and the field is renamedsetupTso the nexthelper reaching for it has to notice whose it is.
CreateInstallKey,AwaitInstallKeyUses,RequireInstallKeyUnused,CreateAccessPolicy,AwaitDeviceWithStatus,AwaitServerLog,AwaitLogContainsandEnrollIdentity/EnrollIdentityAsjoinNewUserandNewMemberrather than living as a second set of helpers beside the cases that happened to need them
first.
AwaitLogContainstakes anyLogSource: the agent is a container the test startsitself, not a compose service.
deviceSSHIDfor<login>@<namespace>.<device>andSSHAddressforlocalhost:plus the published port,39 call sites each.
it.
the dial fail. It cannot wait for a
reason=line the way the other two rows do, becausenone is logged on that path:
Session.EvaluatereturnsErrAccessDeniedas soon asNamespaceHasAccessPoliciesreports false, soSession.authorize— the only place thatlogs a reason — is never reached. It waits for the evaluation failure and the error carried
with it instead. That is knowingly weaker than a reason, and the commit message says so:
the message matches any evaluation failure that is neither billing nor firewall, and
session.ErrAccessDeniedcannot be pinned becauseserver/is a moduletests/go.moddoes not require.
waiting out the 60s deadline, and the failure is raised on the test goroutine:
require.FailNowinside anEventuallycondition would exit testify's goroutine ratherthan fail the test.
ssh.ClientConfig.Timeoutdropped fromdialSSH, which never applied it — onlyssh.Dialreads it, and only for the TCP connect. The handshake stays unbounded, which theapproval case needs, with the package timeout as the backstop.
startAgentshared by the install-key cases, and the container lifecycle split out ofstartAcceptedAgentso a case whose device is not accepted by hand can ask for the firsthalf.
?status=filter already guarantees are dropped — the filter is a real
WHEREclause, so a device inthe wrong state leaves the list empty and the wait fails on its own.
AwaitInstallKeyUsesasserted adifferent proposition depending on the value of an int, and on the zero path it was not a
wait:
EventuallyWithTreturns as soon as one tick passes, and a freshly created keyalready reports no uses and no stamp. A server that charged a manual key a tick later would
have left the case green. It now only waits for a count to rise;
RequireInstallKeyUnusedreads the key once for the negative, after
AwaitDeviceWithStatushas waited for theenrollment that must not have charged it.
Testing
tests/builds and vets clean against currentmaster, and so does every commitindividually —
git bisectover this branch will not hit a build error. The suite itself isthe QA job's
testsproject; the eight new stacks are what pushedtest_timeoutto 60m in#7079.