@@ -423,3 +423,334 @@ TEST_F(HttpErrorResponseMakeErrorTest, MakeErrorEmptyDetail)
423423 EXPECT_EQ (err.message , " Bad Gateway" );
424424 EXPECT_TRUE (err.detail .empty ());
425425}
426+
427+ // ============================================================================
428+ // HttpErrorResponseBuildJsonTest
429+ // ============================================================================
430+
431+ class HttpErrorResponseBuildJsonTest : public ::testing::Test
432+ {
433+ protected:
434+ static auto has_substr (const std::string& haystack, const std::string& needle) -> bool
435+ {
436+ return haystack.find (needle) != std::string::npos;
437+ }
438+ };
439+
440+ TEST_F (HttpErrorResponseBuildJsonTest, StatusCodeAndMessagePopulated)
441+ {
442+ auto err = http_error_response::make_error (http_error_code::not_found, " missing" );
443+ auto response = http_error_response::build_json_error (err);
444+
445+ EXPECT_EQ (response.status_code , 404 );
446+ EXPECT_EQ (response.status_message , " Not Found" );
447+ }
448+
449+ TEST_F (HttpErrorResponseBuildJsonTest, ContentTypeHeaderIsProblemJson)
450+ {
451+ auto err = http_error_response::make_error (http_error_code::internal_server_error);
452+ auto response = http_error_response::build_json_error (err);
453+
454+ auto content_type = response.get_header (" Content-Type" );
455+ ASSERT_TRUE (content_type.has_value ());
456+ EXPECT_EQ (*content_type, " application/problem+json; charset=utf-8" );
457+ }
458+
459+ TEST_F (HttpErrorResponseBuildJsonTest, BodyContainsRfc7807RequiredFields)
460+ {
461+ auto err = http_error_response::make_error (http_error_code::bad_request, " invalid syntax" );
462+ auto body = http_error_response::build_json_error (err).get_body_string ();
463+
464+ EXPECT_TRUE (has_substr (body, " \" type\" : \" about:blank\" " ));
465+ EXPECT_TRUE (has_substr (body, " \" title\" : \" Bad Request\" " ));
466+ EXPECT_TRUE (has_substr (body, " \" status\" : 400" ));
467+ EXPECT_TRUE (has_substr (body, " \" detail\" : \" invalid syntax\" " ));
468+ }
469+
470+ TEST_F (HttpErrorResponseBuildJsonTest, DetailFallsBackToMessageWhenEmpty)
471+ {
472+ http_error err;
473+ err.code = http_error_code::forbidden;
474+ err.message = " access denied" ;
475+ // detail intentionally left empty
476+
477+ auto body = http_error_response::build_json_error (err).get_body_string ();
478+ EXPECT_TRUE (has_substr (body, " \" detail\" : \" access denied\" " ));
479+ }
480+
481+ TEST_F (HttpErrorResponseBuildJsonTest, RequestIdOmittedWhenEmpty)
482+ {
483+ auto err = http_error_response::make_error (http_error_code::not_found, " missing" );
484+ auto body = http_error_response::build_json_error (err).get_body_string ();
485+
486+ EXPECT_FALSE (has_substr (body, " \" instance\" " ));
487+ }
488+
489+ TEST_F (HttpErrorResponseBuildJsonTest, RequestIdIncludedAsInstance)
490+ {
491+ auto err = http_error_response::make_error (http_error_code::not_found, " missing" , " req-42" );
492+ auto body = http_error_response::build_json_error (err).get_body_string ();
493+
494+ EXPECT_TRUE (has_substr (body, " \" instance\" : \" req-42\" " ));
495+ }
496+
497+ TEST_F (HttpErrorResponseBuildJsonTest, BodyContainsIso8601Timestamp)
498+ {
499+ auto err = http_error_response::make_error (http_error_code::service_unavailable, " retry" );
500+ auto body = http_error_response::build_json_error (err).get_body_string ();
501+
502+ // ISO 8601 UTC format: YYYY-MM-DDTHH:MM:SSZ
503+ EXPECT_TRUE (has_substr (body, " \" timestamp\" : \" " ));
504+ EXPECT_TRUE (has_substr (body, " T" ));
505+ EXPECT_TRUE (has_substr (body, " Z\" " ));
506+ }
507+
508+ TEST_F (HttpErrorResponseBuildJsonTest, JsonEscapesDoubleQuote)
509+ {
510+ http_error err;
511+ err.code = http_error_code::bad_request;
512+ err.message = " m" ;
513+ err.detail = " a \" quoted\" value" ;
514+
515+ auto body = http_error_response::build_json_error (err).get_body_string ();
516+ EXPECT_TRUE (has_substr (body, " a \\\" quoted\\\" value" ));
517+ }
518+
519+ TEST_F (HttpErrorResponseBuildJsonTest, JsonEscapesBackslash)
520+ {
521+ http_error err;
522+ err.code = http_error_code::bad_request;
523+ err.message = " m" ;
524+ err.detail = " a\\ b" ;
525+
526+ auto body = http_error_response::build_json_error (err).get_body_string ();
527+ EXPECT_TRUE (has_substr (body, " a\\\\ b" ));
528+ }
529+
530+ TEST_F (HttpErrorResponseBuildJsonTest, JsonEscapesWhitespaceControlChars)
531+ {
532+ http_error err;
533+ err.code = http_error_code::bad_request;
534+ err.message = " m" ;
535+ err.detail = std::string (" nl\n rs\r tb\t " );
536+
537+ auto body = http_error_response::build_json_error (err).get_body_string ();
538+ EXPECT_TRUE (has_substr (body, " nl\\ n" ));
539+ EXPECT_TRUE (has_substr (body, " rs\\ r" ));
540+ EXPECT_TRUE (has_substr (body, " tb\\ t" ));
541+ }
542+
543+ TEST_F (HttpErrorResponseBuildJsonTest, JsonEscapesBackspaceAndFormFeed)
544+ {
545+ http_error err;
546+ err.code = http_error_code::bad_request;
547+ err.message = " m" ;
548+ err.detail = std::string (" b\b f\f " );
549+
550+ auto body = http_error_response::build_json_error (err).get_body_string ();
551+ EXPECT_TRUE (has_substr (body, " b\\ b" ));
552+ EXPECT_TRUE (has_substr (body, " f\\ f" ));
553+ }
554+
555+ TEST_F (HttpErrorResponseBuildJsonTest, JsonEscapesLowControlCharactersAsUnicode)
556+ {
557+ http_error err;
558+ err.code = http_error_code::bad_request;
559+ err.message = " m" ;
560+ // 0x01 is a low control char without a dedicated escape
561+ err.detail = std::string (" x\x01 y" );
562+
563+ auto body = http_error_response::build_json_error (err).get_body_string ();
564+ EXPECT_TRUE (has_substr (body, " x\\ u0001y" ));
565+ }
566+
567+ TEST_F (HttpErrorResponseBuildJsonTest, JsonLeavesPrintableAsciiUnchanged)
568+ {
569+ http_error err;
570+ err.code = http_error_code::bad_request;
571+ err.message = " m" ;
572+ err.detail = " hello world 123" ;
573+
574+ auto body = http_error_response::build_json_error (err).get_body_string ();
575+ EXPECT_TRUE (has_substr (body, " \" detail\" : \" hello world 123\" " ));
576+ }
577+
578+ TEST_F (HttpErrorResponseBuildJsonTest, JsonEscapesAppliedToRequestId)
579+ {
580+ auto err = http_error_response::make_error (http_error_code::not_found, " d" , " req\" id" );
581+ auto body = http_error_response::build_json_error (err).get_body_string ();
582+ EXPECT_TRUE (has_substr (body, " \" instance\" : \" req\\\" id\" " ));
583+ }
584+
585+ // ============================================================================
586+ // HttpErrorResponseBuildHtmlTest
587+ // ============================================================================
588+
589+ class HttpErrorResponseBuildHtmlTest : public ::testing::Test
590+ {
591+ protected:
592+ static auto has_substr (const std::string& haystack, const std::string& needle) -> bool
593+ {
594+ return haystack.find (needle) != std::string::npos;
595+ }
596+ };
597+
598+ TEST_F (HttpErrorResponseBuildHtmlTest, StatusCodeAndMessagePopulated)
599+ {
600+ auto err = http_error_response::make_error (http_error_code::not_found, " missing" );
601+ auto response = http_error_response::build_html_error (err);
602+
603+ EXPECT_EQ (response.status_code , 404 );
604+ EXPECT_EQ (response.status_message , " Not Found" );
605+ }
606+
607+ TEST_F (HttpErrorResponseBuildHtmlTest, ContentTypeHeaderIsTextHtml)
608+ {
609+ auto err = http_error_response::make_error (http_error_code::internal_server_error);
610+ auto response = http_error_response::build_html_error (err);
611+
612+ auto content_type = response.get_header (" Content-Type" );
613+ ASSERT_TRUE (content_type.has_value ());
614+ EXPECT_EQ (*content_type, " text/html; charset=utf-8" );
615+ }
616+
617+ TEST_F (HttpErrorResponseBuildHtmlTest, TitleAndHeadingContainStatus)
618+ {
619+ auto err = http_error_response::make_error (http_error_code::service_unavailable);
620+ auto body = http_error_response::build_html_error (err).get_body_string ();
621+
622+ EXPECT_TRUE (has_substr (body, " <title>503 Service Unavailable</title>" ));
623+ EXPECT_TRUE (has_substr (body, " <h1>503 Service Unavailable</h1>" ));
624+ }
625+
626+ TEST_F (HttpErrorResponseBuildHtmlTest, MessageParagraphAppearsWhenMessageSet)
627+ {
628+ http_error err;
629+ err.code = http_error_code::not_found;
630+ err.message = " resource missing" ;
631+
632+ auto body = http_error_response::build_html_error (err).get_body_string ();
633+ EXPECT_TRUE (has_substr (body, " <p>resource missing</p>" ));
634+ }
635+
636+ TEST_F (HttpErrorResponseBuildHtmlTest, MessageParagraphOmittedWhenMessageEmpty)
637+ {
638+ http_error err;
639+ err.code = http_error_code::not_found;
640+ // message intentionally empty
641+
642+ auto body = http_error_response::build_html_error (err).get_body_string ();
643+ // Body contains the detail block's <p> only when detail is set — with both empty,
644+ // no message-derived <p> element should precede the meta block
645+ EXPECT_FALSE (has_substr (body, " <p></p>" ));
646+ }
647+
648+ TEST_F (HttpErrorResponseBuildHtmlTest, DetailBlockAppearsWhenDetailSet)
649+ {
650+ http_error err;
651+ err.code = http_error_code::bad_request;
652+ err.detail = " malformed syntax" ;
653+
654+ auto body = http_error_response::build_html_error (err).get_body_string ();
655+ EXPECT_TRUE (has_substr (body, " class=\" detail\" " ));
656+ EXPECT_TRUE (has_substr (body, " <strong>Details:</strong> malformed syntax" ));
657+ }
658+
659+ TEST_F (HttpErrorResponseBuildHtmlTest, DetailBlockOmittedWhenDetailEmpty)
660+ {
661+ http_error err;
662+ err.code = http_error_code::bad_request;
663+ err.message = " bad" ;
664+
665+ auto body = http_error_response::build_html_error (err).get_body_string ();
666+ EXPECT_FALSE (has_substr (body, " class=\" detail\" " ));
667+ }
668+
669+ TEST_F (HttpErrorResponseBuildHtmlTest, RequestIdAppearsWhenSet)
670+ {
671+ auto err = http_error_response::make_error (http_error_code::gateway_timeout, " slow" , " req-7" );
672+ auto body = http_error_response::build_html_error (err).get_body_string ();
673+
674+ EXPECT_TRUE (has_substr (body, " Request ID: req-7" ));
675+ }
676+
677+ TEST_F (HttpErrorResponseBuildHtmlTest, RequestIdOmittedWhenEmpty)
678+ {
679+ auto err = http_error_response::make_error (http_error_code::gateway_timeout);
680+ auto body = http_error_response::build_html_error (err).get_body_string ();
681+
682+ EXPECT_FALSE (has_substr (body, " Request ID:" ));
683+ }
684+
685+ TEST_F (HttpErrorResponseBuildHtmlTest, HtmlEscapesAmpersand)
686+ {
687+ http_error err;
688+ err.code = http_error_code::bad_request;
689+ err.message = " a & b" ;
690+
691+ auto body = http_error_response::build_html_error (err).get_body_string ();
692+ EXPECT_TRUE (has_substr (body, " a & b" ));
693+ }
694+
695+ TEST_F (HttpErrorResponseBuildHtmlTest, HtmlEscapesLessThanAndGreaterThan)
696+ {
697+ http_error err;
698+ err.code = http_error_code::bad_request;
699+ err.detail = " <script>alert('x')</script>" ;
700+
701+ auto body = http_error_response::build_html_error (err).get_body_string ();
702+ EXPECT_TRUE (has_substr (body, " <script>" ));
703+ EXPECT_TRUE (has_substr (body, " </script>" ));
704+ // Raw <script> must not appear in the rendered HTML body
705+ EXPECT_FALSE (has_substr (body, " <script>alert" ));
706+ }
707+
708+ TEST_F (HttpErrorResponseBuildHtmlTest, HtmlEscapesDoubleQuote)
709+ {
710+ http_error err;
711+ err.code = http_error_code::bad_request;
712+ err.message = " key=\" value\" " ;
713+
714+ auto body = http_error_response::build_html_error (err).get_body_string ();
715+ EXPECT_TRUE (has_substr (body, " key="value"" ));
716+ }
717+
718+ TEST_F (HttpErrorResponseBuildHtmlTest, HtmlEscapesSingleQuote)
719+ {
720+ http_error err;
721+ err.code = http_error_code::bad_request;
722+ err.detail = " it's here" ;
723+
724+ auto body = http_error_response::build_html_error (err).get_body_string ();
725+ EXPECT_TRUE (has_substr (body, " it's here" ));
726+ }
727+
728+ TEST_F (HttpErrorResponseBuildHtmlTest, HtmlEscapesAppliedToStatusMessage)
729+ {
730+ // status_message comes from get_error_status_text which is a fixed mapping,
731+ // but "I'm a teapot" contains an apostrophe and must be HTML-escaped
732+ auto err = http_error_response::make_error (http_error_code::im_a_teapot);
733+ auto body = http_error_response::build_html_error (err).get_body_string ();
734+
735+ EXPECT_TRUE (has_substr (body, " <title>418 I'm a teapot</title>" ));
736+ EXPECT_TRUE (has_substr (body, " <h1>418 I'm a teapot</h1>" ));
737+ }
738+
739+ TEST_F (HttpErrorResponseBuildHtmlTest, HtmlEscapesAppliedToRequestId)
740+ {
741+ auto err = http_error_response::make_error (
742+ http_error_code::internal_server_error, " boom" , " req<tag>" );
743+ auto body = http_error_response::build_html_error (err).get_body_string ();
744+
745+ EXPECT_TRUE (has_substr (body, " Request ID: req<tag>" ));
746+ }
747+
748+ TEST_F (HttpErrorResponseBuildHtmlTest, LeavesPrintableAsciiUnchanged)
749+ {
750+ http_error err;
751+ err.code = http_error_code::bad_request;
752+ err.message = " plain text 123" ;
753+
754+ auto body = http_error_response::build_html_error (err).get_body_string ();
755+ EXPECT_TRUE (has_substr (body, " <p>plain text 123</p>" ));
756+ }
0 commit comments