diff --git a/src/nrfcloud_utils/nrf93_onboard.py b/src/nrfcloud_utils/nrf93_onboard.py index d8efcd9..ebb3a18 100644 --- a/src/nrfcloud_utils/nrf93_onboard.py +++ b/src/nrfcloud_utils/nrf93_onboard.py @@ -7,6 +7,7 @@ import argparse import re import sys +import time import logging import requests import coloredlogs @@ -188,22 +189,53 @@ def gen_registration_jwt(cred_if, tenant_id): logger.debug('Retrieved registration JWT from device') return jwt_str +def get_bulk_ops_result(api_key, bulk_ops_req_id): + hdr = {'Authorization': 'Bearer ' + api_key} + req = api_url + "bulk-ops-requests/" + bulk_ops_req_id + return requests.get(req, headers=hdr) + + +def wait_for_onboarding_result(api_key, bulk_ops_req_id, max_attempts=12): + logger.info(f'Waiting for onboarding to complete (bulkOpsRequestId: {bulk_ops_req_id})') + for _ in range(max_attempts): + time.sleep(5) + result = get_bulk_ops_result(api_key, bulk_ops_req_id) + if result.status_code != 200: + logger.error('Failed to fetch onboarding result') + return None + result_json = result.json() + status = result_json.get('status', 'UNKNOWN') + logger.info(f'Onboarding status: {status}') + if status in ('IN_PROGRESS', 'PENDING'): + continue + if status == 'SUCCEEDED': + return result + if status == 'FAILED': + if 'errorSummaryUrl' in result_json: + error_resp = requests.get(result_json['errorSummaryUrl']) + if error_resp.status_code == 200: + logger.error(f'Error details: {error_resp.text}') + return result + return result + logger.error('Timeout waiting for onboarding result') + return None + + def onboard_device(api_key, dev_id, sub_type, tags, fw_types, onboarding_token): hdr = { 'Authorization': 'Bearer ' + api_key, - 'Accept': 'application/json', + 'content-type': 'text/plain', } - req = api_url + "devices/" + dev_id + req = api_url + "devices" - payload = { - 'onboardingToken': onboarding_token, - 'subType': sub_type, - 'tags': tags, - 'supportedFirmwareTypes': fw_types, - } + tags_str = '|'.join(tags) if isinstance(tags, list) else tags + fw_str = '|'.join(fw_types) if isinstance(fw_types, list) else fw_types + # CSV format: deviceId,subType,tags,supportedFirmwareTypes,certificate,onboardingToken + # certificate left empty; onboardingToken used instead + payload = f'{dev_id},{sub_type},{tags_str},{fw_str},,{onboarding_token}\n' - return requests.post(req, json=payload, headers=hdr) + return requests.post(req, data=payload, headers=hdr) def main(in_args): args = parse_args(in_args) @@ -273,9 +305,20 @@ def main(in_args): sub_type = "nRF93M1" fw_types = ["MODEM"] onboard_response = onboard_device(args.api_key, dev_id, sub_type, args.tags, fw_types, registration_jwt) - if not onboard_response.ok: + if onboard_response.status_code != 202: logger.error(f'Failed to onboard device: HTTP {onboard_response.status_code} - {onboard_response.text}') sys.exit(6) + + bulk_req_id = onboard_response.json().get('bulkOpsRequestId') + if not bulk_req_id: + logger.error('No bulkOpsRequestId in onboarding response') + sys.exit(6) + + final_result = wait_for_onboarding_result(args.api_key, bulk_req_id) + if final_result is None or final_result.json().get('status') != 'SUCCEEDED': + status = final_result.json().get('status', 'UNKNOWN') if final_result else 'TIMEOUT' + logger.error(f'Onboarding did not succeed: {status}') + sys.exit(6) logger.info('[OK] Device onboarded successfully') except KeyboardInterrupt: diff --git a/tests/test_nRF93_onboard.py b/tests/test_nRF93_onboard.py index fdc7982..fbfa1b7 100644 --- a/tests/test_nRF93_onboard.py +++ b/tests/test_nRF93_onboard.py @@ -162,6 +162,41 @@ def test_tag_too_long_raises(self): nrf93_onboard._valid_tag("a" * 800) +# --------------------------------------------------------------------------- +# onboard_device +# --------------------------------------------------------------------------- + +class TestOnboardDevice: + @patch("nrfcloud_utils.nrf93_onboard.requests.post") + def test_csv_payload_format(self, mock_post): + mock_post.return_value = Mock(status_code=202) + nrf93_onboard.onboard_device( + "my-api-key", TEST_UUID, "nRF93M1", ["tag1", "tag2"], ["MODEM"], TEST_REGJWT + ) + _, kwargs = mock_post.call_args + body = kwargs["data"] + assert body == f"{TEST_UUID},nRF93M1,tag1|tag2,MODEM,,{TEST_REGJWT}\n" + + @patch("nrfcloud_utils.nrf93_onboard.requests.post") + def test_endpoint_is_devices_not_devices_id(self, mock_post): + mock_post.return_value = Mock(status_code=202) + nrf93_onboard.onboard_device( + "my-api-key", TEST_UUID, "nRF93M1", ["nRF93M1-EK"], ["MODEM"], TEST_REGJWT + ) + url = mock_post.call_args[0][0] + assert url.endswith("/devices") + assert TEST_UUID not in url + + @patch("nrfcloud_utils.nrf93_onboard.requests.post") + def test_content_type_is_text_plain(self, mock_post): + mock_post.return_value = Mock(status_code=202) + nrf93_onboard.onboard_device( + "my-api-key", TEST_UUID, "nRF93M1", ["nRF93M1-EK"], ["MODEM"], TEST_REGJWT + ) + headers = mock_post.call_args[1]["headers"] + assert headers["content-type"] == "text/plain" + + # --------------------------------------------------------------------------- # fetch_tenant_id # --------------------------------------------------------------------------- @@ -199,6 +234,10 @@ def test_invalid_json(self, mock_get): def _base_patches(): """Return attribute-name keyed dict for use with patch.multiple(MODULE, ...).""" + onboard_resp = Mock(status_code=202) + onboard_resp.json.return_value = {'bulkOpsRequestId': 'test-bulk-id'} + wait_result = Mock() + wait_result.json.return_value = {'status': 'SUCCEEDED'} return { "Comms": MagicMock(), "ATCommandInterface": MagicMock(), @@ -206,7 +245,8 @@ def _base_patches(): "get_nrf93m1_identity_key": Mock(return_value=TEST_IDENTITY_KEY), "fetch_tenant_id": Mock(return_value=TEST_TENANT_ID), "gen_registration_jwt": Mock(return_value=TEST_REGJWT), - "onboard_device": Mock(return_value=Mock(ok=True)), + "onboard_device": Mock(return_value=onboard_resp), + "wait_for_onboarding_result": Mock(return_value=wait_result), } @@ -266,7 +306,25 @@ def test_jwt_fails_exit_5(self): def test_onboard_http_error_exit_6(self): patches = _base_patches() - patches["onboard_device"] = Mock(return_value=Mock(ok=False, status_code=403, text="Forbidden")) + patches["onboard_device"] = Mock(return_value=Mock(status_code=400, text="Bad Request")) + with pytest.raises(SystemExit) as exc: + _run_main_with_patches(patches) + assert exc.value.code == 6 + + def test_onboard_no_bulk_id_exit_6(self): + patches = _base_patches() + resp = Mock(status_code=202) + resp.json.return_value = {} + patches["onboard_device"] = Mock(return_value=resp) + with pytest.raises(SystemExit) as exc: + _run_main_with_patches(patches) + assert exc.value.code == 6 + + def test_onboard_polling_failed_exit_6(self): + patches = _base_patches() + failed_result = Mock() + failed_result.json.return_value = {'status': 'FAILED'} + patches["wait_for_onboarding_result"] = Mock(return_value=failed_result) with pytest.raises(SystemExit) as exc: _run_main_with_patches(patches) assert exc.value.code == 6