Files
logos-protocol/tests/protocol/test_pending_sentinel.cpp
Dario LipicarandClaude Opus 5 c0df466172 fix: integer signedness in the codec, and a shape check on the pending-call sentinel (#31)
* fix(codec): signedness and range are part of the integer type

Codec<T>::from accepted any integral JSON number and handed it to .get<T>().
That is silent in both directions:

  .get<uint64_t>() on -1   -> 18446744073709551615   (a sign flip)
  .get<int32_t>()  on 2^40 -> truncated

Both now reject with the usual path-carrying CodecError instead. Rejecting is the
codec's existing contract — a value the declared type cannot represent must not
reach business logic wearing a different one — this just extends it to the half
of the integer domain it was skipping.

Note the check is on the JSON category, not the value: a negative literal parses
as number_integer and never as number_unsigned, so `is_number_unsigned()` is the
reliable discriminator rather than a comparison after conversion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(async): the pending-call sentinel is matched by shape, not by key presence

All four detection sites tested `m.contains(pendingCallKey())` and nothing else,
so ANY user map carrying that key was taken for a deferred call: the consumer
extracted a call id, found no completion, and waited out a nested event loop.
The measured outcome is a ~20s HANG, not a fast failure. An `any` slot is enough
to reach it — anything a user can put in a map.

logos::isPendingCallSentinel now requires the canonical shape: exactly one entry,
under the sentinel key, holding a non-empty string. Shape and signature are
mirrored from isUnauthorizedSentinel (logos_rpc_status.h), QJsonObject arm
included — the two are the same kind of in-band marker and there was no reason
for them to be guarded differently. That guard, and isTaggedBytes's, both already
existed in this repo; the difference was chronology, not principle.

Behaviour-preserving: the generated glue builds this map with exactly one entry
whose value is a QString call id, so no real sender changes. The concurrent
dispatch tests pass unchanged.

NARROWS, DOES NOT CLOSE — and the tests say so out loud. A one-key, string-valued
forgery IS the sentinel; no predicate can separate them. It still hangs, and
because call ids are a per-object counter from 0, a forged "lc-0" can collide
with a genuine in-flight completion and steal its result. Closing that needs an
out-of-band channel for "deferred", which the single-QVariant dispatch slot
cannot express without an ABI break — the constraint is stated at
logos_rpc_status.h:24-27 and is real.

tests: 10 new, including one asserting the forgery still matches, so a future
reader cannot mistake the green cells for "the sentinel is safe". 236/236.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 11:50:18 -03:00

133 lines
4.4 KiB
C++

#include <gtest/gtest.h>
#include <QJsonObject>
#include <QString>
#include <QVariant>
#include <QVariantList>
#include <QVariantMap>
#include "logos_async_dispatch.h"
// ---------------------------------------------------------------------------
// The pending-call sentinel is in-band: a "multi" provider returns a QVariantMap
// carrying the call id under __logos_pending_call__, because the dispatch slot
// returns a single QVariant and there is no out-of-band place to say "deferred"
// without an ABI break.
//
// In-band means user data can imitate it. The four detection sites used to test
// `m.contains(pendingCallKey())` and nothing else, so ANY map carrying that key
// was taken for a deferred call: the consumer waited in a nested event loop for
// a completion that never arrives and the call hung for the full timeout. An
// `any` slot is enough to reach it.
//
// These pin the shape check. They do NOT claim the sentinel is unforgeable — a
// forgery of the canonical shape is still indistinguishable, because that is the
// real thing. See known.json.
// ---------------------------------------------------------------------------
namespace {
QVariant canonical(const QString& id)
{
QVariantMap m;
m[logos::pendingCallKey()] = id;
return QVariant(m);
}
} // namespace
TEST(PendingSentinel, CanonicalShapeIsRecognisedAndYieldsTheCallId)
{
QString id;
ASSERT_TRUE(logos::isPendingCallSentinel(canonical("lc-7"), &id));
EXPECT_EQ(id, QStringLiteral("lc-7"));
}
TEST(PendingSentinel, CallIdOutParamIsOptional)
{
EXPECT_TRUE(logos::isPendingCallSentinel(canonical("lc-0")));
}
// The case the conformance matrix measured: user data that merely CONTAINS the
// key. Previously hijacked the call; must now pass through as an ordinary map.
TEST(PendingSentinel, ExtraKeysMeanItIsOrdinaryUserData)
{
QVariantMap m;
m[logos::pendingCallKey()] = QStringLiteral("lc-1");
m[QStringLiteral("x")] = 2;
EXPECT_FALSE(logos::isPendingCallSentinel(QVariant(m)));
}
// echoAny({"__logos_pending_call__": 1, "x": 2}) — the exact matrix payload.
TEST(PendingSentinel, MatrixPayloadIsNotASentinel)
{
QVariantMap m;
m[logos::pendingCallKey()] = 1;
m[QStringLiteral("x")] = 2;
EXPECT_FALSE(logos::isPendingCallSentinel(QVariant(m)));
}
// A call id is a string. A one-key map whose value is a number is user data.
TEST(PendingSentinel, NonStringValueIsNotASentinel)
{
QVariantMap m;
m[logos::pendingCallKey()] = 1;
EXPECT_FALSE(logos::isPendingCallSentinel(QVariant(m)));
}
TEST(PendingSentinel, EmptyCallIdIsNotASentinel)
{
EXPECT_FALSE(logos::isPendingCallSentinel(canonical(QString())));
}
TEST(PendingSentinel, WrongKeyIsNotASentinel)
{
QVariantMap m;
m[QStringLiteral("pending")] = QStringLiteral("lc-1");
EXPECT_FALSE(logos::isPendingCallSentinel(QVariant(m)));
}
TEST(PendingSentinel, NonMapValuesAreNotSentinels)
{
EXPECT_FALSE(logos::isPendingCallSentinel(QVariant()));
EXPECT_FALSE(logos::isPendingCallSentinel(QVariant(QStringLiteral("lc-1"))));
EXPECT_FALSE(logos::isPendingCallSentinel(QVariant(42)));
EXPECT_FALSE(logos::isPendingCallSentinel(QVariant(QVariantList{1, 2})));
EXPECT_FALSE(logos::isPendingCallSentinel(QVariant(QVariantMap{})));
}
// The QJsonObject arm mirrors isUnauthorizedSentinel's: some json_convert paths
// historically produced QJsonObject rather than QVariantMap.
TEST(PendingSentinel, QJsonObjectArmBehavesTheSame)
{
QJsonObject good;
good[logos::pendingCallKey()] = QStringLiteral("lc-2");
QString id;
ASSERT_TRUE(logos::isPendingCallSentinel(QVariant(good), &id));
EXPECT_EQ(id, QStringLiteral("lc-2"));
QJsonObject extra;
extra[logos::pendingCallKey()] = QStringLiteral("lc-2");
extra[QStringLiteral("x")] = 2;
EXPECT_FALSE(logos::isPendingCallSentinel(QVariant(extra)));
QJsonObject numeric;
numeric[logos::pendingCallKey()] = 1;
EXPECT_FALSE(logos::isPendingCallSentinel(QVariant(numeric)));
}
// What the guard does NOT do, pinned so nobody reads the green cells above as
// "the sentinel is safe". A canonical-shape forgery is the real thing.
TEST(PendingSentinel, CanonicalShapeForgeryIsStillIndistinguishable)
{
QVariantMap forged;
forged[logos::pendingCallKey()] = QStringLiteral("lc-0");
EXPECT_TRUE(logos::isPendingCallSentinel(QVariant(forged)))
<< "if this ever fails the in-band design changed — update known.json";
}