blob: befd37b55026bf744a77dd519ede7a1d204c8964 [file]
/** @file
Catch-based tests for HdrsUtils.cc
@section license License
Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements.
See the NOTICE file distributed with this work for additional information regarding copyright
ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance with the License. You may obtain a
copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License
is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
or implied. See the License for the specific language governing permissions and limitations under
the License.
*/
#include <string>
#include <cstring>
#include <cctype>
#include <bitset>
#include <initializer_list>
#include <new>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/generators/catch_generators_range.hpp>
#include "proxy/hdrs/HdrHeap.h"
#include "proxy/hdrs/MIME.h"
#include "proxy/hdrs/HdrUtils.h"
// Parameterized test for HdrCsvIter parsing.
TEST_CASE("HdrCsvIter", "[proxy][hdrutils]")
{
constexpr bool COMBINE_DUPLICATES = true;
// Structure for parameterized HdrCsvIter tests.
struct CsvIterTestCase {
const char *description;
const char *header_text;
const char *field_name;
std::vector<std::string_view> expected_values;
bool combine_dups; // Parameter for get_first()
};
// Test cases for HdrCsvIter parsing.
// clang-format off
static const std::vector<CsvIterTestCase> csv_iter_test_cases = {
// Basic CSV parsing tests
{"single value",
"One: alpha\r\n\r\n",
"One",
{"alpha"},
COMBINE_DUPLICATES},
{"two values",
"Two: alpha, bravo\r\n\r\n",
"Two",
{"alpha", "bravo"},
COMBINE_DUPLICATES},
{"quoted values and escaping",
"Three: zwoop, \"A,B\" , , phil , \"unterminated\r\n\r\n",
"Three",
{"zwoop", "A,B", "phil", "unterminated"},
COMBINE_DUPLICATES},
{"escaped quotes passed through",
"Four: itchi, \"ni, \\\"san\" , \"\" , \"\r\n\r\n",
"Four",
{"itchi", "ni, \\\"san"},
COMBINE_DUPLICATES},
{"duplicate fields combined",
"Five: alpha, bravo, charlie\r\nFive: delta, echo\r\n\r\n",
"Five",
{"alpha", "bravo", "charlie", "delta", "echo"},
COMBINE_DUPLICATES},
{"duplicate fields not combined",
"Five: alpha, bravo, charlie\r\nFive: delta, echo\r\n\r\n",
"Five",
{"alpha", "bravo", "charlie"},
!COMBINE_DUPLICATES},
// Cache-Control specific tests
{"Cache-Control: basic max-age and public",
"Cache-Control: max-age=30, public\r\n\r\n",
"Cache-Control",
{"max-age=30", "public"},
COMBINE_DUPLICATES},
{"Cache-Control: extension directives with values",
"Cache-Control: stale-if-error=1, stale-while-revalidate=60, no-cache\r\n\r\n",
"Cache-Control",
{"stale-if-error=1", "stale-while-revalidate=60", "no-cache"},
COMBINE_DUPLICATES},
{"Cache-Control: mixed directives",
"Cache-Control: public, max-age=300, s-maxage=600\r\n\r\n",
"Cache-Control",
{"public", "max-age=300", "s-maxage=600"},
COMBINE_DUPLICATES},
{"Cache-Control: semicolon separator treated as single value",
"Cache-Control: public; max-age=30\r\n\r\n",
"Cache-Control",
{"public; max-age=30"},
COMBINE_DUPLICATES},
{"Cache-Control: empty value",
"Cache-Control: \r\n\r\n",
"Cache-Control",
{},
COMBINE_DUPLICATES},
};
// clang-format on
auto test_case = GENERATE(from_range(csv_iter_test_cases));
CAPTURE(test_case.description, test_case.header_text);
HdrHeap *heap = new_HdrHeap(HdrHeap::DEFAULT_SIZE + 64);
MIMEParser parser;
char const *real_s = test_case.header_text;
char const *real_e = test_case.header_text + strlen(test_case.header_text);
MIMEHdr mime;
mime.create(heap);
mime_parser_init(&parser);
auto result = mime_parser_parse(&parser, heap, mime.m_mime, &real_s, real_e, false, true, false);
REQUIRE(ParseResult::DONE == result);
HdrCsvIter iter;
MIMEField *field = mime.field_find(test_case.field_name);
REQUIRE(field != nullptr);
if (test_case.expected_values.empty()) {
auto value = iter.get_first(field, test_case.combine_dups);
REQUIRE(value.empty());
} else {
auto value = iter.get_first(field, test_case.combine_dups);
REQUIRE(value == test_case.expected_values[0]);
for (size_t i = 1; i < test_case.expected_values.size(); ++i) {
value = iter.get_next();
REQUIRE(value == test_case.expected_values[i]);
}
// After all expected values, the next should be empty.
value = iter.get_next();
REQUIRE(value.empty());
}
heap->destroy();
}
TEST_CASE("HdrUtils 2", "[proxy][hdrutils]")
{
// Test empty field.
static constexpr swoc::TextView text{"Host: example.one\r\n"
"Connection: keep-alive\r\n"
"Vary:\r\n"
"After: value\r\n"
"\r\n"};
static constexpr swoc::TextView connection_tag{"Connection"};
static constexpr swoc::TextView vary_tag{"Vary"};
static constexpr swoc::TextView after_tag{"After"};
char buff[text.size() + 1];
HdrHeap *heap = new_HdrHeap(HdrHeap::DEFAULT_SIZE + 64);
MIMEParser parser;
char const *real_s = text.data();
char const *real_e = text.data_end();
MIMEHdr mime;
mime.create(heap);
mime_parser_init(&parser);
auto result = mime_parser_parse(&parser, heap, mime.m_mime, &real_s, real_e, false, true, false);
REQUIRE(ParseResult::DONE == result);
MIMEField *field{mime.field_find(connection_tag)};
REQUIRE(mime_hdr_fields_count(mime.m_mime) == 4);
REQUIRE(field != nullptr);
field = mime.field_find(vary_tag);
REQUIRE(field != nullptr);
REQUIRE(field->m_len_value == 0);
field = mime.field_find(after_tag);
REQUIRE(field != nullptr);
int idx = 0;
int skip = 0;
auto parse = mime_hdr_print(mime.m_mime, buff, static_cast<int>(sizeof(buff)), &idx, &skip);
REQUIRE(parse != 0);
REQUIRE(idx == static_cast<int>(text.size()));
REQUIRE(0 == memcmp(swoc::TextView(buff, idx), text));
heap->destroy();
};
TEST_CASE("HdrUtils 3", "[proxy][hdrutils]")
{
// Test empty field.
static constexpr swoc::TextView text{"Host: example.one\r\n"
"Connection: keep-alive\r\n"
"Before: value\r\n"
"Vary: \r\n"
"\r\n"};
static constexpr swoc::TextView connection_tag{"Connection"};
static constexpr swoc::TextView vary_tag{"Vary"};
static constexpr swoc::TextView before_tag{"Before"};
char buff[text.size() + 1];
HdrHeap *heap = new_HdrHeap(HdrHeap::DEFAULT_SIZE + 64);
MIMEParser parser;
char const *real_s = text.data();
char const *real_e = text.data_end();
MIMEHdr mime;
mime.create(heap);
mime_parser_init(&parser);
auto result = mime_parser_parse(&parser, heap, mime.m_mime, &real_s, real_e, false, true, false);
REQUIRE(ParseResult::DONE == result);
MIMEField *field{mime.field_find(connection_tag)};
REQUIRE(mime_hdr_fields_count(mime.m_mime) == 4);
REQUIRE(field != nullptr);
field = mime.field_find(vary_tag);
REQUIRE(field != nullptr);
REQUIRE(field->m_len_value == 0);
field = mime.field_find(before_tag);
REQUIRE(field != nullptr);
int idx = 0;
int skip = 0;
auto parse = mime_hdr_print(mime.m_mime, buff, static_cast<int>(sizeof(buff)), &idx, &skip);
REQUIRE(parse != 0);
REQUIRE(idx == static_cast<int>(text.size()));
REQUIRE(0 == memcmp(swoc::TextView(buff, idx), text));
heap->destroy();
};
// Test that malformed Cache-Control directives are properly ignored during cooking.
// All malformed directives should result in mask == 0.
TEST_CASE("Cache-Control Malformed Cooking", "[proxy][hdrutils]")
{
struct MalformedCCTestCase {
const char *description;
const char *header_text;
};
// clang-format off
// These tests align with cache-tests.fyi/#cc-parse
static const std::vector<MalformedCCTestCase> malformed_cc_test_cases = {
// Separator issues
{"semicolon separator (should be comma)",
"Cache-Control: public; max-age=30\r\n\r\n"},
// Space around equals (cc-parse: max-age with space before/after =)
{"space before equals sign",
"Cache-Control: max-age =300\r\n\r\n"},
{"space after equals sign",
"Cache-Control: max-age= 300\r\n\r\n"},
{"space both before and after equals sign",
"Cache-Control: max-age = 300\r\n\r\n"},
// Quoted values (cc-parse: single-quoted max-age)
{"single quotes around value",
"Cache-Control: max-age='300'\r\n\r\n"},
{"double quotes around value",
"Cache-Control: max-age=\"300\"\r\n\r\n"},
// s-maxage variants
{"s-maxage with space before equals",
"Cache-Control: s-maxage =600\r\n\r\n"},
{"s-maxage with space after equals",
"Cache-Control: s-maxage= 600\r\n\r\n"},
// Invalid numeric values (cc-parse: decimal max-age)
{"decimal value in max-age (1.5)",
"Cache-Control: max-age=1.5\r\n\r\n"},
{"decimal value in max-age (3600.0)",
"Cache-Control: max-age=3600.0\r\n\r\n"},
{"decimal value starting with dot (.5)",
"Cache-Control: max-age=.5\r\n\r\n"},
{"decimal value in s-maxage",
"Cache-Control: s-maxage=1.5\r\n\r\n"},
// Leading and trailing alpha characters
{"leading alpha in max-age value",
"Cache-Control: max-age=a300\r\n\r\n"},
{"trailing alpha in max-age value",
"Cache-Control: max-age=300a\r\n\r\n"},
{"leading alpha in s-maxage value",
"Cache-Control: s-maxage=a600\r\n\r\n"},
{"trailing alpha in s-maxage value",
"Cache-Control: s-maxage=600a\r\n\r\n"},
// Empty and missing values
{"empty max-age value alone",
"Cache-Control: max-age=\r\n\r\n"},
};
// clang-format on
auto test_case = GENERATE(from_range(malformed_cc_test_cases));
CAPTURE(test_case.description, test_case.header_text);
HdrHeap *heap = new_HdrHeap(HdrHeap::DEFAULT_SIZE + 64);
MIMEParser parser;
char const *real_s = test_case.header_text;
char const *real_e = test_case.header_text + strlen(test_case.header_text);
MIMEHdr mime;
mime.create(heap);
mime_parser_init(&parser);
auto result = mime_parser_parse(&parser, heap, mime.m_mime, &real_s, real_e, false, true, false);
REQUIRE(ParseResult::DONE == result);
mime.m_mime->recompute_cooked_stuff();
// All malformed directives should result in mask == 0.
auto mask = mime.get_cooked_cc_mask();
REQUIRE(mask == 0);
heap->destroy();
}
// Test that properly formed Cache-Control directives are correctly cooked.
TEST_CASE("Cache-Control Valid Cooking", "[proxy][hdrutils]")
{
struct ValidCCTestCase {
const char *description;
const char *header_text;
uint32_t expected_mask;
int32_t expected_max_age;
int32_t expected_s_maxage;
int32_t expected_max_stale;
int32_t expected_min_fresh;
};
// Use 0 to indicate "don't care" for integer values (mask determines which are valid).
// clang-format off
static const std::vector<ValidCCTestCase> valid_cc_test_cases = {
// Basic directives without values
{"public only",
"Cache-Control: public\r\n\r\n",
MIME_COOKED_MASK_CC_PUBLIC,
0, 0, 0, 0},
{"private only",
"Cache-Control: private\r\n\r\n",
MIME_COOKED_MASK_CC_PRIVATE,
0, 0, 0, 0},
{"no-cache only",
"Cache-Control: no-cache\r\n\r\n",
MIME_COOKED_MASK_CC_NO_CACHE,
0, 0, 0, 0},
{"no-store only",
"Cache-Control: no-store\r\n\r\n",
MIME_COOKED_MASK_CC_NO_STORE,
0, 0, 0, 0},
{"no-transform only",
"Cache-Control: no-transform\r\n\r\n",
MIME_COOKED_MASK_CC_NO_TRANSFORM,
0, 0, 0, 0},
{"must-revalidate only",
"Cache-Control: must-revalidate\r\n\r\n",
MIME_COOKED_MASK_CC_MUST_REVALIDATE,
0, 0, 0, 0},
{"proxy-revalidate only",
"Cache-Control: proxy-revalidate\r\n\r\n",
MIME_COOKED_MASK_CC_PROXY_REVALIDATE,
0, 0, 0, 0},
{"only-if-cached only",
"Cache-Control: only-if-cached\r\n\r\n",
MIME_COOKED_MASK_CC_ONLY_IF_CACHED,
0, 0, 0, 0},
// Directives with values
{"max-age=0",
"Cache-Control: max-age=0\r\n\r\n",
MIME_COOKED_MASK_CC_MAX_AGE,
0, 0, 0, 0},
{"max-age=300",
"Cache-Control: max-age=300\r\n\r\n",
MIME_COOKED_MASK_CC_MAX_AGE,
300, 0, 0, 0},
{"max-age=86400",
"Cache-Control: max-age=86400\r\n\r\n",
MIME_COOKED_MASK_CC_MAX_AGE,
86400, 0, 0, 0},
{"s-maxage=600",
"Cache-Control: s-maxage=600\r\n\r\n",
MIME_COOKED_MASK_CC_S_MAXAGE,
0, 600, 0, 0},
{"max-stale=100",
"Cache-Control: max-stale=100\r\n\r\n",
MIME_COOKED_MASK_CC_MAX_STALE,
0, 0, 100, 0},
{"min-fresh=60",
"Cache-Control: min-fresh=60\r\n\r\n",
MIME_COOKED_MASK_CC_MIN_FRESH,
0, 0, 0, 60},
// Multiple directives
{"max-age and public",
"Cache-Control: max-age=300, public\r\n\r\n",
MIME_COOKED_MASK_CC_MAX_AGE | MIME_COOKED_MASK_CC_PUBLIC,
300, 0, 0, 0},
{"public and max-age (reversed order)",
"Cache-Control: public, max-age=300\r\n\r\n",
MIME_COOKED_MASK_CC_MAX_AGE | MIME_COOKED_MASK_CC_PUBLIC,
300, 0, 0, 0},
{"max-age and s-maxage",
"Cache-Control: max-age=300, s-maxage=600\r\n\r\n",
MIME_COOKED_MASK_CC_MAX_AGE | MIME_COOKED_MASK_CC_S_MAXAGE,
300, 600, 0, 0},
{"private and no-cache",
"Cache-Control: private, no-cache\r\n\r\n",
MIME_COOKED_MASK_CC_PRIVATE | MIME_COOKED_MASK_CC_NO_CACHE,
0, 0, 0, 0},
{"no-store and no-cache",
"Cache-Control: no-store, no-cache\r\n\r\n",
MIME_COOKED_MASK_CC_NO_STORE | MIME_COOKED_MASK_CC_NO_CACHE,
0, 0, 0, 0},
{"must-revalidate and proxy-revalidate",
"Cache-Control: must-revalidate, proxy-revalidate\r\n\r\n",
MIME_COOKED_MASK_CC_MUST_REVALIDATE | MIME_COOKED_MASK_CC_PROXY_REVALIDATE,
0, 0, 0, 0},
{"complex: public, max-age, s-maxage, must-revalidate",
"Cache-Control: public, max-age=300, s-maxage=600, must-revalidate\r\n\r\n",
MIME_COOKED_MASK_CC_PUBLIC | MIME_COOKED_MASK_CC_MAX_AGE |
MIME_COOKED_MASK_CC_S_MAXAGE | MIME_COOKED_MASK_CC_MUST_REVALIDATE,
300, 600, 0, 0},
{"all request directives: max-age, max-stale, min-fresh, no-cache, no-store, no-transform, only-if-cached",
"Cache-Control: max-age=100, max-stale=200, min-fresh=50, no-cache, no-store, no-transform, only-if-cached\r\n\r\n",
MIME_COOKED_MASK_CC_MAX_AGE | MIME_COOKED_MASK_CC_MAX_STALE | MIME_COOKED_MASK_CC_MIN_FRESH |
MIME_COOKED_MASK_CC_NO_CACHE | MIME_COOKED_MASK_CC_NO_STORE |
MIME_COOKED_MASK_CC_NO_TRANSFORM | MIME_COOKED_MASK_CC_ONLY_IF_CACHED,
100, 0, 200, 50},
// Edge cases - whitespace
{"extra whitespace around directive",
"Cache-Control: max-age=300 \r\n\r\n",
MIME_COOKED_MASK_CC_MAX_AGE,
300, 0, 0, 0},
{"extra whitespace between directives",
"Cache-Control: max-age=300 , public\r\n\r\n",
MIME_COOKED_MASK_CC_MAX_AGE | MIME_COOKED_MASK_CC_PUBLIC,
300, 0, 0, 0},
{"tab character in header value",
"Cache-Control:\tmax-age=300\r\n\r\n",
MIME_COOKED_MASK_CC_MAX_AGE,
300, 0, 0, 0},
// Edge cases - unknown directives
{"unknown directive ignored, known directive parsed",
"Cache-Control: unknown-directive, max-age=300\r\n\r\n",
MIME_COOKED_MASK_CC_MAX_AGE,
300, 0, 0, 0},
{"unknown directive with value ignored",
"Cache-Control: unknown=value, public\r\n\r\n",
MIME_COOKED_MASK_CC_PUBLIC,
0, 0, 0, 0},
// Edge cases - numeric values (cc-parse: 0000 max-age, large max-age)
{"max-age with leading zeros (cc-parse: 0000 max-age)",
"Cache-Control: max-age=0000\r\n\r\n",
MIME_COOKED_MASK_CC_MAX_AGE,
0, 0, 0, 0},
{"max-age with leading zeros and value",
"Cache-Control: max-age=00300\r\n\r\n",
MIME_COOKED_MASK_CC_MAX_AGE,
300, 0, 0, 0},
{"large max-age value",
"Cache-Control: max-age=999999999\r\n\r\n",
MIME_COOKED_MASK_CC_MAX_AGE,
999999999, 0, 0, 0},
// Edge cases - negative values should be parsed (behavior per implementation)
{"negative max-age value",
"Cache-Control: max-age=-1\r\n\r\n",
MIME_COOKED_MASK_CC_MAX_AGE,
-1, 0, 0, 0},
};
// clang-format on
auto test_case = GENERATE(from_range(valid_cc_test_cases));
CAPTURE(test_case.description, test_case.header_text);
HdrHeap *heap = new_HdrHeap(HdrHeap::DEFAULT_SIZE + 64);
MIMEParser parser;
char const *real_s = test_case.header_text;
char const *real_e = test_case.header_text + strlen(test_case.header_text);
MIMEHdr mime;
mime.create(heap);
mime_parser_init(&parser);
auto result = mime_parser_parse(&parser, heap, mime.m_mime, &real_s, real_e, false, true, false);
REQUIRE(ParseResult::DONE == result);
mime.m_mime->recompute_cooked_stuff();
auto mask = mime.get_cooked_cc_mask();
REQUIRE(mask == test_case.expected_mask);
if (test_case.expected_mask & MIME_COOKED_MASK_CC_MAX_AGE) {
REQUIRE(mime.get_cooked_cc_max_age() == test_case.expected_max_age);
}
if (test_case.expected_mask & MIME_COOKED_MASK_CC_S_MAXAGE) {
REQUIRE(mime.get_cooked_cc_s_maxage() == test_case.expected_s_maxage);
}
if (test_case.expected_mask & MIME_COOKED_MASK_CC_MAX_STALE) {
REQUIRE(mime.get_cooked_cc_max_stale() == test_case.expected_max_stale);
}
if (test_case.expected_mask & MIME_COOKED_MASK_CC_MIN_FRESH) {
REQUIRE(mime.get_cooked_cc_min_fresh() == test_case.expected_min_fresh);
}
heap->destroy();
}