oatpp-authkit/test/test_queryable.cpp
Uwe Schuster 9976efe1de #16 (audit L-1..L-8): fix the low-severity findings
L-1 RequireRole: guard std::stoi on the bundle id — a non-numeric/out-of-range
    value now yields a clean 401 instead of an uncaught exception → 500.
    AuthPrincipal::id documented as numeric-only (carry UUIDs in username).
L-2 SmtpTransport: require TLS (CURLUSESSL_ALL) for non-loopback relays so a
    stripped STARTTLS can't downgrade credentials/body to cleartext; localhost
    relay stays opportunistic.
L-3 AuditLog: escapeJson now escapes all control chars (RFC 8259) so a newline
    in a field can't forge/corrupt the audit JSON; SKIP_FIELDS gains credential
    names (password/passwordHash/tlsCertDn/apiKey/token/secret) so secrets never
    land in changed_fields.
L-4 ws/Hub: consume the thread_local auth handoff once, up front, and clear it
    unconditionally — a stale value can't attach to a later connection on a
    reused worker thread.
L-5 TemporalRepository: default id generator draws 128 bits from the platform
    CSPRNG (std::random_device) per call instead of a once-seeded mt19937_64,
    so entity_ids aren't predictable from observed output.
L-6 AuthInterceptor: expired-session sweep is now a lock-free atomic timer and
    exception-isolated; documented that resolveBySessionHash() must enforce
    expiry at query time (the sweep is GC only).
L-7 new util/ConstantTime.hpp (constantTimeEquals) + TokenHasher doc requiring a
    >=256-bit cryptographic hash.
L-8 IQueryable: likeEscape + Field::likeContains/likePrefix emit
    `LIKE ? ESCAPE '\'` with %/_/\ escaped for untrusted terms; documented the
    compile-time identifier-source invariant.

Tests: new test_constant_time; likeEscape/likeContains/likePrefix cases added to
test_queryable. All 20 ctest targets pass. README + header docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:03:01 +02:00

243 lines
8.7 KiB
C++

