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>
243 lines
8.7 KiB
C++
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;
|
|
}
|