Repository navigation
test: cover device enrollment and provisioning keys end to end - #7353
Merged
Merged
Conversation
|
Claude encountered an error after 3m 3s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 3m 11s —— View job I'll analyze this and get back to you. |
otavio
force-pushed
the
test/e2e-device-enrollment
branch
from
October 6, 2026 20:02
257cca7 to
e77597e
Compare
luizhf42
previously approved these changes
Oct 6, 2026
otavio
added this pull request to stack #7361
October 6, 2026 22:16
otavio
force-pushed
the
test/e2e-device-enrollment
branch
from
October 7, 2026 13:31
e77597e to
88bb9f4
Compare
The enrollment tests need state the product only reaches with time or in the cloud. An expired provisioning key takes a day, because the API sets an expiry in whole days ahead. A namespace device limit is set only by the cloud, although validateDeviceAcceptance enforces any positive max_devices whatever the edition. So the stack runs psql in its postgres container, and the helpers that need it say which state they stand in for. SQL pipes the statement through stdin because psql does not substitute :'name' variables in a -c command. Passing values as variables keeps them out of the statement text. The provisioning key helpers now list 100 keys per page. A stack shared by many cases holds more keys than the default page of 10, and the old lookups found a key only while it sorted first. RequireProvisioningKeyUsesHold polls for five seconds, so a charge that must not happen cannot pass by arriving after a single read.
The enrollment cases send hand-built requests to the endpoint an agent enrolls through, so each one controls the identity it presents and reads the outcome back through the API. They share one stack to keep CI time down. Accepting a device merges it into an accepted device with the same MAC, so every case gives its devices a MAC and a hostname no other case uses.
An allowlist key decides a device on its own, so it never leaves one pending. The reconcile case starts from a manual key and turns it into an allowlist, which is how a pending device meets an allowlist on a later authentication. The server answers a device's authentication from a cache for 30 seconds, so a re-authentication reaches the enrollment policy only once that entry expires. awaitStatusOnReauth keeps re-authenticating until it does.
The store derives the digests of a namespace's legacy and pairing keys from its tenant id, as the SHA-256 of "system:<tenant>" and "system:pairing:<tenant>". Anyone who knows a tenant id can compute those plaintexts, so the test presents them, after checking they hash to the listed keys' ids. The case that disables the legacy key re-enables it in a cleanup as well, because a failure before the re-enable would leave every later keyless case refused. The cleanup uses context.Background() since t.Context() is already cancelled when cleanups run.
Removing a device keeps its tags, so the re-registered device holds the tag it had and the one the key carries now. The test pins both.
Community namespaces have no device limit, so the test sets max_devices to the count of accepted devices and restores -1 in a cleanup. Lifting the limit lets the next re-authentication accept the pending device, which shows the limit alone held it back.
The event's fingerprint is checked against a fixed key whose SHA-256 fingerprint came from ssh-keygen, not from the Go code the server uses to compute it.
The stub runs in the server's network namespace and answers each call with the decision its path names, after the delay its query asks for. It logs every call it receives as a line of JSON, which the test reads back. The stack allows the webhook to call 127.0.0.0/8, so the server reaches the stub on loopback while every other private address stays behind the SSRF guard. The stub binds all interfaces, so the SSRF case calls it at the server's own network address. The guard is the only thing that can stop that call. The timeout case gives the key a one second timeout and the stub a ten second delay, and checks the enrollment returned within the five second default. The callback window case sleeps three seconds, past the one second TTL, because the token carries no leeway and nothing observable marks its expiry. The throttle case re-authenticates every two seconds for up to the 30 second auth cache plus two reconcile intervals, and checks the integrator was asked again no sooner than a minute after the previous reconcile.
otavio
force-pushed
the
test/e2e-device-enrollment
branch
from
October 7, 2026 14:47
88bb9f4 to
92c3aed
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Covers the 35 open items of Domain 8 in shellhub-io/team#243, Device Enrollment / Provisioning Keys:
34 testcontainers items in
tests/and one Playwright item. The PR is tests only and changes noproduct code.
The webhook cases cover accept, reject, defer, timeout, the HMAC signature, the payload, the
deferred callback accepting and rejecting, a single-use callback token, the callback TTL,
reconcile on re-auth, the once-a-minute throttle, and the SSRF guard.
Two cases write the database through psql, because no API reaches the state they need. One moves
a key's expiry into the past, since the API sets expiry only in whole days ahead. The other sets
max_devices, which only the cloud sets, althoughvalidateDeviceAcceptanceenforces anypositive value in every edition. Each commit message carries the reason behind its fixture.
Evidence
Before: none of these 35 items had a test that asserted its outcome. To show each new case
can fail, I broke the server one behavior at a time and ran the suites. Every targeted case
failed:
IsValidignores disabled, expired, revokedEnrollWithdrops ephemeral; key tags not appliedrejectread as pendingrejectapplied as acceptIsSystemchecks removed400 auth invalidAfter: every case passes on the community stack, through the
testcompose runner:I ran the Playwright spec on community only, because this machine has no license or Stripe
keys. The reveal route is community code and CI runs the spec on every edition.
Found along the way, not fixed here: a provisioning key accepts tag names the tag schema rejects,
for example
first-enrollment. The server then fails to serve the device it tagged, because itsresponse no longer matches the OpenAPI schema.
Merge Danger
Door: two-way
Blast Radius: CI
The
testsjob inqa.ymlgains two stacks, about four minutes. The webhook stub image buildsfrom
golang:1.26.8-alpine3.24, the image the agent test build already uses.