Skip to content

Commit 6b9721f

Browse files
authored
test(http_error): cover build_json_error and build_html_error (#1004)
Add unit tests for http_error_response::build_json_error and build_html_error, which previously had zero coverage. New tests exercise: - RFC 7807 required fields and Content-Type header - Optional request_id / detail / message branches - ISO 8601 timestamp formatting - JSON escape for quote, backslash, newline, carriage return, tab, backspace, form feed, and low control characters - HTML escape for ampersand, angle brackets, double and single quotes - Escape coverage for status_message (im_a_teapot) and request_id Closes #1003 Part of #953
1 parent 190cb11 commit 6b9721f

1 file changed

Lines changed: 331 additions & 0 deletions

File tree

tests/test_network_config_http_error.cpp

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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\nrs\rtb\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\bf\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\x01y");
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 &amp; 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, "&lt;script&gt;"));
703+
EXPECT_TRUE(has_substr(body, "&lt;/script&gt;"));
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=&quot;value&quot;"));
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&#39;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&#39;m a teapot</title>"));
736+
EXPECT_TRUE(has_substr(body, "<h1>418 I&#39;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&lt;tag&gt;"));
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

Comments
 (0)