@@ -316,6 +316,37 @@ class RecordingQrResponseDisplayIo : public nsealr::QrResponseDisplayIo {
316316 std::vector<nsealr::QrResponseDisplayFrame> frames;
317317};
318318
319+ class RecordingSessionSourceBackupIo : public nsealr ::SessionSourceBackupIo {
320+ public:
321+ explicit RecordingSessionSourceBackupIo (std::vector<nsealr::ReviewButton> buttons)
322+ : buttons_(std::move(buttons)) {}
323+
324+ void show_backup_review_frame (const nsealr::ReviewDisplayFrame& frame) override {
325+ frames.push_back (frame);
326+ events.push_back (" frame:" + frame.title );
327+ }
328+
329+ nsealr::ReviewButton read_backup_review_button () override {
330+ assert (!buttons_.empty ());
331+ const nsealr::ReviewButton button = buttons_.front ();
332+ buttons_.erase (buttons_.begin ());
333+ events.push_back (" button" );
334+ return button;
335+ }
336+
337+ void emit_backup_payload (const nsealr::SessionSourceBackupPayload& payload) override {
338+ payloads.push_back (payload);
339+ events.push_back (" payload" );
340+ }
341+
342+ std::vector<nsealr::ReviewDisplayFrame> frames;
343+ std::vector<nsealr::SessionSourceBackupPayload> payloads;
344+ std::vector<std::string> events;
345+
346+ private:
347+ std::vector<nsealr::ReviewButton> buttons_;
348+ };
349+
319350class NextOnlyQrReviewIo : public nsealr ::QrReviewIo {
320351public:
321352 std::string scan_request_qr () override {
@@ -1081,6 +1112,65 @@ void test_session_source_backup_flow_reveals_only_after_local_approval() {
10811112 });
10821113}
10831114
1115+ void test_session_source_backup_io_reveals_only_after_displayed_approval () {
1116+ nsealr::StatelessSessionKeyring keyring;
1117+ keyring.add_nsec (" nsec test vector" , nsealr::decode_nsec_secret_key (nsealr::test_vectors::kNip19NsecTestKey1 ));
1118+ RecordingSessionSourceBackupIo io{{nsealr::ReviewButton::Next, nsealr::ReviewButton::Approve}};
1119+
1120+ const nsealr::SessionSourceBackupFlowResult result =
1121+ nsealr::run_session_source_backup_io_flow (keyring.source_at (0 ), io);
1122+
1123+ assert (result.approved );
1124+ assert (result.revealed );
1125+ assert (result.backup_payload .has_value ());
1126+ assert (result.backup_payload ->nsec == std::string (nsealr::test_vectors::kNip19NsecTestKey1 ));
1127+ assert (io.payloads .size () == 1U );
1128+ assert (io.payloads [0 ].nsec == std::string (nsealr::test_vectors::kNip19NsecTestKey1 ));
1129+ assert (io.frames .size () == 2U );
1130+ assert (io.frames [0 ].title == " Backup source" );
1131+ assert (io.frames [0 ].page_indicator == " Page 1/2" );
1132+ assert (lines_contain (io.frames [0 ].body_lines , " Danger: secret export" ));
1133+ assert (!lines_contain (io.frames [0 ].body_lines , nsealr::test_vectors::kNip19NsecTestKey1 ));
1134+ assert (!lines_contain (io.frames [0 ].body_lines , nsealr::test_vectors::kNip19NsecTestKey1SecretKey ));
1135+ assert (io.frames [1 ].title == " Show secret?" );
1136+ assert (io.frames [1 ].page_indicator == " Page 2/2" );
1137+ assert ((io.events == std::vector<std::string>{
1138+ " frame:Backup source" ,
1139+ " button" ,
1140+ " frame:Show secret?" ,
1141+ " button" ,
1142+ " payload" ,
1143+ }));
1144+ }
1145+
1146+ void test_session_source_backup_io_rejection_or_timeout_does_not_emit_payload () {
1147+ nsealr::StatelessSessionKeyring keyring;
1148+ keyring.add_nsec (" nsec test vector" , nsealr::decode_nsec_secret_key (nsealr::test_vectors::kNip19NsecTestKey1 ));
1149+
1150+ RecordingSessionSourceBackupIo rejected_io{{nsealr::ReviewButton::Reject}};
1151+ const nsealr::SessionSourceBackupFlowResult rejected =
1152+ nsealr::run_session_source_backup_io_flow (keyring.source_at (0 ), rejected_io);
1153+ assert (!rejected.approved );
1154+ assert (!rejected.revealed );
1155+ assert (!rejected.backup_payload .has_value ());
1156+ assert (rejected_io.payloads .empty ());
1157+ assert (rejected_io.frames .size () == 1U );
1158+
1159+ RecordingSessionSourceBackupIo timeout_io{{nsealr::ReviewButton::Next, nsealr::ReviewButton::Back}};
1160+ expect_throw (" max button steps" , [&] {
1161+ (void )nsealr::run_session_source_backup_io_flow (keyring.source_at (0 ), timeout_io, {}, 1U );
1162+ });
1163+ assert (timeout_io.payloads .empty ());
1164+ assert (timeout_io.frames .size () == 1U );
1165+
1166+ RecordingSessionSourceBackupIo early_approval_io{{nsealr::ReviewButton::Approve}};
1167+ expect_throw (" approval requires viewing every review page" , [&] {
1168+ (void )nsealr::run_session_source_backup_io_flow (keyring.source_at (0 ), early_approval_io);
1169+ });
1170+ assert (early_approval_io.payloads .empty ());
1171+ assert (early_approval_io.frames .size () == 1U );
1172+ }
1173+
10841174void test_policy_change_review_matches_shared_vector () {
10851175 const nsealr::test_vectors::PolicyChangeReviewVector vector =
10861176 policy_change_review_vector_by_name (" esp32-usb-enable-kind-1-automation" );
@@ -3319,6 +3409,8 @@ int main() {
33193409 test_session_source_backup_review_matches_shared_danger_zone_vectors ();
33203410 test_session_source_backup_payload_matches_shared_secret_payloads ();
33213411 test_session_source_backup_flow_reveals_only_after_local_approval ();
3412+ test_session_source_backup_io_reveals_only_after_displayed_approval ();
3413+ test_session_source_backup_io_rejection_or_timeout_does_not_emit_payload ();
33223414 test_policy_change_review_matches_shared_vector ();
33233415 test_policy_change_review_flow_requires_device_approval ();
33243416 test_policy_change_review_rejects_companion_authority_or_secret_material ();
0 commit comments