What We’re Testing
Session recording is managed by handleSessionRecording in backend/src/handlers/session-recording.ts. It is a separate feature from the remote shell itself, gated by the session_recording feature flag (distinct from remote_management). The endpoint is:
POST https://login.quickztna.com/api/session-recording
The Go agent can also report recordings directly via a separate node-key-authenticated route:
POST https://login.quickztna.com/api/session-recording-agent
The recording lifecycle has these states: active → completed. Storage is two-tier:
- PostgreSQL (
session_recordingstable) — metadata:id,org_id,machine_id,user_id,session_type,status,started_at,ended_at,duration_seconds,metadata(JSON),r2_key - Cloudflare R2 (
quickztnabucket) — recording JSON atsession-recordings/ORG_ID/SESSION_ID/recording.json; chunk files atrecordings/ORG_ID/SESSION_ID/chunk-N.cast
The complete action writes a summary JSON to R2 at session-recordings/{org_id}/{session_id}/recording.json and stores the R2 key back in the database. The get action fetches both the DB row and the R2 data in a single response.
Org-level settings (get_settings / update_settings) control whether recording is enabled, the retention period (1-365 days), and the storage mode (platform or custom). Settings live in org_settings.recording_enabled, recording_retention_days, recording_storage_mode.
Your Test Setup
| Machine | Role |
|---|---|
| ⊞ Win-A | API caller, dashboard browser |
| 🐧 Linux-C | Machine whose sessions are being recorded |
Prerequisites:
- The org must have the
session_recordingfeature enabled (check Billing page). - The caller must be an org member for most actions;
list,delete,update_settings,cleanuprequire admin role. - A valid JWT is required for all actions.
ST1 — Enable Session Recording and Verify Settings
What it verifies: get_settings returns current recording configuration, and update_settings persists changes to org_settings.
Steps:
- On ⊞ Win-A , fetch current settings:
curl -s -X POST "https://login.quickztna.com/api/session-recording" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d '{
"action": "get_settings",
"org_id": "YOUR_ORG_ID"
}'
Expected response:
{
"success": true,
"data": {
"recording_enabled": false,
"retention_days": 90,
"storage_mode": "platform"
},
"error": null
}
- Enable recording and set a 30-day retention:
curl -s -X POST "https://login.quickztna.com/api/session-recording" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d '{
"action": "update_settings",
"org_id": "YOUR_ORG_ID",
"recording_enabled": true,
"retention_days": 30,
"storage_mode": "platform"
}'
Expected: HTTP 200, data.updated: true.
- Fetch settings again to confirm persistence:
curl -s -X POST "https://login.quickztna.com/api/session-recording" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d '{"action": "get_settings", "org_id": "YOUR_ORG_ID"}'
Expected: recording_enabled: true, retention_days: 30, storage_mode: "platform".
- Verify retention clamping: attempt to set
retention_days: 0andretention_days: 400:
# retention_days: 0 should be clamped to 1
# retention_days: 400 should be clamped to 365
Expected: Values are clamped by Math.max(1, Math.min(365, retention_days)) in the handler.
Pass: Settings persist correctly. Retention is clamped to range 1-365.
Fail / Common issues:
- HTTP 403
FORBIDDENonupdate_settings— only org admins can update settings. Theget_settingsaction does not require admin role. recording_enabledremains false after update — ensure theorg_settingsrow exists for the org. It is created during org setup migrations.
ST2 — Start and Complete a Recording
What it verifies: The start action creates an active session recording row; complete transitions it to completed and writes a summary to R2.
Steps:
- Start a recording for a shell session on Linux-C:
curl -s -X POST "https://login.quickztna.com/api/session-recording" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d '{
"action": "start",
"org_id": "YOUR_ORG_ID",
"machine_id": "LINUX_C_MACHINE_ID",
"session_type": "ssh"
}'
Expected:
{
"success": true,
"data": {
"session_id": "UUID",
"status": "active"
},
"error": null
}
Note the session_id. HTTP status is 201.
- Complete the recording after simulating a session:
curl -s -X POST "https://login.quickztna.com/api/session-recording" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d '{
"action": "complete",
"org_id": "YOUR_ORG_ID",
"session_id": "SESSION_UUID",
"duration_seconds": 120,
"recording_url": "https://example.com/recording.cast",
"transcript_lines": 42
}'
Expected: HTTP 200, data.completed equals the session UUID.
- Fetch the recording detail to confirm R2 storage:
curl -s -X POST "https://login.quickztna.com/api/session-recording" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d '{
"action": "get",
"org_id": "YOUR_ORG_ID",
"session_id": "SESSION_UUID"
}'
Expected:
{
"success": true,
"data": {
"recording": {
"id": "SESSION_UUID",
"status": "completed",
"duration_seconds": 120,
"r2_key": "session-recordings/YOUR_ORG_ID/SESSION_UUID/recording.json",
"machine_name": "Linux-C"
},
"r2_data": {
"session_id": "SESSION_UUID",
"duration_seconds": 120,
"recording_url": "https://example.com/recording.cast",
"transcript_lines": 42,
"completed_at": "2026-03-17T..."
}
},
"error": null
}
Pass: Recording row shows status: "completed", r2_key is set, and r2_data is populated from R2.
Fail / Common issues:
r2_data: nulleven thoughr2_keyis set — R2 write is best-effort (wrapped in try/catch). If R2 credentials are misconfigured, the DB row is still updated but R2 data is absent. CheckS3_ENDPOINT,S3_ACCESS_KEY,S3_SECRET_KEYenvironment variables on the production server.- HTTP 403
FORBIDDENoncomplete— only the session owner or an org admin can complete a recording. Ensure the calling user’suser_idmatchessession_recordings.user_id.
ST3 — Upload Recording Chunk
What it verifies: The upload_chunk action stores individual .cast file chunks in R2 at recordings/ORG_ID/SESSION_ID/chunk-N.cast and updates the session’s metadata.
Steps:
- Start a recording (as in ST2, Step 1). Note the
session_id. - Upload chunk 0:
curl -s -X POST "https://login.quickztna.com/api/session-recording" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d '{
"action": "upload_chunk",
"org_id": "YOUR_ORG_ID",
"session_id": "SESSION_UUID",
"chunk_index": 0,
"chunk_data": "[0, \"o\", \"$ \"]"
}'
Expected:
{
"success": true,
"data": {
"stored": "recordings/YOUR_ORG_ID/SESSION_UUID/chunk-0.cast",
"chunk_index": 0
},
"error": null
}
- Upload chunk 1:
curl -s -X POST "https://login.quickztna.com/api/session-recording" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d '{
"action": "upload_chunk",
"org_id": "YOUR_ORG_ID",
"session_id": "SESSION_UUID",
"chunk_index": 1,
"chunk_data": "[1.2, \"o\", \"hello\\n\"]"
}'
Expected: stored is recordings/YOUR_ORG_ID/SESSION_UUID/chunk-1.cast.
- Confirm chunk path naming: the R2 key pattern is
recordings/{org_id}/{session_id}/chunk-{chunk_index}.cast.
Pass: Each chunk returns the correct R2 key. chunk_index defaults to 0 if not provided.
Fail / Common issues:
chunk_datamissing — the handler requires bothsession_idandchunk_data. Sendingchunk_data: nullreturns HTTP 400MISSING_FIELDS.
ST4 — List Recordings and Verify Statistics
What it verifies: The list action returns paginated recordings with a stats breakdown by status, and correctly joins machine names.
Steps:
- Ensure at least two recordings exist (one
active, onecompleted) from ST2 and ST3. - On ⊞ Win-A , list all recordings for the org:
curl -s -X POST "https://login.quickztna.com/api/session-recording" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d '{
"action": "list",
"org_id": "YOUR_ORG_ID",
"limit": 20,
"offset": 0
}'
Expected:
{
"success": true,
"data": {
"recordings": [
{
"id": "...",
"status": "completed",
"duration_seconds": 120,
"machine_name": "Linux-C",
"session_type": "ssh",
"started_at": "..."
}
],
"stats": [
{ "status": "completed", "count": 1, "total_duration": 120 },
{ "status": "active", "count": 1, "total_duration": 0 }
]
},
"error": null
}
- Filter by machine:
curl -s -X POST "https://login.quickztna.com/api/session-recording" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d '{
"action": "list",
"org_id": "YOUR_ORG_ID",
"machine_id": "LINUX_C_MACHINE_ID"
}'
Expected: Only recordings for Linux-C are returned.
- Filter by status:
-d '{"action": "list", "org_id": "YOUR_ORG_ID", "status": "completed"}'
Expected: Only completed recordings.
- Verify the
machine_namefield is populated via the LEFT JOIN to themachinestable (it may benullif the machine was deleted — this is expected because recordings useSET NULLon machine deletion).
Pass: Recordings listed in descending started_at order. Stats show correct counts and total durations. Machine name is present for existing machines.
Fail / Common issues:
- HTTP 403 on
list— only admins can list recordings. Regular members cannot call thelistaction. machine_name: nullfor a recording — the machine has been deleted. This is correct behavior; recordings are preserved with a nullmachine_idreference.
ST5 — Agent-Reported Recording via Node Key
What it verifies: The agent-side recording endpoint (/api/session-recording-agent) accepts session data with node_key authentication and creates a completed recording record directly.
Steps:
- Obtain the node key for 🐧 Linux-C . On Linux-C:
ztna status --json | jq .node_key
Note: the node key is a long hex string. The backend hashes it with SHA-256 and looks up machines.node_key_hash.
- Submit a recording report from 🐧 Linux-C (or simulate from Win-A with the node key):
curl -s -X POST "https://login.quickztna.com/api/session-recording-agent" \
-H "Content-Type: application/json" \
-d '{
"node_key": "NODE_KEY_HEX",
"session_type": "ssh",
"session_data": {
"transcript": "[0, \"o\", \"$ uname\\n\"]\n[0.5, \"o\", \"Linux linux-c\\n\"]",
"duration_seconds": 30
}
}'
Expected:
{
"success": true,
"data": {
"session_id": "UUID",
"status": "completed"
},
"error": null
}
HTTP status is 201.
- Verify the recording appears in the
listaction withstatus: "completed"andduration_seconds: 30. - Confirm the recording’s
metadatacontainsreported_by_agent: trueandmachine_name: "Linux-C".
Pass: Agent-reported recording is created with status: "completed" in one step, no start/complete cycle required. The user_id is set to machine.owner_id.
Fail / Common issues:
- HTTP 401
UNAUTHORIZED— the node key hash does not match any machine. Use the exact node key string as returned byztna status --json, not the public key. - Duration is 0 — the asciicast transcript does not have a valid last line with a timestamp. The handler tries to parse
lastLine[0]as a float; a malformed transcript skips duration extraction.
Summary
| Sub-test | What it proves |
|---|---|
| ST1 | get_settings returns defaults; update_settings persists changes; retention is clamped to 1-365 days |
| ST2 | start creates an active recording; complete transitions to completed and writes summary to R2 at session-recordings/{org}/{session}/recording.json |
| ST3 | upload_chunk stores .cast chunks in R2 at recordings/{org}/{session}/chunk-N.cast and updates session metadata |
| ST4 | list returns recordings with machine names (LEFT JOIN), filtered by machine or status; stats provide per-status counts and total duration |
| ST5 | /api/session-recording-agent accepts node-key auth and creates a completed recording in one step; reported_by_agent: true is set in metadata |