diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cee4ca..cbb5f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- iot-client — `iot_client_get_session_token_ex()`, which reports *why* the + cloud refused to issue an agent token. + - The refusals a device actually meets in the field all arrive on this one + path and need opposite handling: `GATEWAY_NOT_EXISTS` (removed from the + cloud — re-provision), `CHILD_PRIVACY_AGREEMENT_REQUIRED` (agreement not + signed yet — wait and retry, the user is expected to act in the app), + `ISSUE_TOKEN_FAILED` (no agent configured for the product — retrying is + pointless). `iot_client_get_session_token()` collapsed all three into + `OPRT_ATOP_BUSINESS_ERROR`, so a device could only retry blindly. + - `atop_base_response_t` already parsed `errorCode`/`errorMsg`, but + `atop_ai_token_get()` dropped them on the floor. They now reach the caller + through a new `iot_atop_rejection_t` out-parameter. + - Additive: `iot_client_get_session_token()` keeps its signature and + behaviour, and is now a wrapper passing `NULL`. Passing `NULL` for + `rejection` is supported and means "don't care". + - iot-client — device-initiated reset (`iot_client_reset`), for a device that unbinds itself rather than waiting to be removed from the app. - New public `iot_client_reset()` in `iot_client.h` over a new diff --git a/CMakeLists.txt b/CMakeLists.txt index 356516f..235b378 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -322,6 +322,7 @@ if(AGENTIC_KIT_BUILD_TESTS AND AGENTIC_KIT_ENABLE_PROJECT_TESTS) agentic_kit_add_iot_test(iot_dns_test "${IOT_CLIENT_TESTS_DIR}/dns_test.c") agentic_kit_add_iot_test(iot_atop_test "${IOT_CLIENT_TESTS_DIR}/atop_test.c") agentic_kit_add_iot_test(iot_atop_call_test "${IOT_CLIENT_TESTS_DIR}/iot_atop_call_test.c") + agentic_kit_add_iot_test(iot_session_token_test "${IOT_CLIENT_TESTS_DIR}/iot_session_token_test.c") agentic_kit_add_iot_test(iot_reset_test "${IOT_CLIENT_TESTS_DIR}/iot_reset_test.c") agentic_kit_add_iot_test(iot_mqtt_test "${IOT_CLIENT_TESTS_DIR}/mqtt_test.c") agentic_kit_add_iot_test(iot_message_test "${IOT_CLIENT_TESTS_DIR}/iot_client_message_test.c") diff --git a/modules/iot-client/include/iot_atop.h b/modules/iot-client/include/iot_atop.h index 70efd82..80e0caf 100644 --- a/modules/iot-client/include/iot_atop.h +++ b/modules/iot-client/include/iot_atop.h @@ -69,9 +69,9 @@ extern "C" { #endif -/** Buffer sizes for the cloud's error strings (see iot_atop_response_t). */ -#define IOT_ATOP_ERROR_CODE_LEN 48 -#define IOT_ATOP_ERROR_MSG_LEN 128 +/* IOT_ATOP_ERROR_CODE_LEN / IOT_ATOP_ERROR_MSG_LEN live in iot_client.h, next to + * iot_atop_rejection_t: the typed wrappers there report rejections too, and a + * header cannot include this one back (iot_atop.h includes iot_client.h). */ /** * @brief What to call. diff --git a/modules/iot-client/include/iot_client.h b/modules/iot-client/include/iot_client.h index bbde1b1..da95783 100644 --- a/modules/iot-client/include/iot_client.h +++ b/modules/iot-client/include/iot_client.h @@ -52,6 +52,27 @@ typedef enum { TEST, } iot_env_t; +/** Buffer sizes for the cloud's rejection strings (see iot_atop_rejection_t). */ +#define IOT_ATOP_ERROR_CODE_LEN 48 +#define IOT_ATOP_ERROR_MSG_LEN 128 + +/** + * @brief Why the cloud rejected a call. + * + * A rejection is the cloud reaching a verdict and saying no: the envelope is + * well-formed, `errorCode` takes the place of `result`. The code is the + * discriminator the caller acts on — the same rejection surfaces as + * OPRT_ATOP_BUSINESS_ERROR no matter *why* the cloud refused, and those whys + * need opposite responses (a device removed from the cloud should re-provision; + * a product whose privacy agreement is unsigned must NOT). + * + * `code` is "" unless the call came back OPRT_ATOP_BUSINESS_ERROR. + */ +typedef struct { + char code[IOT_ATOP_ERROR_CODE_LEN]; /**< cloud errorCode, e.g. "GATEWAY_NOT_EXISTS" */ + char msg[IOT_ATOP_ERROR_MSG_LEN]; /**< cloud errorMsg, human-readable */ +} iot_atop_rejection_t; + /** * @brief Initialize IoT SDK with the built-in default PAL adapter (POSIX / FreeRTOS). * @@ -387,6 +408,29 @@ IOT_API int iot_client_publish(iot_client_t *client, const uint8_t *data, size_t */ IOT_API int iot_client_get_session_token(iot_client_t *client, const char *agent_code, char *token, size_t token_len); +/** + * @brief Get an AI agent session token, and learn why the cloud said no. + * + * Same as iot_client_get_session_token(), except that a rejection is reported + * instead of being flattened into a bare OPRT_ATOP_BUSINESS_ERROR. Three + * different causes reach this call in practice — the device removed from the + * cloud, a product whose privacy agreement is unsigned, and a product with no + * AI agent configured — and they need opposite handling, so the code has to + * reach the caller that knows the product. + * + * @param client Pointer to iot_client_t instance + * @param agent_code Agent code string (NULL for default) + * @param token Output buffer for the token + * @param token_len Size of the output buffer in bytes + * @param rejection Optional; zeroed on entry and filled when the return code + * is OPRT_ATOP_BUSINESS_ERROR. NULL to ignore. + * @return OPRT_OK on success, OPRT_INVALID_PARAMETER if client or token is NULL, + * OPRT_ATOP_BUSINESS_ERROR when the cloud refused (see @p rejection). + */ +IOT_API int iot_client_get_session_token_ex(iot_client_t *client, const char *agent_code, + char *token, size_t token_len, + iot_atop_rejection_t *rejection); + /** * @brief Get CA certificate for a target host via IoT DNS service. * diff --git a/modules/iot-client/src/atop.c b/modules/iot-client/src/atop.c index 28e4032..b35e506 100644 --- a/modules/iot-client/src/atop.c +++ b/modules/iot-client/src/atop.c @@ -543,6 +543,14 @@ int atop_ai_token_get(const pal_t *pal, const ai_token_request_t *request, ai_to pal->free(post_data); if (rt != OPRT_OK) { + /* Carry the cloud's verdict out. Without this the caller sees only + * OPRT_ATOP_BUSINESS_ERROR, and "device removed from the cloud", + * "privacy agreement unsigned" and "no agent configured" become the + * same number — they need opposite handling. */ + snprintf(response->rejection.code, sizeof(response->rejection.code), "%s", + atop_response.error_code); + snprintf(response->rejection.msg, sizeof(response->rejection.msg), "%s", + atop_response.error_msg); log_error("http post err, rt:%d", rt); atop_base_response_free(pal,&atop_response); return rt; diff --git a/modules/iot-client/src/atop.h b/modules/iot-client/src/atop.h index 90bc37a..c31aa83 100644 --- a/modules/iot-client/src/atop.h +++ b/modules/iot-client/src/atop.h @@ -67,6 +67,7 @@ void atop_activate_response_free(const pal_t *pal, activite_response_t *response */ typedef struct { char *token; // JSON string (caller must free) + iot_atop_rejection_t rejection; // filled when the cloud refused; code "" otherwise } ai_token_response_t; /** diff --git a/modules/iot-client/src/iot_client.c b/modules/iot-client/src/iot_client.c index bab28e5..1f989ab 100644 --- a/modules/iot-client/src/iot_client.c +++ b/modules/iot-client/src/iot_client.c @@ -569,6 +569,17 @@ IOT_API iot_client_t *iot_client_init_on_boarding_with_token(const iot_on_boardi IOT_API int iot_client_get_session_token(iot_client_t *client, const char *agent_code, char *token, size_t token_len) { + return iot_client_get_session_token_ex(client, agent_code, token, token_len, NULL); +} + +IOT_API int iot_client_get_session_token_ex(iot_client_t *client, const char *agent_code, + char *token, size_t token_len, + iot_atop_rejection_t *rejection) +{ + if (rejection != NULL) { + memset(rejection, 0, sizeof(*rejection)); + } + if (client == NULL || token == NULL || token_len == 0) { log_error("iot_client_get_session_token: invalid parameters"); return OPRT_INVALID_PARAMETER; @@ -591,6 +602,9 @@ IOT_API int iot_client_get_session_token(iot_client_t *client, const char *agent ai_token_response_t resp = {0}; int ret = atop_ai_token_get(client->pal, &req, &resp); if (ret != OPRT_OK) { + if (rejection != NULL) { + *rejection = resp.rejection; + } log_error("atop_ai_token_get failed: %d", ret); return ret; } diff --git a/modules/iot-client/test/iot_session_token_test.c b/modules/iot-client/test/iot_session_token_test.c new file mode 100644 index 0000000..4b70ab0 --- /dev/null +++ b/modules/iot-client/test/iot_session_token_test.c @@ -0,0 +1,345 @@ +/** + * @file iot_session_token_test.c + * @brief Tests for iot_client_get_session_token() and its _ex variant. + * + * The interesting case is a rejection: the cloud refuses to issue an agent + * token and says why in errorCode. All of "device unbound", "privacy agreement + * unsigned" and "no agent configured" arrive on this one path as the same + * return code, so the caller can only tell them apart -- and a device can only + * react correctly, retry vs. re-provision vs. stop -- if errorCode survives. + * + * The client is a stack iot_client_t with devid/secret_key/https_url set, so no + * activation or MQTT connection is needed; https_url points the ATOP host + * resolution at the mock server. The mock rejects any agentCode of the form + * "reject:" with that errorCode. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "iot_client.h" +#include "iot_config_defaults.h" +#include "log.h" + +#define MOCK_HOST "127.0.0.1" +#define MOCK_PORT 8443 + +/* Must match test/config/atop.conf (device_id / sec_key). */ +#define TEST_DEVID "ci_device_test_001" +#define TEST_SEC_KEY "1234567890abcdef" + +static pid_t mock_pid = -1; +static int tests_run = 0; +static int tests_passed = 0; +static char *g_cacert = NULL; +static iot_client_t g_client; + +static char *load_file(const pal_t *pal, const char *path) +{ + FILE *f = fopen(path, "rb"); + if (!f) return NULL; + fseek(f, 0, SEEK_END); + long len = ftell(f); + fseek(f, 0, SEEK_SET); + char *buf = pal->malloc(len + 1); + if (buf) { + size_t read_len = fread(buf, 1, len, f); + if (read_len != (size_t)len) { + pal->free(buf); + fclose(f); + return NULL; + } + buf[len] = '\0'; + } + fclose(f); + return buf; +} + +#define RUN_TEST(fn) \ + do { \ + tests_run++; \ + printf("\n--- [%d] %s ---\n", tests_run, #fn); \ + if ((fn)() == 0) { \ + tests_passed++; \ + printf(" PASS\n"); \ + } else { \ + printf(" FAIL\n"); \ + } \ + } while (0) + +/* ---------- Mock server lifecycle (same probe as iot_atop_call_test) ---------- */ + +static int wait_for_port(uint16_t port, int timeout_ms) +{ + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + inet_pton(AF_INET, MOCK_HOST, &addr.sin_addr); + + const int step_ms = 50; + for (int waited = 0; waited <= timeout_ms; waited += step_ms) { + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd >= 0) { + int fl = fcntl(fd, F_GETFL, 0); + fcntl(fd, F_SETFL, fl | O_NONBLOCK); + int rc = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); + if (rc == 0) { close(fd); return 0; } + if (rc < 0 && errno == EINPROGRESS) { + fd_set wfds; FD_ZERO(&wfds); FD_SET(fd, &wfds); + struct timeval tv = { .tv_sec = 0, .tv_usec = 300 * 1000 }; + if (select(fd + 1, NULL, &wfds, NULL, &tv) > 0) { + int err = 0; socklen_t len = sizeof(err); + getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len); + if (err == 0) { close(fd); return 0; } + } + } + close(fd); + } + usleep(step_ms * 1000); + } + return -1; +} + +static int start_mock_server(void) +{ + mock_pid = fork(); + if (mock_pid == 0) { + setenv("ATOP_MOCK_USE_SSL", "1", 1); + execlp(PYTHON3_EXEC, PYTHON3_EXEC, MOCK_SCRIPT_PATH, NULL); + perror("execlp failed"); + _exit(1); + } + if (mock_pid < 0) { + perror("fork"); + return -1; + } + printf("Mock server started (pid %d, port %u), waiting for ready...\n", mock_pid, MOCK_PORT); + if (wait_for_port(MOCK_PORT, 15000) != 0) { + fprintf(stderr, "ATOP mock (%u) never became connectable\n", MOCK_PORT); + return -1; + } + return 0; +} + +static void stop_mock_server(void) +{ + if (mock_pid > 0) { + printf("Stopping mock server (pid %d)...\n", mock_pid); + kill(mock_pid, SIGTERM); + waitpid(mock_pid, NULL, 0); + mock_pid = -1; + } +} + +/* ---------- Guards ---------- */ + +static int test_null_params(void) +{ + char token[128] = {0}; + + if (iot_client_get_session_token(NULL, "agent", token, sizeof(token)) != OPRT_INVALID_PARAMETER || + iot_client_get_session_token(&g_client, "agent", NULL, sizeof(token)) != OPRT_INVALID_PARAMETER || + iot_client_get_session_token(&g_client, "agent", token, 0) != OPRT_INVALID_PARAMETER) { + printf(" NULL guards failed\n"); + return -1; + } + return 0; +} + +/* The _ex variant guards the same way, and a rejection buffer must not be a + * way to sneak past them. */ +static int test_ex_null_params(void) +{ + char token[128] = {0}; + iot_atop_rejection_t rejection; + memset(&rejection, 'x', sizeof(rejection)); + + int rt = iot_client_get_session_token_ex(NULL, "agent", token, sizeof(token), &rejection); + if (rt != OPRT_INVALID_PARAMETER) { + printf(" returned %d, expected OPRT_INVALID_PARAMETER\n", rt); + return -1; + } + if (rejection.code[0] != '\0' || rejection.msg[0] != '\0') { + printf(" rejection not cleared on a rejected argument\n"); + return -1; + } + return 0; +} + +/* ---------- Round trips ---------- */ + +static int test_token_round_trip(void) +{ + /* "token" is the whole session blob -- the connect and session config the + * caller then parses -- so it needs room, not the 32 bytes a bare token + * would take. */ + char token[2048] = {0}; + + int rt = iot_client_get_session_token(&g_client, "agent_alpha", token, sizeof(token)); + if (rt != OPRT_OK) { + printf(" returned %d, expected OPRT_OK\n", rt); + return -1; + } + if (strstr(token, "\"agentToken\":\"mock_token_agent_alpha\"") == NULL) { + printf(" token blob carries no agentToken: %s\n", token); + return -1; + } + return 0; +} + +/* On success there is nothing to explain, so the rejection stays empty. */ +static int test_success_leaves_rejection_empty(void) +{ + char token[2048] = {0}; + iot_atop_rejection_t rejection; + memset(&rejection, 'x', sizeof(rejection)); + + int rt = iot_client_get_session_token_ex(&g_client, "agent_alpha", token, + sizeof(token), &rejection); + if (rt != OPRT_OK) { + printf(" returned %d, expected OPRT_OK\n", rt); + return -1; + } + if (rejection.code[0] != '\0' || rejection.msg[0] != '\0') { + printf(" rejection is \"%s\"/\"%s\", expected empty\n", rejection.code, rejection.msg); + return -1; + } + return 0; +} + +/* The whole point: the cloud's verdict reaches the caller. */ +static int test_rejection_carries_error_code(void) +{ + const char *codes[] = { + "GATEWAY_NOT_EXISTS", /* device removed from the cloud */ + "CHILD_PRIVACY_AGREEMENT_REQUIRED", /* agreement not signed yet */ + "ISSUE_TOKEN_FAILED", /* no agent configured for the product */ + }; + + for (size_t i = 0; i < sizeof(codes) / sizeof(codes[0]); i++) { + char agent_code[96]; + char token[128] = {0}; + iot_atop_rejection_t rejection = {0}; + + snprintf(agent_code, sizeof(agent_code), "reject:%s", codes[i]); + int rt = iot_client_get_session_token_ex(&g_client, agent_code, token, + sizeof(token), &rejection); + if (rt == OPRT_OK) { + printf(" \"%s\" unexpectedly succeeded\n", codes[i]); + return -1; + } + if (strcmp(rejection.code, codes[i]) != 0) { + printf(" code is \"%s\", expected \"%s\"\n", rejection.code, codes[i]); + return -1; + } + if (rejection.msg[0] == '\0') { + printf(" \"%s\" carried no errorMsg\n", codes[i]); + return -1; + } + printf(" %s -> rt=%d code=\"%s\" msg=\"%s\"\n", codes[i], rt, + rejection.code, rejection.msg); + } + return 0; +} + +/* A caller that does not care must still be able to pass NULL. */ +static int test_rejection_buffer_is_optional(void) +{ + char token[128] = {0}; + + int rt = iot_client_get_session_token_ex(&g_client, "reject:ISSUE_TOKEN_FAILED", + token, sizeof(token), NULL); + if (rt == OPRT_OK) { + printf(" unexpectedly succeeded\n"); + return -1; + } + /* Same path through the old signature, which is now a wrapper. */ + rt = iot_client_get_session_token(&g_client, "reject:ISSUE_TOKEN_FAILED", + token, sizeof(token)); + if (rt == OPRT_OK) { + printf(" wrapper unexpectedly succeeded\n"); + return -1; + } + return 0; +} + +/* A token that does not fit is a caller bug, not a cloud rejection: the + * response is well formed, so nothing should be reported as refused. */ +static int test_short_buffer_is_not_a_rejection(void) +{ + char token[4] = {0}; /* far too small for the session blob */ + iot_atop_rejection_t rejection; + memset(&rejection, 'x', sizeof(rejection)); + + int rt = iot_client_get_session_token_ex(&g_client, "agent_alpha", token, + sizeof(token), &rejection); + if (rt != OPRT_INVALID_RESULT) { + printf(" returned %d, expected OPRT_INVALID_RESULT\n", rt); + return -1; + } + if (rejection.code[0] != '\0') { + printf(" reported rejection \"%s\" for a local buffer problem\n", rejection.code); + return -1; + } + return 0; +} + +int main(void) +{ + printf("========== Session Token Test Suite ==========\n"); + + const pal_t *pal = get_default_pal(); + iot_init(pal); + + g_cacert = load_file(pal, TEST_CONFIG_DIR "/root_cert.pem"); + if (!g_cacert) { + fprintf(stderr, "Failed to load CA certificate from %s\n", + TEST_CONFIG_DIR "/root_cert.pem"); + return 1; + } + + /* A stack client is enough: the token call only reads credentials, the + * resolved ATOP endpoint, and the TLS settings. */ + memset(&g_client, 0, sizeof(g_client)); + g_client.pal = pal; + snprintf(g_client.devid, sizeof(g_client.devid), "%s", TEST_DEVID); + snprintf(g_client.secret_key, sizeof(g_client.secret_key), "%s", TEST_SEC_KEY); + snprintf(g_client.https_url, sizeof(g_client.https_url), + "https://%s:%u", MOCK_HOST, MOCK_PORT); + g_client.cacert = g_cacert; + + if (start_mock_server() != 0) { + fprintf(stderr, "Failed to start mock server\n"); + pal->free(g_cacert); + return 1; + } + + /* Guards — no network needed */ + RUN_TEST(test_null_params); + RUN_TEST(test_ex_null_params); + + /* Round trips */ + RUN_TEST(test_token_round_trip); + RUN_TEST(test_success_leaves_rejection_empty); + RUN_TEST(test_rejection_carries_error_code); + RUN_TEST(test_rejection_buffer_is_optional); + RUN_TEST(test_short_buffer_is_not_a_rejection); + + stop_mock_server(); + pal->free(g_cacert); + + printf("\n========== Results: %d/%d passed ==========\n", tests_passed, tests_run); + return (tests_passed == tests_run) ? 0 : 1; +} diff --git a/modules/iot-client/test/mock/atop_mock.py b/modules/iot-client/test/mock/atop_mock.py index d3b127e..940921c 100755 --- a/modules/iot-client/test/mock/atop_mock.py +++ b/modules/iot-client/test/mock/atop_mock.py @@ -271,6 +271,18 @@ def handle_ai_token_request(request_data, config): agent_code = request_json.get('agentCode', '') + # Test-only: an agentCode of "reject:" makes the cloud reject the + # request with that errorCode, so the caller's handling of the real + # rejections (unbound device, unsigned privacy agreement, no agent + # configured) can be tested. Real agent codes never contain a colon. + if agent_code.startswith('reject:'): + return json.dumps({ + "success": False, + "t": int(time.time()), + "errorCode": agent_code[len('reject:'):], + "errorMsg": "mock rejection" + }, separators=(',', ':')) + device_id = config.get('device_id', 'device') response = { "success": True,