#!/usr/bin/env bash # # End-to-end smoke test of the Agent v1 protocol against a running control plane. # `curl` plays the device, so this verifies the server half of the loop without an # emulator: enroll -> mint token -> queue command -> authenticated capability-gated # check-in -> ack. # # Prerequisite: a database seeded like a real install (a Liquibase-only one is NOT enough). On the dev stack run # scripts/dev-seed.sh; it seeds through install/lib/db.sh, as the installers do, and sets the admin password to # "admin" (or DEV_ADMIN_PASSWORD; pass the same value as ADMIN_PW). (See docs/DEV.md "End-to-end agent loop".) # # Usage: [ADMIN_PW=] scripts/agent-v1-e2e.sh [BASE_URL] (defaults: admin, http://localhost:8080) set -euo pipefail BASE="${1:-${BASE_URL:-http://localhost:8080}}" CJ="$(mktemp)"; OJ="" # Fixtures a later section creates register themselves here, so an abort (set -e) never leaves them behind. A fixture # is unregistered only once its teardown check passed; if that check failed, cleanup() tries the teardown again. LIVE_RID=""; LIVE_RVER=""; LIVE_OID=""; LIVE_OLOGIN="" # Id of the user with exactly this login, or nothing. Prints nothing (never a traceback) on a bad response because # cleanup() uses it too; the main flow checks for an empty result itself. uid_of(){ curl -s -b "$CJ" "$BASE/rest/private/users/all?filter=$1" | python3 -c "import sys,json try: print(next((u['id'] for u in json.load(sys.stdin)['data'] if u['login']==sys.argv[1]), '')) except Exception: pass" "$1"; } # Id of the active rollout if its targetVersion is exactly this one (i.e. this run created it), or nothing; never # fails, like uid_of. A rollout another client created is never matched, so cleanup() never cancels it. rid_of(){ curl -s -b "$CJ" "$BASE/rest/private/agent/v1/rollout/active" | python3 -c "import sys,json try: d=json.load(sys.stdin)['data'] or {}; print(d['id'] if d.get('targetVersion')==sys.argv[1] else '') except Exception: pass" "$1"; } cleanup(){ # A rollout exists before its id is known: if the create's response was lost, find it again by its targetVersion. [ -n "$LIVE_RID" ] || [ -z "$LIVE_RVER" ] || LIVE_RID=$(rid_of "$LIVE_RVER" || true) [ -z "$LIVE_RID" ] || curl -s -b "$CJ" -X POST "$BASE/rest/private/agent/v1/rollout/$LIVE_RID/cancel" >/dev/null || true # The Observer exists before its id is known: if the id lookup itself aborted, find the user again by its login. [ -n "$LIVE_OID" ] || [ -z "$LIVE_OLOGIN" ] || LIVE_OID=$(uid_of "$LIVE_OLOGIN" || true) [ -z "$LIVE_OID" ] || curl -s -b "$CJ" -X DELETE "$BASE/rest/private/users/other/$LIVE_OID" >/dev/null || true rm -f "$CJ" ${OJ:+"$OJ"} } trap cleanup EXIT PASS=0; FAIL=0 chk(){ if [ "$2" = "$3" ]; then echo " PASS: $1"; PASS=$((PASS+1)); else echo " FAIL: $1 (got '$2' want '$3')"; FAIL=$((FAIL+1)); fi; } # Extract a field from a JSON response on stdin. First arg is python code operating on `d`. # (Not eval — fixed expressions passed by this script only.) field(){ python3 -c "import sys,json; d=json.load(sys.stdin); print($1)"; } MD5=$(printf '%s' "${ADMIN_PW:-admin}" | md5sum | awk '{print toupper($1)}') echo "== login ==" chk "login OK" "$(curl -s -c "$CJ" -H 'Content-Type: application/json' \ -d "{\"login\":\"admin\",\"password\":\"$MD5\"}" "$BASE/rest/public/auth/login" | field "d['status']")" "OK" echo "== mint enrollment token ==" TOK=$(curl -s -b "$CJ" -X POST "$BASE/rest/private/agent/v1/token" | field "d['data']['token']") [ -n "$TOK" ] || { echo " FAIL: no token"; exit 1; } echo "== enroll ==" ENR=$(curl -s -X POST -H 'Content-Type: application/json' -d "{\"enrollToken\":\"$TOK\",\"agent\":{\"version\":\"0.1.0\",\"package\":\"com.mdmesh.agent\"},\"device\":{\"androidSdkInt\":34,\"isDeviceOwner\":true},\"capabilities\":{\"policy\":[\"wifi\"],\"appManagement\":[],\"remoteControl\":{\"tier\":\"none\"},\"oem\":{\"vendor\":\"samsung\",\"knox\":false}}}" "$BASE/rest/public/agent/v1/enroll") chk "enroll OK" "$(echo "$ENR" | field "d['status']")" "OK" DID=$(echo "$ENR" | field "d['data']['deviceId']"); SEC=$(echo "$ENR" | field "d['data']['deviceSecret']") echo "== queue wifi command (requires policy.wifi) ==" QRES=$(curl -s -b "$CJ" -X POST -H 'Content-Type: application/json' \ -d '{"type":"policy.apply","requiresCapability":"policy.wifi","payload":"{\"policy\":\"wifi\",\"value\":false}"}' \ "$BASE/rest/private/agent/v1/devices/$DID/commands") # The admin API answers with the payload-free command view: an id, but never the payload, device or gate it was given. chk "queue response has an id, no payload/deviceNumber/requiresCapability" \ "$(echo "$QRES" | field "str(bool((d.get('data') or {}).get('id')))+':'+','.join(k for k in ('payload','deviceNumber','requiresCapability') if k in (d.get('data') or {}))")" \ "True:" echo "== authenticated check-in delivers the command ==" C1=$(curl -s -X POST -H "Authorization: Bearer $SEC" -H 'Content-Type: application/json' \ -d "{\"deviceId\":\"$DID\",\"capabilities\":{\"policy\":[\"wifi\"]}}" "$BASE/rest/public/agent/v1/checkin") chk "wifi command delivered" \ "$(echo "$C1" | field "(lambda cs: f\"{len(cs)}:{cs[0]['type']}:{cs[0]['requiresCapability']}\" if cs else '')(d['data']['commands'])")" \ "1:policy.apply:policy.wifi" DCMD=$(echo "$C1" | field "d['data']['commands'][0]['commandId']") echo "== capability gate ==" curl -s -b "$CJ" -X POST -H 'Content-Type: application/json' \ -d '{"type":"policy.apply","requiresCapability":"policy.camera","payload":"{\"policy\":\"camera\",\"value\":true}"}' \ "$BASE/rest/private/agent/v1/devices/$DID/commands" >/dev/null chk "camera withheld when not advertised" \ "$(curl -s -X POST -H "Authorization: Bearer $SEC" -H 'Content-Type: application/json' -d "{\"deviceId\":\"$DID\",\"capabilities\":{\"policy\":[\"wifi\"]}}" "$BASE/rest/public/agent/v1/checkin" | field "'policy.camera' in [c.get('requiresCapability') for c in d['data']['commands']]")" \ "False" chk "camera delivered once advertised" \ "$(curl -s -X POST -H "Authorization: Bearer $SEC" -H 'Content-Type: application/json' -d "{\"deviceId\":\"$DID\",\"capabilities\":{\"policy\":[\"wifi\",\"camera\"]}}" "$BASE/rest/public/agent/v1/checkin" | field "'policy.camera' in [c.get('requiresCapability') for c in d['data']['commands']]")" \ "True" echo "== auth rejection ==" chk "bad bearer rejected" \ "$(curl -s -X POST -H 'Authorization: Bearer WRONG' -H 'Content-Type: application/json' -d "{\"deviceId\":\"$DID\",\"capabilities\":{\"policy\":[\"wifi\"]}}" "$BASE/rest/public/agent/v1/checkin" | field "d['status']+':'+str(d.get('message'))")" \ "ERROR:error.agent.unauthorized" chk "missing bearer rejected" \ "$(curl -s -X POST -H 'Content-Type: application/json' -d "{\"deviceId\":\"$DID\",\"capabilities\":{\"policy\":[\"wifi\"]}}" "$BASE/rest/public/agent/v1/checkin" | field "d['status']+':'+str(d.get('message'))")" \ "ERROR:error.agent.unauthorized" echo "== ack command ==" chk "check-in accepts result ack" \ "$(curl -s -X POST -H "Authorization: Bearer $SEC" -H 'Content-Type: application/json' -d "{\"deviceId\":\"$DID\",\"capabilities\":{\"policy\":[\"wifi\"]},\"results\":[{\"commandId\":\"$DCMD\",\"status\":\"done\",\"completedAt\":\"2026-01-01T00:00:00Z\"}]}" "$BASE/rest/public/agent/v1/checkin" | field "d['status']")" \ "OK" echo "== check-in with state snapshot ==" chk "state checkin OK" \ "$(curl -s -X POST -H "Authorization: Bearer $SEC" -H 'Content-Type: application/json' \ -d "{\"deviceId\":\"$DID\",\"capabilities\":{\"policy\":[\"wifi\"]},\"state\":{\"battery\":77,\"charging\":true,\"locked\":false,\"kioskActive\":false,\"androidRelease\":\"14\",\"lastBootAt\":1000}}" \ "$BASE/rest/public/agent/v1/checkin" | field "d['status']")" "OK" echo "== check-in with telemetry ==" chk "telemetry checkin OK" \ "$(curl -s -X POST -H "Authorization: Bearer $SEC" -H 'Content-Type: application/json' \ -d "{\"deviceId\":\"$DID\",\"capabilities\":{\"policy\":[\"wifi\"]},\"state\":{\"battery\":77,\"charging\":true,\"locked\":false,\"kioskActive\":false,\"androidRelease\":\"14\",\"lastBootAt\":1000},\"telemetry\":{\"dynamic\":{\"batteryPct\":77},\"hardware\":{\"model\":\"Pixel\"}}}" \ "$BASE/rest/public/agent/v1/checkin" | field "d['status']")" "OK" echo "== read telemetry ==" chk "telemetry hardware.model" \ "$(curl -s -b "$CJ" "$BASE/rest/private/agent/v1/devices/$DID/telemetry" | field "d['data']['hardware']['model']")" "Pixel" echo "== check-in with events ==" chk "events checkin OK" \ "$(curl -s -X POST -H "Authorization: Bearer $SEC" -H 'Content-Type: application/json' \ -d "{\"deviceId\":\"$DID\",\"capabilities\":{\"policy\":[\"wifi\"]},\"state\":{\"battery\":77,\"charging\":true,\"locked\":false,\"kioskActive\":false,\"androidRelease\":\"14\",\"lastBootAt\":1000},\"events\":[{\"type\":\"boot\",\"ts\":1},{\"type\":\"appInstalled\",\"ts\":2,\"detail\":\"com.x\"}]}" \ "$BASE/rest/public/agent/v1/checkin" | field "d['status']")" "OK" echo "== read events ==" chk "events has boot" \ "$(curl -s -b "$CJ" "$BASE/rest/private/agent/v1/devices/$DID/events?since=0" | field "'boot' in [e['type'] for e in d['data']]")" "True" echo "== read device state ==" chk "state battery=77" \ "$(curl -s -b "$CJ" "$BASE/rest/private/agent/v1/devices/$DID/state" | field "d['data']['battery']")" "77" chk "state androidRelease=14" \ "$(curl -s -b "$CJ" "$BASE/rest/private/agent/v1/devices/$DID/state" | field "d['data']['androidRelease']")" "14" echo "== desired-state: config.apply on drift ==" CFG_ID=$(curl -s -b "$CJ" "$BASE/rest/private/agent/v1/devices/$DID/configStatus" | field "d['data']['configurationId']") CUR_REV=$(curl -s -b "$CJ" "$BASE/rest/private/agent/v1/devices/$DID/configStatus" | field "d['data']['currentRevision']") chk "current revision is 64 hex" "$(printf '%s' "$CUR_REV" | grep -cE '^[0-9a-f]{64}$')" "1" # capable agent, stale revision -> command delivered in the same response C_DS=$(curl -s -X POST -H "Authorization: Bearer $SEC" -H 'Content-Type: application/json' \ -d "{\"deviceId\":\"$DID\",\"capabilities\":{\"policy\":[\"wifi\"],\"device\":[\"configApply\"]},\"state\":{\"battery\":77,\"charging\":true,\"locked\":false,\"kioskActive\":false,\"androidRelease\":\"14\",\"lastBootAt\":1000,\"appliedConfigRevision\":\"0000\"}}" \ "$BASE/rest/public/agent/v1/checkin") chk "config.apply delivered on drift" "$(echo "$C_DS" | field "[c['type'] for c in d['data']['commands']].count('config.apply')")" "1" chk "config.apply carries current revision" "$(echo "$C_DS" | field "[c['payload']['revision'] for c in d['data']['commands'] if c['type']=='config.apply'][0]")" "$CUR_REV" DS_CMD=$(echo "$C_DS" | field "[c['commandId'] for c in d['data']['commands'] if c['type']=='config.apply'][0]") # The agent checks in again without a result (it restarted mid-apply, or never got the response): the delivered # config.apply is ended and the same revision is sent again at once, once. A second one in a row gets the backoff. CI_NORESULT="{\"deviceId\":\"$DID\",\"capabilities\":{\"policy\":[\"wifi\"],\"device\":[\"configApply\"]}}" ABANDONED="no result: the agent checked in again without finishing it" hist_row(){ curl -s -b "$CJ" "$BASE/rest/private/agent/v1/devices/$DID/commands?since=0" | field "[str(c['status'])+':'+str(c.get('detail')) for c in d['data'] if str(c['id'])=='$1'][0]"; } C_RE=$(curl -s -X POST -H "Authorization: Bearer $SEC" -H 'Content-Type: application/json' -d "$CI_NORESULT" "$BASE/rest/public/agent/v1/checkin") chk "re-sent at once after a check-in without its result" "$(echo "$C_RE" | field "[c['payload']['revision'] for c in d['data']['commands'] if c['type']=='config.apply']")" "['$CUR_REV']" DS_RE=$(echo "$C_RE" | field "[c['commandId'] for c in d['data']['commands'] if c['type']=='config.apply'][0]") chk "the unfinished config.apply is ended with the reason" "$(hist_row "$DS_CMD")" "expired:$ABANDONED" chk "abandoned again: backoff, no re-send" "$(curl -s -X POST -H "Authorization: Bearer $SEC" -H 'Content-Type: application/json' -d "$CI_NORESULT" "$BASE/rest/public/agent/v1/checkin" | field "[c['type'] for c in d['data']['commands']].count('config.apply')")" "0" chk "the re-send is ended too" "$(hist_row "$DS_RE")" "expired:$ABANDONED" # The first command's result turns up after all (ack done, revision applied) -> it replaces the server's guess; in sync curl -s -X POST -H "Authorization: Bearer $SEC" -H 'Content-Type: application/json' \ -d "{\"deviceId\":\"$DID\",\"capabilities\":{\"policy\":[\"wifi\"],\"device\":[\"configApply\"]},\"results\":[{\"commandId\":\"$DS_CMD\",\"status\":\"done\",\"detail\":\"{\\\"revision\\\":\\\"$CUR_REV\\\",\\\"outcomes\\\":{\\\"policies.wifi\\\":\\\"applied\\\"}}\",\"completedAt\":\"2026-01-01T00:00:00Z\"}],\"state\":{\"battery\":77,\"charging\":true,\"locked\":false,\"kioskActive\":false,\"androidRelease\":\"14\",\"lastBootAt\":1000,\"appliedConfigRevision\":\"$CUR_REV\"}}" \ "$BASE/rest/public/agent/v1/checkin" >/dev/null chk "a late result overwrites the abandoned row" "$(hist_row "$DS_CMD" | cut -d: -f1)" "done" chk "configStatus inSync after ack" "$(curl -s -b "$CJ" "$BASE/rest/private/agent/v1/devices/$DID/configStatus" | field "d['data']['inSync']")" "True" chk "syncSummary counts this device in sync" "$(curl -s -b "$CJ" "$BASE/rest/private/agent/v1/configurations/syncSummary" | field "[s['inSync'] for s in d['data'] if s['configurationId']==$CFG_ID][0] >= 1")" "True" # old agent (no configApply key) never gets the command chk "old agent not sent config.apply" "$(curl -s -X POST -H "Authorization: Bearer $SEC" -H 'Content-Type: application/json' -d "{\"deviceId\":\"$DID\",\"capabilities\":{\"policy\":[\"wifi\"]},\"state\":{\"battery\":1,\"charging\":false,\"locked\":false,\"kioskActive\":false,\"androidRelease\":\"14\",\"lastBootAt\":1,\"appliedConfigRevision\":\"stale\"}}" "$BASE/rest/public/agent/v1/checkin" | field "[c['type'] for c in d['data']['commands']].count('config.apply')")" "0" echo "== desired-state: kiosk (mainAppId is an application VERSION id) ==" # Runs on a throwaway configuration so no real device on the shared configuration is ever # kiosked. The main app is picked from the device configuration's install apps; its # applications.id and applicationVersions.id differ, so matching by the wrong id fails here. KCFG_SRC=$(curl -s -b "$CJ" "$BASE/rest/private/configurations/$CFG_ID") KAPPS=$(curl -s -b "$CJ" "$BASE/rest/private/configurations/applications/$CFG_ID") KPICK=$(echo "$KAPPS" | field "' '.join(str(x) for x in next((a['id'],a['usedVersionId'],a['pkg']) for a in d['data'] if a.get('selected') and a.get('action')==1 and a.get('usedVersionId') and a.get('pkg') and a['id']!=a['usedVersionId']))") read -r KAPP_ID KVID KPKG <<<"$KPICK" chk "picked install app has a distinct version id" "$([ -n "$KVID" ] && [ "$KAPP_ID" != "$KVID" ] && echo yes)" "yes" KNAME="e2e-kiosk-$(date +%s)-$$" # Body = the source configuration minus identity, with ONE install app and kiosk off. kcfg_body(){ # $1 = id or "" ; $2 = kioskMode (true|false) KCFG_SRC="$KCFG_SRC" KID="$1" KMODE="$2" KNAME="$KNAME" KAPP_ID="$KAPP_ID" KVID="$KVID" python3 -c ' import json,os c=json.loads(os.environ["KCFG_SRC"])["data"] for k in ("id","qrCodeKey","selected"): c.pop(k,None) if os.environ["KID"]: c["id"]=int(os.environ["KID"]) c["name"]=os.environ["KNAME"]; c["description"]="agent-v1-e2e kiosk scenario (temporary)" c["applications"]=[{"id":int(os.environ["KAPP_ID"]),"usedVersionId":int(os.environ["KVID"]),"action":1,"showIcon":True,"remove":False}] c["kioskMode"]=os.environ["KMODE"]=="true"; c["mainAppId"]=int(os.environ["KVID"]) print(json.dumps(c))'; } KNEW=$(curl -s -b "$CJ" -X PUT -H 'Content-Type: application/json' -d "$(kcfg_body "" false)" "$BASE/rest/private/configurations") KCFG=$(echo "$KNEW" | field "d['data']['id']") chk "kiosk scenario configuration created" "$(echo "$KNEW" | field "d['status']")" "OK" KDEV=$(curl -s -b "$CJ" -X POST -H 'Content-Type: application/json' -d "{\"value\":\"$DID\",\"pageSize\":5,\"pageNum\":1}" "$BASE/rest/private/devices/search" | field "[x['id'] for x in d['data']['devices']['items'] if x['number']=='$DID'][0]") chk "device moved to kiosk scenario configuration" "$(curl -s -b "$CJ" -X PUT -H 'Content-Type: application/json' -d "{\"ids\":[$KDEV],\"configurationId\":$KCFG}" "$BASE/rest/private/devices" | field "d['status']")" "OK" chk "PUT configuration kioskMode=true + mainAppId=version id" \ "$(curl -s -b "$CJ" -X PUT -H 'Content-Type: application/json' -d "$(kcfg_body "$KCFG" true)" "$BASE/rest/private/configurations" | field "str(d['status'])+':'+str(d['data']['kioskMode'])+':'+str(d['data']['mainAppId'])")" "OK:True:$KVID" # Capable agent, stale revision (it last applied the shared configuration's revision). C_K=$(curl -s -X POST -H "Authorization: Bearer $SEC" -H 'Content-Type: application/json' \ -d "{\"deviceId\":\"$DID\",\"capabilities\":{\"policy\":[\"wifi\"],\"device\":[\"configApply\"]},\"state\":{\"battery\":77,\"charging\":true,\"locked\":false,\"kioskActive\":false,\"androidRelease\":\"14\",\"lastBootAt\":1000,\"appliedConfigRevision\":\"$CUR_REV\"}}" \ "$BASE/rest/public/agent/v1/checkin") KPAY="[c['payload'] for c in d['data']['commands'] if c['type']=='config.apply'][0]" chk "kiosk config.apply delivered" "$(echo "$C_K" | field "[c['type'] for c in d['data']['commands']].count('config.apply')")" "1" chk "kiosk.mode == single" "$(echo "$C_K" | field "$KPAY['kiosk']['mode']")" "single" chk "kiosk.pinPackage == main app pkg" "$(echo "$C_K" | field "$KPAY['kiosk']['pinPackage']")" "$KPKG" # Restore: kiosk off, device back on its configuration, drop the temporary configuration. chk "restore kioskMode=false" "$(curl -s -b "$CJ" -X PUT -H 'Content-Type: application/json' -d "$(kcfg_body "$KCFG" false)" "$BASE/rest/private/configurations" | field "str(d['status'])+':'+str(d['data']['kioskMode'])")" "OK:False" chk "device restored to its configuration" "$(curl -s -b "$CJ" -X PUT -H 'Content-Type: application/json' -d "{\"ids\":[$KDEV],\"configurationId\":$CFG_ID}" "$BASE/rest/private/devices" | field "d['status']")" "OK" chk "kiosk scenario configuration deleted" "$(curl -s -b "$CJ" -X DELETE "$BASE/rest/private/configurations/$KCFG" | field "d['status']")" "OK" echo "== configuration extensions: a save without them keeps them, with them replaces them, a copy copies them ==" # A temporary configuration with no apps, made from the shared one's columns. XNAME="e2e-ext-$(date +%s)-$$" xcfg_body(){ # $1 = id or "" ; $2 = the extensions JSON, or - to leave the key out (an older client) KCFG_SRC="$KCFG_SRC" XID="$1" XEXT="$2" XNAME="$XNAME" python3 -c ' import json,os c=json.loads(os.environ["KCFG_SRC"])["data"] for k in ("id","qrCodeKey","selected","extensions"): c.pop(k,None) if os.environ["XID"]: c["id"]=int(os.environ["XID"]) c["name"]=os.environ["XNAME"]; c["description"]="agent-v1-e2e extensions (temporary)" c["applications"]=[]; c["kioskMode"]=False; c["mainAppId"]=None; c["contentAppId"]=None if os.environ["XEXT"]!="-": c["extensions"]=json.loads(os.environ["XEXT"]) print(json.dumps(c))'; } xput(){ curl -s -b "$CJ" -X PUT -H 'Content-Type: application/json' -d "$(xcfg_body "$1" "$2")" "$BASE/rest/private/configurations"; } xext(){ curl -s -b "$CJ" "$BASE/rest/private/configurations/$1" | field "json.dumps(d['data']['extensions'],sort_keys=True)"; } XNEW=$(xput "" '{"e2e":{"n":1}}') XCFG=$(echo "$XNEW" | field "d['data']['id']") chk "created with extensions" "$(echo "$XNEW" | field "str(d['status'])+':'+json.dumps(d['data']['extensions'])")" 'OK:{"e2e": {"n": 1}}' chk "a save without extensions keeps them" "$(xput "$XCFG" - | field "d['status']"):$(xext "$XCFG")" 'OK:{"e2e": {"n": 1}}' chk "a save with extensions replaces them" "$(xput "$XCFG" '{"e2e.more":{"m":true}}' | field "d['status']"):$(xext "$XCFG")" 'OK:{"e2e.more": {"m": true}}' chk "a namespace that is not one is refused, and nothing changes" \ "$(xput "$XCFG" '{"Not One":{}}' | field "str(d['status'])+':'+d['message']"):$(xext "$XCFG")" 'ERROR:error.extensions.namespace:{"e2e.more": {"m": true}}' chk "copy OK" "$(curl -s -b "$CJ" -X PUT -H 'Content-Type: application/json' -d "{\"id\":$XCFG,\"name\":\"$XNAME-copy\"}" "$BASE/rest/private/configurations/copy" | field "d['status']")" "OK" XCOPY=$(curl -s -b "$CJ" "$BASE/rest/private/configurations/search" | field "[c['id'] for c in d['data'] if c['name']=='$XNAME-copy'][0]") chk "the copy has the extensions" "$(xext "$XCOPY")" '{"e2e.more": {"m": true}}' chk "extensions copy deleted" "$(curl -s -b "$CJ" -X DELETE "$BASE/rest/private/configurations/$XCOPY" | field "d['status']")" "OK" chk "extensions configuration deleted" "$(curl -s -b "$CJ" -X DELETE "$BASE/rest/private/configurations/$XCFG" | field "d['status']")" "OK" echo "== command history (payload-free, 6.6) ==" HIST=$(curl -s -b "$CJ" "$BASE/rest/private/agent/v1/devices/$DID/commands?since=0") chk "history has completedAt" "$(echo "$HIST" | field "any(c.get('completedAt') for c in d['data'])")" "True" chk "history rows carry no payload" "$(echo "$HIST" | field "sum(1 for c in d['data'] if 'payload' in c)")" "0" chk "history rows carry no deviceNumber" "$(echo "$HIST" | field "sum(1 for c in d['data'] if 'deviceNumber' in c)")" "0" chk "history keeps the config.apply result detail" "$(echo "$HIST" | field "[c.get('detail') or '' for c in d['data'] if str(c['id'])=='$DS_CMD'][0].startswith('{')")" "True" echo "== force sync ==" chk "force sync OK" \ "$(curl -s -b "$CJ" -X POST "$BASE/rest/private/agent/v1/devices/$DID/sync" | field "d['status']")" "OK" echo "== permissions: read-only Observer (role 100) cannot mutate ==" # A temporary Observer user of the same customer: agent/rollout mutations need edit_devices, # reads stay open to any user of the customer. The user is deleted at the end of this section. OJ="$(mktemp)" OLOGIN="e2e-obs-$(date +%s)-$RANDOM" # users.login is varchar(30) OPW=$(printf '%s' "$OLOGIN-pw" | md5sum | awk '{print toupper($1)}') LIVE_OLOGIN="$OLOGIN" # before the create call: from here on cleanup() can find the user even without its id chk "observer user created" "$(curl -s -b "$CJ" -X PUT -H 'Content-Type: application/json' \ -d "{\"login\":\"$OLOGIN\",\"name\":\"$OLOGIN\",\"email\":\"$OLOGIN@e2e.invalid\",\"userRole\":{\"id\":100},\"newPassword\":\"$OPW\",\"allDevicesAvailable\":true,\"allConfigAvailable\":true}" \ "$BASE/rest/private/users" | field "d['status']")" "OK" OID=$(uid_of "$OLOGIN" || true) # a curl error must reach the FAIL below, not abort (cleanup() still finds the user) [ -n "$OID" ] || { echo " FAIL: observer user id lookup"; exit 1; } LIVE_OID="$OID" chk "observer login OK" "$(curl -s -c "$OJ" -H 'Content-Type: application/json' \ -d "{\"login\":\"$OLOGIN\",\"password\":\"$OPW\"}" "$BASE/rest/public/auth/login" | field "d['status']")" "OK" DENIED="ERROR:error.permission.denied" ores(){ field "d['status']+':'+str(d.get('message'))"; } # "status:message" of a Response on stdin chk "observer: queue device.wipe denied" "$(curl -s -b "$OJ" -X POST -H 'Content-Type: application/json' \ -d '{"type":"device.wipe","payload":"{}"}' "$BASE/rest/private/agent/v1/devices/$DID/commands" | ores)" "$DENIED" chk "observer: bulk command denied" "$(curl -s -b "$OJ" -X POST -H 'Content-Type: application/json' \ -d "{\"deviceIds\":[$KDEV],\"command\":{\"type\":\"policy.apply\",\"payload\":\"{}\"}}" "$BASE/rest/private/agent/v1/bulk/commands" | ores)" "$DENIED" chk "observer: mint enrollment token denied" "$(curl -s -b "$OJ" -X POST -H 'Content-Type: application/json' \ -d '{}' "$BASE/rest/private/agent/v1/token" | ores)" "$DENIED" chk "observer: syncApps denied" "$(curl -s -b "$OJ" -X POST "$BASE/rest/private/agent/v1/devices/$DID/syncApps" | ores)" "$DENIED" chk "observer: force sync denied" "$(curl -s -b "$OJ" -X POST "$BASE/rest/private/agent/v1/devices/$DID/sync" | ores)" "$DENIED" # Registered before the create call: unless the create is clearly denied, cleanup() cancels a 9.9.9-e2e rollout. LIVE_RVER="9.9.9-e2e" ROUT=$(curl -s -b "$OJ" -X POST -H 'Content-Type: application/json' \ -d "{\"targetVersion\":\"9.9.9-e2e\",\"packageName\":\"com.mdmesh.agent\",\"apkVersionCode\":999999,\"apkSha256\":\"$(printf '0%.0s' $(seq 64))\",\"canaryDeviceNumbers\":[\"$DID\"]}" \ "$BASE/rest/private/agent/v1/rollout" || true) OCREATE=$(echo "$ROUT" | ores || true) chk "observer: rollout create denied" "$OCREATE" "$DENIED" if [ "$OCREATE" = "$DENIED" ]; then LIVE_RVER=""; fi # Should the create ever get through (regression), do not leave an active rollout behind. RID=$(echo "$ROUT" | field "(d.get('data') or {}).get('id') or ''" || true) if [ -n "$RID" ]; then LIVE_RID="$RID" RCANCELLED=$(curl -s -b "$CJ" -X POST "$BASE/rest/private/agent/v1/rollout/$RID/cancel" | field "d['status']" || true) chk "observer-created rollout cancelled" "$RCANCELLED" "OK" if [ "$RCANCELLED" = OK ]; then LIVE_RID=""; LIVE_RVER=""; fi fi # A REAL rollout (admin-created) — the Observer may read it but neither promote nor cancel it. The canary is the # e2e device, which advertises no app.silentInstall, so nothing is queued. Skipped rather than touching a live # rollout if the server already has one (one active rollout per customer). if [ "$(curl -s -b "$CJ" "$BASE/rest/private/agent/v1/rollout/active" | field "d['data'] is None")" = True ]; then LIVE_RVER="9.9.8-e2e" # before the create call: if its response is lost, cleanup() still finds the rollout AROUT=$(curl -s -b "$CJ" -X POST -H 'Content-Type: application/json' \ -d "{\"targetVersion\":\"9.9.8-e2e\",\"packageName\":\"com.mdmesh.agent\",\"apkVersionCode\":999998,\"apkSha256\":\"$(printf '0%.0s' $(seq 64))\",\"canaryDeviceNumbers\":[\"$DID\"]}" \ "$BASE/rest/private/agent/v1/rollout" || true) LIVE_RID=$(echo "$AROUT" | field "(d.get('data') or {}).get('id') or ''" || true) chk "admin: rollout created (canary)" "$(echo "$AROUT" | field "str(d['status'])+':'+str((d.get('data') or {}).get('stage'))")" "OK:canary" chk "observer: rollout promote denied" "$(curl -s -b "$OJ" -X POST "$BASE/rest/private/agent/v1/rollout/$LIVE_RID/promote" | ores)" "$DENIED" chk "observer: rollout cancel denied" "$(curl -s -b "$OJ" -X POST "$BASE/rest/private/agent/v1/rollout/$LIVE_RID/cancel" | ores)" "$DENIED" chk "observer: rollout still active, still canary" "$(curl -s -b "$OJ" "$BASE/rest/private/agent/v1/rollout/active" | field "str((d.get('data') or {}).get('id'))+':'+str((d.get('data') or {}).get('stage'))")" "$LIVE_RID:canary" # `|| true`: a transport error or a non-JSON body must end in the FAIL below (and a retry on exit), not an abort. CANCELLED=$(curl -s -b "$CJ" -X POST "$BASE/rest/private/agent/v1/rollout/$LIVE_RID/cancel" | field "d['status']" || true) chk "admin: rollout cancel OK" "$CANCELLED" "OK" if [ "$CANCELLED" = OK ]; then LIVE_RID=""; LIVE_RVER=""; fi chk "no active rollout left" "$(curl -s -b "$CJ" "$BASE/rest/private/agent/v1/rollout/active" | field "d['data'] is None")" "True" else echo " SKIP: observer promote/cancel on a real rollout (this server already has an active rollout)" fi chk "observer: command history readable" "$(curl -s -b "$OJ" "$BASE/rest/private/agent/v1/devices/$DID/commands?since=0" | field "d['status']")" "OK" chk "observer: device state readable" "$(curl -s -b "$OJ" "$BASE/rest/private/agent/v1/devices/$DID/state" | field "str(d['status'])+':'+str(d['data']['battery'])")" "OK:77" chk "observer: active rollout readable" "$(curl -s -b "$OJ" "$BASE/rest/private/agent/v1/rollout/active" | field "d['status']")" "OK" DELETED=$(curl -s -b "$CJ" -X DELETE "$BASE/rest/private/users/other/$OID" | field "d['status']" || true) # as CANCELLED chk "observer user deleted" "$DELETED" "OK" if [ "$DELETED" = OK ]; then LIVE_OID=""; LIVE_OLOGIN=""; fi # Last, since it enrolls two more devices: nothing above counts devices after this. echo "== a token for two devices enrolls two, then refuses a third ==" enroll_with(){ curl -s -X POST -H 'Content-Type: application/json' -d "{\"enrollToken\":\"$1\",\"agent\":{\"version\":\"0.1.0\",\"package\":\"com.mdmesh.agent\"},\"device\":{\"androidSdkInt\":34,\"isDeviceOwner\":true},\"capabilities\":{\"policy\":[],\"appManagement\":[],\"remoteControl\":{\"tier\":\"none\"},\"oem\":{\"vendor\":\"samsung\",\"knox\":false}}}" "$BASE/rest/public/agent/v1/enroll"; } TOK2=$(curl -s -b "$CJ" -X POST -H 'Content-Type: application/json' -d '{"maxUses":2,"validHours":1}' \ "$BASE/rest/private/agent/v1/token" | field "str(d['data']['maxUses'])+':'+d['data']['token']") chk "two-device token minted" "${TOK2%%:*}" "2" TOK2=${TOK2#*:} chk "two-device token: 1st enroll OK" "$(enroll_with "$TOK2" | field "d['status']")" "OK" chk "two-device token: 2nd enroll OK" "$(enroll_with "$TOK2" | field "d['status']")" "OK" chk "two-device token: 3rd enroll refused" "$(enroll_with "$TOK2" | field "d['message']")" "error.agent.token.used" chk "single-use token: 2nd enroll refused" "$(enroll_with "$TOK" | field "d['message']")" "error.agent.token.used" chk "token for more than 1000 devices refused" "$(curl -s -b "$CJ" -X POST -H 'Content-Type: application/json' \ -d '{"maxUses":1001}' "$BASE/rest/private/agent/v1/token" | field "d['message']")" "error.agent.token.maxUses" # The device count holds when enrolls race: claim() is one UPDATE, so of 8 at once exactly 3 get a 3-device token. TOK3=$(curl -s -b "$CJ" -X POST -H 'Content-Type: application/json' -d '{"maxUses":3,"validHours":1}' \ "$BASE/rest/private/agent/v1/token" | field "d['data']['token']") PAR="$(mktemp -d)" for i in 1 2 3 4 5 6 7 8; do (enroll_with "$TOK3" | field "d['status']" > "$PAR/$i") & done; wait chk "three-device token: 8 enrolls at once, 3 succeed" "$(cat "$PAR"/* | grep -c '^OK$')" "3" rm -rf "$PAR" # Stopping all codes ends a batch code at once (enrolls two more devices), and a code made afterwards works. echo "== stopping all codes stops a batch code ==" TOK4=$(curl -s -b "$CJ" -X POST -H 'Content-Type: application/json' -d '{"maxUses":5,"validHours":1}' \ "$BASE/rest/private/agent/v1/token" | field "d['data']['token']") chk "batch code: enroll before the stop OK" "$(enroll_with "$TOK4" | field "d['status']")" "OK" chk "stop all codes" "$(curl -s -b "$CJ" -X POST "$BASE/rest/private/agent/v1/token/revoke-all" | field "str(d['status'])+':'+str(d['data']['revoked']>=1)")" "OK:True" chk "batch code: enroll after the stop refused" "$(enroll_with "$TOK4" | field "d['message']")" "error.agent.token.used" TOK5=$(curl -s -b "$CJ" -X POST "$BASE/rest/private/agent/v1/token" | field "d['data']['token']") chk "a code made after the stop enrolls" "$(enroll_with "$TOK5" | field "d['status']")" "OK" echo "===== RESULT: PASS=$PASS FAIL=$FAIL =====" [ "$FAIL" -eq 0 ]