// Tests for the oatpp-authkit#9 IQueryable<T> capability.
//
// Verifies that the AST emits the expected parameterised SQL and that the
// bind bag captures the values in order. No real database is involved —
// these tests exercise the SQL emitter and the builder API.
#include "oatpp-authkit/repo/IQueryable.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include <cstdio>
#include <string>
#include <variant>
#include <vector>
#include OATPP_CODEGEN_BEGIN(DTO)
namespace {
class MockQueryDto : public oatpp::DTO {
DTO_INIT(MockQueryDto, DTO)
DTO_FIELD(String, entity_id);
DTO_FIELD(String, name);
DTO_FIELD(String, email);
DTO_FIELD(Int64, age);
DTO_FIELD(Boolean, active);
};
#include OATPP_CODEGEN_END(DTO)
} // namespace
OATPP_AUTHKIT_REGISTER_TABLE(MockQueryDto, "mock_query")
OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, entity_id, "entity_id")
OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, name, "name")
OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, email, "email")
OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, age, "age")
OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, active, "active")
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
#define REQUIRE_EQ(a, b) do { \
auto _av = (a); auto _bv = (b); \
if (!(_av == _bv)) { \
std::fprintf(stderr, "FAIL %s:%d %s == %s ('%s' vs '%s')\n", \
__FILE__, __LINE__, #a, #b, \
std::string(_av).c_str(), std::string(_bv).c_str()); \
++g_failures; \
} \
} while (0)
using namespace oatpp_authkit::repo;
// Helper: pull a typed value out of a BindValue, or "<null>" / "<wrong>".
template <typename T>
std::string bindAsString(const BindValue& v) {
if (auto* p = std::get_if<T>(&v)) {
if constexpr (std::is_same_v<T, std::string>) return *p;
else return std::to_string(*p);
}
if (std::holds_alternative<std::monostate>(v)) return "<null>";
return "<wrong-type>";
}
void test_equality_emits_parameterised_sql() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::email>().eq("foo@bar"))
.toSql();
REQUIRE_EQ(sql.text,
std::string("SELECT * FROM mock_query WHERE email = ?"));
REQUIRE(sql.binds.size() == 1);
REQUIRE_EQ(bindAsString<std::string>(sql.binds[0]),
std::string("foo@bar"));
}
void test_and_or_combines_predicates() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::active>().eq(true)
&& (field<&MockQueryDto::age>().gt(18)
|| field<&MockQueryDto::age>().lt(5)))
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE "
"(active = ? AND (age > ? OR age < ?))"));
REQUIRE(sql.binds.size() == 3);
REQUIRE(std::get<bool>(sql.binds[0]) == true);
REQUIRE(std::get<std::int64_t>(sql.binds[1]) == 18);
REQUIRE(std::get<std::int64_t>(sql.binds[2]) == 5);
}
void test_repeated_where_implies_and() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::email>().eq("foo@bar"))
.where(field<&MockQueryDto::active>().eq(true))
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE "
"(email = ? AND active = ?)"));
}
void test_range_emits_inclusive_and_exclusive() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::age>().ge(18)
&& field<&MockQueryDto::age>().le(65))
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE (age >= ? AND age <= ?)"));
REQUIRE(std::get<std::int64_t>(sql.binds[0]) == 18);
REQUIRE(std::get<std::int64_t>(sql.binds[1]) == 65);
}
void test_in_with_multiple_values() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::email>().in({"a@x", "b@x", "c@x"}))
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE email IN (?, ?, ?)"));
REQUIRE(sql.binds.size() == 3);
REQUIRE_EQ(std::get<std::string>(sql.binds[0]), std::string("a@x"));
REQUIRE_EQ(std::get<std::string>(sql.binds[2]), std::string("c@x"));
}
void test_in_with_empty_list_is_always_false() {
std::vector<std::string> empty;
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::email>().in(empty))
.toSql();
REQUIRE_EQ(sql.text, std::string("SELECT * FROM mock_query WHERE 0"));
REQUIRE(sql.binds.empty());
}
void test_like_pattern_is_bound_not_interpolated() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::name>().like("Al%"))
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE name LIKE ?"));
REQUIRE_EQ(std::get<std::string>(sql.binds[0]), std::string("Al%"));
}
void test_like_contains_escapes_wildcards() {
// authkit#16 L-8: a user term with %/_/\ must be matched literally via an
// explicit ESCAPE clause, not treated as wildcards.
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::name>().likeContains("50%_off\\x"))
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE name LIKE ? ESCAPE '\\'"));
REQUIRE_EQ(std::get<std::string>(sql.binds[0]),
std::string("%50\\%\\_off\\\\x%"));
auto pfx = Query<MockQueryDto>()
.where(field<&MockQueryDto::name>().likePrefix("a_b"))
.toSql();
REQUIRE_EQ(pfx.text, std::string(
"SELECT * FROM mock_query WHERE name LIKE ? ESCAPE '\\'"));
REQUIRE_EQ(std::get<std::string>(pfx.binds[0]), std::string("a\\_b%"));
// The bare likeEscape helper.
REQUIRE_EQ(likeEscape("100%_\\"), std::string("100\\%\\_\\\\"));
REQUIRE_EQ(likeEscape("plain"), std::string("plain"));
}
void test_is_null_and_is_not_null() {
auto a = Query<MockQueryDto>()
.where(field<&MockQueryDto::email>().isNull())
.toSql();
REQUIRE_EQ(a.text, std::string(
"SELECT * FROM mock_query WHERE email IS NULL"));
REQUIRE(a.binds.empty());
auto b = Query<MockQueryDto>()
.where(field<&MockQueryDto::email>().isNotNull())
.toSql();
REQUIRE_EQ(b.text, std::string(
"SELECT * FROM mock_query WHERE email IS NOT NULL"));
}
void test_not_negates_predicate() {
auto sql = Query<MockQueryDto>()
.where(!field<&MockQueryDto::active>().eq(true))
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE NOT (active = ?)"));
}
void test_order_by_and_limit_offset() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::active>().eq(true))
.orderBy(field<&MockQueryDto::name>())
.orderByDesc(field<&MockQueryDto::age>())
.limit(50)
.offset(100)
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE active = ? "
"ORDER BY name ASC, age DESC LIMIT 50 OFFSET 100"));
REQUIRE(std::get<bool>(sql.binds[0]) == true);
}
void test_no_where_no_clauses_is_plain_select() {
auto sql = Query<MockQueryDto>().toSql();
REQUIRE_EQ(sql.text, std::string("SELECT * FROM mock_query"));
REQUIRE(sql.binds.empty());
}
void test_oatpp_string_value_is_unwrapped() {
auto sql = Query<MockQueryDto>()
.where(field<&MockQueryDto::email>().eq(oatpp::String("z@x")))
.toSql();
REQUIRE_EQ(std::get<std::string>(sql.binds[0]), std::string("z@x"));
}
} // namespace
int main() {
test_equality_emits_parameterised_sql();
test_and_or_combines_predicates();
test_repeated_where_implies_and();
test_range_emits_inclusive_and_exclusive();
test_in_with_multiple_values();
test_in_with_empty_list_is_always_false();
test_like_pattern_is_bound_not_interpolated();
test_like_contains_escapes_wildcards();
test_is_null_and_is_not_null();
test_not_negates_predicate();
test_order_by_and_limit_offset();
test_no_where_no_clauses_is_plain_select();
test_oatpp_string_value_is_unwrapped();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}