Files
pictureFrame-firmware/test/test_normal_operation/test_main.cpp
T
football2801 27d01057e4 feat(operation): verify X-Image-Sha256 before painting the panel
Pairs with the server-side header. After streaming the response body to
LittleFS, hash the file with mbedtls/sha256 (hardware-accelerated on
ESP32-S3) and compare against the server's claim. On mismatch:

- Don't update NVS_KEY_IMG_ID, so the next poll reports the old id and
  the server sends 200 again with fresh bytes (natural retry, no extra
  HTTP round-trip in this cycle).
- Don't draw — panel keeps whatever was up before, no garbage on the
  e-ink.
- Raise NVS_KEY_ERR_BORDER so the next healthy 304 paints a clean
  recovery frame with the sync-fail border.

Verification is skipped when the header is absent, so the firmware
stays compatible with any server that hasn't deployed the matching
header yet. mbedtls compiles into a native-test no-op stub (returns
empty hex), so existing native tests don't need a SHA implementation.

Two new tests: FW-17a (mismatch path) and FW-17b (missing header
backward compat). Mock String now has equalsIgnoreCase so the new
comparison compiles in native-test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:43:02 -04:00

401 lines
16 KiB
C++

#include <unity.h>
#include <map>
#include <string>
#include <cstdint>
#include <cctype>
// Include mocks first — they shadow system/Arduino headers.
// -iquote test/mocks ensures quoted includes from test_main find mocks first.
// operation.h uses #ifdef UNIT_TEST to pick epd_mock.h and esp_sleep.h.
#include "Arduino.h"
#include "WiFi.h"
#include "WiFiClientSecure.h"
#include "Preferences.h"
#include "LittleFS.h"
#include "epd_mock.h"
#include "esp_sleep.h"
#include "HTTPClient.h"
#include "SPI.h"
#include "WebServer.h"
#include "DNSServer.h"
#include "qrcode.h"
#include "config.h"
// Define globals referenced as extern in the mock headers
int g_http_get_code;
std::map<std::string, std::string> g_http_response_headers;
std::map<std::string, std::string> g_http_request_headers;
bool g_http_end_called;
std::string g_http_body;
int g_epd_init_count, g_epd_sleep_count, g_epd_draw_image_count;
int g_epd_fill_count, g_epd_fill_last_color, g_epd_draw_setup_count;
int g_epd_draw_border_count, g_epd_draw_border_last_color, g_epd_draw_border_last_thickness;
uint64_t g_sleep_us;
bool g_deep_sleep_started;
// Globals for new mocks
int g_show_setup_qr_count;
uint32_t g_millis_value;
int g_digital_read_value;
int g_wifi_status;
// Ordering / sequencing globals (shared with Preferences.h and epd_mock.h)
int g_call_seq = 0;
int g_prefs_putint_seq = -1;
int g_epd_draw_seq = -1;
// Include the template under test AFTER all mocks are defined.
// operation.h with UNIT_TEST defined will include "epd_mock.h" and "esp_sleep.h"
// via -iquote test/mocks path (real src/epd.h is never pulled in).
#include "../../src/operation.h"
// Test fixtures
static Preferences prefs;
static MockHTTPClient http;
void reset_state() {
g_http_get_code = 200;
g_http_response_headers.clear();
g_http_request_headers.clear();
g_http_end_called = false;
g_http_body = "TESTDATA";
g_epd_init_count = g_epd_sleep_count = g_epd_draw_image_count = 0;
g_epd_fill_count = g_epd_fill_last_color = g_epd_draw_setup_count = 0;
g_epd_draw_border_count = g_epd_draw_border_last_color = g_epd_draw_border_last_thickness = 0;
g_sleep_us = 0;
g_deep_sleep_started = false;
g_show_setup_qr_count = 0;
g_millis_value = 0;
g_digital_read_value = HIGH; // button not pressed by default
g_wifi_status = WL_CONNECTED; // connected by default
prefs.clear();
LittleFS.files.clear();
http._ended = false;
g_call_seq = 0;
g_prefs_putint_seq = -1;
g_epd_draw_seq = -1;
}
void setUp() { reset_state(); }
void tearDown() {}
// FW-01: 200 response — file written, epd_draw called, NVS saved, deep sleep started
void test_fw01_200_response_happy_path() {
// Use an interval < FETCH_INTERVAL_MS so server value is honored
g_http_response_headers["X-Image-Id"] = "42";
g_http_response_headers["X-Interval-Ms"] = "30000";
g_http_body = "BINDATA";
normal_operation_impl(String("1C:C3:AB:D1:91:F8"), http, String("https://test/api/device/mac/image"), prefs);
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
TEST_ASSERT_EQUAL(42, prefs.getInt("img_id", -1));
TEST_ASSERT_EQUAL_UINT64(30000ULL * 1000ULL, g_sleep_us);
TEST_ASSERT_TRUE(g_deep_sleep_started);
TEST_ASSERT_FALSE(LittleFS.files[IMAGE_PATH].empty());
}
// FW-02: REGRESSION — headers must be read BEFORE http.end(), otherwise newId is empty
void test_fw02_headers_read_before_end_regression() {
g_http_response_headers["X-Image-Id"] = "99";
normal_operation_impl(String("mac"), http, String("url"), prefs);
// If newId was read after end(), NVS img_id would remain -1
TEST_ASSERT_EQUAL(99, prefs.getInt("img_id", -1));
}
// FW-03: 304 — no epd draw, no init, deep sleep started
void test_fw03_304_no_redraw() {
g_http_get_code = 304;
// Use an interval < FETCH_INTERVAL_MS so server value is honored
g_http_response_headers["X-Interval-Ms"] = "30000";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(0, g_epd_init_count);
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
TEST_ASSERT_TRUE(g_deep_sleep_started);
TEST_ASSERT_EQUAL_UINT64(30000ULL * 1000ULL, g_sleep_us);
}
// FW-04: 204 — show_setup_qr called exactly once
void test_fw04_204_shows_setup_qr() {
g_http_get_code = 204;
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(1, g_show_setup_qr_count);
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
}
// FW-05: 404 — show_setup_qr called exactly once
void test_fw05_404_shows_setup_qr() {
g_http_get_code = 404;
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(1, g_show_setup_qr_count);
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
}
// FW-06a: 5xx error WITH a cached image → preserve last image and overlay a
// yellow BORDER (per FR38). MUST NOT fill the screen with yellow — that would
// destroy the last good image. Sets the err_border NVS flag so the next
// healthy response repaints clean.
void test_fw06a_error_with_cache_draws_border_not_fill() {
g_http_get_code = 500;
LittleFS.files[IMAGE_PATH] = "IMGDATA";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL_MESSAGE(0, g_epd_fill_count,
"epd_fill must NOT be called when a cached image exists — it would obliterate the photo");
TEST_ASSERT_EQUAL(1, g_epd_draw_border_count);
TEST_ASSERT_EQUAL(COLOR_YELLOW, g_epd_draw_border_last_color);
TEST_ASSERT_EQUAL(BORDER_THICKNESS_PX, g_epd_draw_border_last_thickness);
TEST_ASSERT_EQUAL(1, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
}
// FW-06b: 5xx error with NO cached image → fall back to full yellow fill so
// the user still sees a sync-fail signal on a fresh device.
void test_fw06b_error_without_cache_falls_back_to_fill() {
g_http_get_code = 500;
// LittleFS has no IMAGE_PATH entry
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(0, g_epd_draw_border_count);
TEST_ASSERT_EQUAL(1, g_epd_fill_count);
TEST_ASSERT_EQUAL(COLOR_YELLOW, g_epd_fill_last_color);
TEST_ASSERT_EQUAL(1, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
}
// FW-06c: 304 with err_border flag set (sync recovered after a previous
// failure) → repaint the cached image clean and clear the flag.
void test_fw06c_304_after_error_repaints_clean() {
g_http_get_code = 304;
prefs.ints[NVS_KEY_ERR_BORDER] = 1;
prefs.ints[NVS_KEY_IMG_ID] = 7;
LittleFS.files[IMAGE_PATH] = "IMGDATA";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(1, g_epd_init_count);
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
TEST_ASSERT_EQUAL_MESSAGE(0, g_epd_draw_border_count,
"304 with err flag must redraw clean — no border on the recovery frame");
TEST_ASSERT_EQUAL(0, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
}
// FW-06d: 304 (same image) with NO error or pending state must NOT touch the
// display. Specifically must not invoke any yellow path. Locks down the
// regression the user reported: 304 was suspected of triggering yellow fill.
void test_fw06d_304_steady_state_does_not_fill_yellow() {
g_http_get_code = 304;
prefs.ints[NVS_KEY_IMG_ID] = 7;
prefs.ints[NVS_KEY_SCHEMA_V] = NVS_SCHEMA_VERSION; // post-migration steady state
LittleFS.files[IMAGE_PATH] = "IMGDATA";
// err_border = 0, draw_needed = 0 (defaults)
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(0, g_epd_fill_count);
TEST_ASSERT_EQUAL(0, g_epd_draw_border_count);
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
TEST_ASSERT_EQUAL(0, g_epd_init_count);
}
// FW-06e: 200 response after a previous error border → fresh image fully
// overwrites the framebuffer, err_border flag cleared.
void test_fw06e_200_after_error_clears_flag() {
g_http_response_headers["X-Image-Id"] = "8";
g_http_body = "BINDATA";
prefs.ints[NVS_KEY_ERR_BORDER] = 1;
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
TEST_ASSERT_EQUAL(0, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
}
// FW-06f: First boot of err-border-aware firmware (NVS schema_v missing) on a
// device that may be displaying a stale full-screen yellow from the previous
// buggy build → migration forces a clean redraw on the next 304 and bumps
// schema_v so it doesn't fire again. Locks in the upgrade-path recovery.
void test_fw06f_schema_migration_forces_redraw_on_first_boot() {
g_http_get_code = 304;
prefs.ints[NVS_KEY_IMG_ID] = 3;
LittleFS.files[IMAGE_PATH] = "IMGDATA";
// No NVS_KEY_SCHEMA_V set → defaults to 0, triggers migration
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(1, g_epd_init_count);
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
TEST_ASSERT_EQUAL(NVS_SCHEMA_VERSION, prefs.getInt(NVS_KEY_SCHEMA_V, -1));
TEST_ASSERT_EQUAL(0, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
}
// FW-06g: Second boot under same firmware → migration does NOT fire again,
// 304 is a no-op as in the steady-state case.
void test_fw06g_schema_migration_does_not_fire_again() {
g_http_get_code = 304;
prefs.ints[NVS_KEY_IMG_ID] = 3;
prefs.ints[NVS_KEY_SCHEMA_V] = NVS_SCHEMA_VERSION;
LittleFS.files[IMAGE_PATH] = "IMGDATA";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(0, g_epd_init_count);
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
TEST_ASSERT_EQUAL(0, g_epd_fill_count);
}
// FW-07: NVS has saved img_id → X-Current-Image-Id header sent
void test_fw07_current_image_id_sent_when_saved() {
prefs.ints["img_id"] = 99;
g_http_response_headers["X-Image-Id"] = "99";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL_STRING("99", g_http_request_headers["X-Current-Image-Id"].c_str());
}
// FW-08: NVS img_id = -1 (default) → X-Current-Image-Id NOT sent
void test_fw08_no_current_image_id_when_default() {
// prefs has no img_id — getInt returns -1
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_TRUE(g_http_request_headers.find("X-Current-Image-Id") == g_http_request_headers.end());
}
// FW-09: server interval < FETCH_INTERVAL_MS → server value used
void test_fw09_server_interval_honored() {
g_http_response_headers["X-Interval-Ms"] = "30000";
g_http_response_headers["X-Image-Id"] = "1";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL_UINT64(30000ULL * 1000ULL, g_sleep_us);
}
// FW-10: server interval > FETCH_INTERVAL_MS → capped at ceiling
void test_fw10_server_interval_capped() {
g_http_response_headers["X-Interval-Ms"] = "999999999";
g_http_response_headers["X-Image-Id"] = "1";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL_UINT64(FETCH_INTERVAL_MS * 1000ULL, g_sleep_us);
}
// FW-11: no X-Interval-Ms → default ceiling used
void test_fw11_default_interval_when_absent() {
g_http_response_headers["X-Image-Id"] = "1";
// no X-Interval-Ms set
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL_UINT64(FETCH_INTERVAL_MS * 1000ULL, g_sleep_us);
}
// FW-14: 304 — epd_sleep NOT called (display already in hardware deep sleep)
void test_fw14_304_skips_epd_sleep() {
g_http_get_code = 304;
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(0, g_epd_sleep_count);
TEST_ASSERT_EQUAL(0, g_epd_init_count);
}
// FW-15: 200 — NVS img_id saved BEFORE epd_draw_image_from_file; draw_needed cleared after
void test_fw15_nvs_saved_before_epd_draw_and_flag_cleared() {
g_http_response_headers["X-Image-Id"] = "42";
g_http_body = "BINDATA";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_TRUE_MESSAGE(g_prefs_putint_seq < g_epd_draw_seq,
"NVS putInt must be called before epd_draw_image_from_file");
TEST_ASSERT_EQUAL(42, prefs.getInt("img_id", -1));
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
TEST_ASSERT_EQUAL(0, prefs.getInt("draw", -1));
}
// FW-16: 304 with draw_needed=1 (interrupted draw) — re-draws from LittleFS and clears flag
void test_fw16_304_with_draw_needed_redraws() {
prefs.ints["img_id"] = 42;
prefs.ints["draw"] = 1;
g_http_get_code = 304;
LittleFS.files[IMAGE_PATH] = "IMGDATA";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(1, g_epd_init_count);
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
TEST_ASSERT_EQUAL(1, g_epd_sleep_count);
TEST_ASSERT_EQUAL(0, prefs.getInt("draw", -1));
}
// FW-17a: 200 response with mismatched X-Image-Sha256 — the bytes were
// corrupted in transit. Don't paint the panel; don't update NVS_KEY_IMG_ID
// (so the next poll re-fetches); raise err_border so the user sees a sync
// issue instead of garbage on the panel.
void test_fw17a_sha256_mismatch_skips_draw_and_keeps_old_img_id() {
g_http_response_headers["X-Image-Id"] = "42";
g_http_response_headers["X-Image-Sha256"] = "deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe";
g_http_body = "BINDATA";
prefs.ints[NVS_KEY_IMG_ID] = 7; // pre-existing image we want to keep
normal_operation_impl(String("mac"), http, String("url"), prefs);
// Panel must not be touched — the corrupt bytes never reach hardware.
TEST_ASSERT_EQUAL(0, g_epd_init_count);
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
// NVS image-id stays at the prior value — next poll mismatches the
// server's currentImage, server sends 200 again, device retries.
TEST_ASSERT_EQUAL(7, prefs.getInt(NVS_KEY_IMG_ID, -1));
// err_border raised so the next healthy 304 will repaint clean.
TEST_ASSERT_EQUAL(1, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
}
// FW-17b: 200 response without an X-Image-Sha256 header (e.g., older server)
// — verification is skipped, the success path runs as it always did. Backward-
// compat guarantee: the firmware still works against pre-checksum servers.
void test_fw17b_missing_sha256_header_skips_verification() {
g_http_response_headers["X-Image-Id"] = "42";
// No X-Image-Sha256 set
g_http_body = "BINDATA";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
TEST_ASSERT_EQUAL(42, prefs.getInt(NVS_KEY_IMG_ID, -1));
TEST_ASSERT_EQUAL(0, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
}
// FW-12/13: AP SSID derivation via ap_ssid_from_mac()
void test_fw12_ap_ssid_from_mac_aabbcc() {
String ssid = ap_ssid_from_mac(String("AA:BB:CC:DD:EE:FF"));
TEST_ASSERT_EQUAL_STRING("PictureFrame-EEFF", ssid.c_str());
}
void test_fw13_ap_ssid_from_real_mac() {
String ssid = ap_ssid_from_mac(String("1C:C3:AB:D1:91:F8"));
TEST_ASSERT_EQUAL_STRING("PictureFrame-91F8", ssid.c_str());
}
int main(int argc, char** argv) {
UNITY_BEGIN();
RUN_TEST(test_fw01_200_response_happy_path);
RUN_TEST(test_fw02_headers_read_before_end_regression);
RUN_TEST(test_fw03_304_no_redraw);
RUN_TEST(test_fw04_204_shows_setup_qr);
RUN_TEST(test_fw05_404_shows_setup_qr);
RUN_TEST(test_fw06a_error_with_cache_draws_border_not_fill);
RUN_TEST(test_fw06b_error_without_cache_falls_back_to_fill);
RUN_TEST(test_fw06c_304_after_error_repaints_clean);
RUN_TEST(test_fw06d_304_steady_state_does_not_fill_yellow);
RUN_TEST(test_fw06e_200_after_error_clears_flag);
RUN_TEST(test_fw06f_schema_migration_forces_redraw_on_first_boot);
RUN_TEST(test_fw06g_schema_migration_does_not_fire_again);
RUN_TEST(test_fw07_current_image_id_sent_when_saved);
RUN_TEST(test_fw08_no_current_image_id_when_default);
RUN_TEST(test_fw09_server_interval_honored);
RUN_TEST(test_fw10_server_interval_capped);
RUN_TEST(test_fw11_default_interval_when_absent);
RUN_TEST(test_fw12_ap_ssid_from_mac_aabbcc);
RUN_TEST(test_fw13_ap_ssid_from_real_mac);
RUN_TEST(test_fw14_304_skips_epd_sleep);
RUN_TEST(test_fw15_nvs_saved_before_epd_draw_and_flag_cleared);
RUN_TEST(test_fw16_304_with_draw_needed_redraws);
RUN_TEST(test_fw17a_sha256_mismatch_skips_draw_and_keeps_old_img_id);
RUN_TEST(test_fw17b_missing_sha256_header_skips_verification);
return UNITY_END();
}