What We’re Testing
The handleTerraformApi handler in backend/src/handlers/terraform-api.ts provides a stable, API-key-authenticated REST interface designed for infrastructure-as-code tooling such as a Terraform provider. It exposes five resource types under POST/GET/DELETE /api/terraform/* and is registered at app.all('/api/terraform/*', wrapWithUrl(env, handleTerraformApi)) in the router.
Key facts from source code:
- Base path:
/api/terraform/ - Auth:
X-Api-Keyheader — handler hashesapiKey.replace('ztna_', '')and looks up inapi_keyswhererevoked = FALSE; expiry is checked withnew Date(keyData.expires_at) < new Date() - Org scoping: all resources are automatically scoped to
keyData.org_id— noorg_idquery param needed - Resources and methods:
GET /api/terraform/machines— list all machines (fields:id,name,tailnet_ip,os,status,last_seen,tags,advertised_routes,ephemeral,created_at)GET /api/terraform/machines/:id— get single machineDELETE /api/terraform/machines/:id— delete a machineGET /api/terraform/acl-rules— list all ACL rules ordered bypriorityPOST /api/terraform/acl-rules— create ACL rule (fields:name,source,destination,ports,protocol,action,priority)DELETE /api/terraform/acl-rules/:id— delete ACL ruleGET /api/terraform/dns— list DNS configsGET /api/terraform/users— list org members (id,user_id,role,joined_at)GET /api/terraform/settings— get org settingsPUT /api/terraform/settings— update org settings (allowed fields:allow_exit_nodes,allow_subnet_routing,key_expiry_days)
- Validation on ACL rule POST:
name(string, max 100 chars),source(non-empty),destination(non-empty),actionmust be"allow"or"deny",protocolmust be"tcp","udp", or"*" - Error for unknown resource: HTTP 404 with
available: ["machines", "acl-rules", "dns", "users", "settings"]
Your Test Setup
| Machine | Role |
|---|---|
| ⊞ Win-A | Windows workstation — dashboard, curl, API key management |
| 🐧 Linux-C | Linux cloud node — curl calls simulating CI/CD or Terraform provider |
Prerequisites:
- A valid API key (
API_KEY) with sufficient scope for the operations below - ⊞ Win-A is a registered machine in the org
ST1 — Read All Terraform Resources
What it verifies: The five GET-capable Terraform resources all return HTTP 200 with well-formed data payloads scoped to the API key’s org.
Steps:
- On 🐧 Linux-C , set the API key:
API_KEY="qztna_<your full key here>"
BASE="https://login.quickztna.com/api/terraform"
- List all five resources:
echo "=== machines ==="
curl -s "$BASE/machines" -H "X-Api-Key: $API_KEY" | python3 -m json.tool
echo "=== acl-rules ==="
curl -s "$BASE/acl-rules" -H "X-Api-Key: $API_KEY" | python3 -m json.tool
echo "=== dns ==="
curl -s "$BASE/dns" -H "X-Api-Key: $API_KEY" | python3 -m json.tool
echo "=== users ==="
curl -s "$BASE/users" -H "X-Api-Key: $API_KEY" | python3 -m json.tool
echo "=== settings ==="
curl -s "$BASE/settings" -H "X-Api-Key: $API_KEY" | python3 -m json.tool
Expected: All five return HTTP 200 with "success": true.
machines:data.datais an array. ⊞ Win-A should appear withstatus: "online"or"offline".acl-rules:data.datais an array ordered bypriority.dns:data.datais an array (may be empty if no DNS configs exist).users:data.datais an array with at least one org member.settings:datais a single object withorg_id,allow_exit_nodes,allow_subnet_routing,key_expiry_days.
Pass: All five return HTTP 200 with success: true. Arrays are present (may be empty for dns/acl-rules). Settings object is non-null.
Fail / Common issues:
- One or more return HTTP 404
"Unknown resource"— check the exact path spelling;acl-rulesuses a hyphen, not underscore. settingsreturnsdata: null— the org may not have a row inorg_settingsyet.
ST2 — Create and Delete an ACL Rule via Terraform API
What it verifies: The POST /api/terraform/acl-rules endpoint validates all required fields and creates a rule. DELETE /api/terraform/acl-rules/:id removes it.
Steps:
- On 🐧 Linux-C , create an ACL rule:
CREATE_RESP=$(curl -s -X POST "$BASE/acl-rules" \
-H "X-Api-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"terraform-test-rule\",
\"source\": \"100.0.0.0/8\",
\"destination\": \"100.64.0.1/32\",
\"ports\": \"443\",
\"protocol\": \"tcp\",
\"action\": \"allow\",
\"priority\": 50
}")
echo "$CREATE_RESP" | python3 -m json.tool
ACL_ID=$(echo "$CREATE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['id'])")
echo "Created ACL rule ID: $ACL_ID"
Expected (HTTP 201):
{
"success": true,
"data": {
"created": true,
"id": "<uuid>"
}
}
- Verify the rule appears in the list:
curl -s "$BASE/acl-rules" -H "X-Api-Key: $API_KEY" | python3 -m json.tool
Find the row with "name": "terraform-test-rule" and "priority": 50.
- Delete the rule:
curl -s -X DELETE "$BASE/acl-rules/$ACL_ID" \
-H "X-Api-Key: $API_KEY" | python3 -m json.tool
Expected (HTTP 200):
{
"success": true,
"data": {
"deleted": true
}
}
- Confirm it no longer appears in the list:
curl -s "$BASE/acl-rules" -H "X-Api-Key: $API_KEY" | python3 -m json.tool
Pass: POST returns HTTP 201 with an id. Rule appears in GET list. DELETE returns HTTP 200. Rule no longer in list after deletion.
Fail / Common issues:
INVALID_INPUTon POST — verifyactionis exactly"allow"or"deny"andprotocolis"tcp","udp", or"*".- DELETE returns HTTP 405
"Method not allowed"— DELETE requires the resource ID in the path (/api/terraform/acl-rules/<id>); a DELETE to/api/terraform/acl-rules(no ID) falls through to the default 405.
ST3 — ACL Rule Validation Rejects Invalid Inputs
What it verifies: The four server-side validations in terraform-api.ts for name, source, destination, action, and protocol each return INVALID_INPUT (400).
Steps:
- On 🐧 Linux-C , test each invalid case:
# Missing name
curl -s -X POST "$BASE/acl-rules" \
-H "X-Api-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"source\": \"10.0.0.0/8\", \"destination\": \"10.0.0.1/32\", \"action\": \"allow\"}" \
| python3 -m json.tool
Expected: HTTP 400, "code": "INVALID_INPUT", "message": "name is required and must be a string with max 100 characters".
# Invalid action
curl -s -X POST "$BASE/acl-rules" \
-H "X-Api-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"name\": \"bad-action\", \"source\": \"10.0.0.0/8\", \"destination\": \"10.0.0.1\", \"action\": \"permit\"}" \
| python3 -m json.tool
Expected: HTTP 400, "message": "action must be \"allow\" or \"deny\"".
# Invalid protocol
curl -s -X POST "$BASE/acl-rules" \
-H "X-Api-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"name\": \"bad-proto\", \"source\": \"10.0.0.0/8\", \"destination\": \"10.0.0.1\", \"action\": \"allow\", \"protocol\": \"icmp\"}" \
| python3 -m json.tool
Expected: HTTP 400, "message": "protocol must be \"tcp\", \"udp\", or \"*\"".
# Empty source
curl -s -X POST "$BASE/acl-rules" \
-H "X-Api-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"name\": \"empty-src\", \"source\": \"\", \"destination\": \"10.0.0.1\", \"action\": \"allow\"}" \
| python3 -m json.tool
Expected: HTTP 400, "message": "source is required and must be a non-empty string".
Pass: All four invalid cases return HTTP 400 with INVALID_INPUT and specific error messages matching the source code.
Fail / Common issues:
- Any invalid input returns HTTP 200 with
created: true— the validation block atterraform-api.tslines 59–73 is not running. Verify the request body is valid JSON and the handler version is current.
ST4 — Update and Read Org Settings
What it verifies: The PUT /api/terraform/settings endpoint updates the three allowed org settings fields. Unknown fields are silently ignored. Changes are immediately visible via GET /api/terraform/settings.
Steps:
- On ⊞ Win-A , read current settings:
API_KEY="qztna_<your full key here>"
BASE="https://login.quickztna.com/api/terraform"
curl -s "$BASE/settings" -H "X-Api-Key: $API_KEY" | python3 -m json.tool
Note the current values of allow_exit_nodes, allow_subnet_routing, and key_expiry_days.
- Update all three allowed fields:
curl -s -X PUT "$BASE/settings" \
-H "X-Api-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"allow_exit_nodes\": true,
\"allow_subnet_routing\": true,
\"key_expiry_days\": 45,
\"unknown_field\": \"should be ignored\"
}" | python3 -m json.tool
Expected (HTTP 200):
{
"success": true,
"data": {
"updated": true
}
}
- Re-read settings to confirm the changes:
curl -s "$BASE/settings" -H "X-Api-Key: $API_KEY" | python3 -m json.tool
Expected: allow_exit_nodes: true, allow_subnet_routing: true, key_expiry_days: 45. The unknown_field does not appear — it was silently ignored by the handler’s field allowlist check at terraform-api.ts line 105.
- Restore the original values to avoid impacting other tests:
curl -s -X PUT "$BASE/settings" \
-H "X-Api-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"allow_exit_nodes\": false,
\"allow_subnet_routing\": false,
\"key_expiry_days\": 90
}" | python3 -m json.tool
Pass: PUT returns HTTP 200 updated: true. GET shows updated values. Unknown field is absent from the settings row.
Fail / Common issues:
settingsreturns null after PUT — the org has no row inorg_settings. The UPDATE runs but affects 0 rows. In this case, a row should be pre-created via the org settings UI.- PUT returns 405 — ensure the method is
PUTnotPOST; only GET and PUT are handled for thesettingsresource.
ST5 — Machine Deletion via Terraform API
What it verifies: DELETE /api/terraform/machines/:id removes a machine from the database. This simulates Terraform terraform destroy for a machine resource.
Steps:
-
On ⊞ Win-A , list machines via the Terraform API and identify a test machine to delete. Do not delete ⊞ Win-A or 🐧 Linux-C — create a dedicated test machine or use an offline/stale machine.
-
On 🐧 Linux-C , list machines:
curl -s "$BASE/machines" -H "X-Api-Key: $API_KEY" | python3 -m json.tool
- Identify a machine you are prepared to delete. Save its
idasDEL_MACHINE_ID.
DEL_MACHINE_ID="<target machine uuid>"
- Confirm the machine exists:
curl -s "$BASE/machines/$DEL_MACHINE_ID" -H "X-Api-Key: $API_KEY" | python3 -m json.tool
Expected: HTTP 200 with the machine object.
- Delete it:
curl -s -X DELETE "$BASE/machines/$DEL_MACHINE_ID" \
-H "X-Api-Key: $API_KEY" | python3 -m json.tool
Expected (HTTP 200):
{
"success": true,
"data": {
"deleted": true
}
}
- Confirm it is gone:
curl -s "$BASE/machines/$DEL_MACHINE_ID" -H "X-Api-Key: $API_KEY" | python3 -m json.tool
Expected: HTTP 200 with "data": null.
- Attempt to use GET for a DELETE operation (wrong method):
curl -s -X GET "$BASE/machines/$DEL_MACHINE_ID" \
-H "X-Api-Key: $API_KEY" | python3 -m json.tool
Expected: HTTP 200 with "data": null — GET on a missing resource returns null, not an error.
Pass: DELETE returns HTTP 200 deleted: true. Subsequent GET returns null. Using GET instead of DELETE on a missing machine returns null (not 200 with a machine object, and not 404).
Fail / Common issues:
- DELETE returns HTTP 405 — make sure the ID is appended to the URL path.
DELETE /api/terraform/machines(no ID) falls through to the 405 return interraform-api.tsline 122. - Machine reappears in the list — the
ztna upclient for that machine may have re-registered. API key deletion does not block re-registration if the machine still has a valid auth key.
Summary
| Sub-test | What it proves | Pass condition |
|---|---|---|
| ST1 | All 5 Terraform resources readable | HTTP 200 for machines, acl-rules, dns, users, settings |
| ST2 | ACL rule create and delete | HTTP 201 on POST; appears in GET list; HTTP 200 on DELETE; absent after |
| ST3 | ACL rule input validation | HTTP 400 INVALID_INPUT for missing name, bad action, bad protocol, empty source |
| ST4 | Settings read and update | PUT returns updated: true; GET reflects changes; unknown fields ignored |
| ST5 | Machine deletion via Terraform API | HTTP 200 deleted: true; subsequent GET returns null |