Files
logos-protocol/tests/protocol/test_module_identity.cpp
Dario LipicarandClaude Opus 5 43cd059608 feat(proxy): answer name()/version() for a provider that does not (#61)
Module identity should be total: every module answers name() and version(),
whatever built it. A module generated through the LIDL frontend now has both in
its own dispatch, but that leaves the rest -- already-built .lgx packages, ui /
ui_qml plugins, any provider whose dispatch does not answer -- reporting
nothing.

Every provider already knows both, through the providerName() /
providerVersion() vtable slots LogosProviderObject has always had. ModuleProxy
answers from those, so those modules gain identity with no edit to any of them.

Two placement decisions do the work:

  * the dispatch fallback runs AFTER m_provider->callMethod. An invalid
    QVariant is that slot's "unknown method" answer, so a provider that DOES
    implement name() keeps its own result -- nothing existing changes
    behaviour. It is also gated on an empty argument list, so a module with its
    own name(which) reaches its dispatch exactly as before.
  * getPluginInterface() advertises the same two methods when the provider does
    not list them. Without this a module would ANSWER a method it claimed not
    to have: present to whoever already knew to ask, invisible to `lm` and to
    every untyped caller. Additive only -- an entry the provider already lists
    wins, keeping its description and parameters.

Identity is a method, not introspection, so it stays behind the auth gate. The
three getPlugin* calls are ungated on purpose (they precede the token
exchange); these are not.

This is the one place both transports converge -- the plain transport publishes
a ModuleProxy and reaches it through QMetaObject::invokeMethod -- so one change
covers qt_remote and plain alike.

475/475 tests pass, 6 new.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:50:26 -03:00

202 lines
7.7 KiB
C++

// Module identity at the proxy layer — name() and version() for every module.
//
// A module built through the LIDL frontend has these generated into its own
// dispatch and never reaches the code under test here. A LEGACY module derives
// no contract and has neither, yet its provider has always known both through
// the providerName()/providerVersion() vtable slots. ModuleProxy answers from
// those, which is what makes identity uniform across the fleet without editing
// a single legacy module.
//
// Two properties carry the whole design, and each has a test that fails if it
// is lost:
// * the fallback is AFTER dispatch, so a provider that answers for itself
// keeps its own result (no silent shadowing);
// * the dispatch answer and the introspection listing agree, so a module
// cannot answer a method it claims not to have.
#include <gtest/gtest.h>
#include "logos_provider_interface.h"
#include "module_proxy.h"
#include "token_manager.h"
#include <QCoreApplication>
#include <QJsonArray>
#include <QJsonObject>
#include <QString>
#include <QVariantList>
namespace {
QCoreApplication* ensureIdentityApp() {
static int argc = 0;
static char* argv[] = { nullptr };
if (!QCoreApplication::instance())
new QCoreApplication(argc, argv);
return QCoreApplication::instance();
}
// A legacy-shaped provider: it knows its identity (every provider does) but
// exposes no identity METHOD, which is exactly the fleet's 40-odd legacy
// modules.
class LegacyProvider : public LogosProviderObject {
public:
QVariant callMethod(const QString& method, const QVariantList& args) override {
++calls;
lastMethod = method;
lastArgs = args;
if (method == QLatin1String("work")) return QStringLiteral("worked");
return QVariant(); // this slot's "unknown method"
}
bool informModuleToken(const QString&, const QString&) override { return true; }
QJsonArray getMethods() override {
QJsonObject work;
work["name"] = QStringLiteral("work");
work["type"] = QStringLiteral("method");
return QJsonArray{ work };
}
void setEventListener(EventCallback) override {}
void init(void*) override {}
QString providerName() const override { return QStringLiteral("legacy_module"); }
QString providerVersion() const override { return QStringLiteral("3.2.1"); }
int calls = 0;
QString lastMethod;
QVariantList lastArgs;
};
// A provider that answers name() itself — the shape a generated (or
// hand-written) module has. logos-delivery-module really does this.
class SelfAnsweringProvider : public LegacyProvider {
public:
QVariant callMethod(const QString& method, const QVariantList& args) override {
if (method == QLatin1String("name")) return QStringLiteral("its-own-answer");
return LegacyProvider::callMethod(method, args);
}
QJsonArray getMethods() override {
QJsonObject name;
name["name"] = QStringLiteral("name");
name["type"] = QStringLiteral("method");
name["description"] = QStringLiteral("hand-written");
return QJsonArray{ name };
}
};
// A token this proxy has actually issued, so calls are authorized. Mirrors what
// capability_module's informModuleToken does for a (caller, target) pair.
QString authorize(ModuleProxy& proxy) {
const QString token = QStringLiteral("identity-test-token");
proxy.saveToken(QStringLiteral("some_caller"), token);
return token;
}
QStringList methodNames(const QJsonArray& iface) {
QStringList names;
for (const QJsonValue& v : iface)
if (v.isObject()) names << v.toObject().value("name").toString();
return names;
}
} // namespace
TEST(ModuleIdentity, ALegacyProviderAnswersNameAndVersion)
{
ensureIdentityApp();
LegacyProvider provider;
ModuleProxy proxy(&provider);
const QString token = authorize(proxy);
EXPECT_EQ(proxy.callRemoteMethod(token, QStringLiteral("name"), {}).toString(),
QStringLiteral("legacy_module"));
EXPECT_EQ(proxy.callRemoteMethod(token, QStringLiteral("version"), {}).toString(),
QStringLiteral("3.2.1"));
}
TEST(ModuleIdentity, TheProvidersOwnAnswerWins)
{
// The fallback runs only when dispatch returned "unknown method". Shadowing
// a provider that implements name() would silently change its behaviour.
ensureIdentityApp();
SelfAnsweringProvider provider;
ModuleProxy proxy(&provider);
const QString token = authorize(proxy);
EXPECT_EQ(proxy.callRemoteMethod(token, QStringLiteral("name"), {}).toString(),
QStringLiteral("its-own-answer"));
// ...and the one it does NOT implement still falls back.
EXPECT_EQ(proxy.callRemoteMethod(token, QStringLiteral("version"), {}).toString(),
QStringLiteral("3.2.1"));
}
TEST(ModuleIdentity, IntrospectionAndDispatchAgree)
{
// A module that answers a method it claims not to have is present to
// whoever already knew to ask and invisible to `lm` and every untyped
// caller. Whatever the listing says must be callable, and vice versa.
ensureIdentityApp();
LegacyProvider provider;
ModuleProxy proxy(&provider);
const QString token = authorize(proxy);
const QStringList listed = methodNames(proxy.getPluginInterface());
EXPECT_TRUE(listed.contains(QStringLiteral("name"))) << listed.join(", ").toStdString();
EXPECT_TRUE(listed.contains(QStringLiteral("version"))) << listed.join(", ").toStdString();
EXPECT_TRUE(listed.contains(QStringLiteral("work"))) << listed.join(", ").toStdString();
for (const QString& m : { QStringLiteral("name"), QStringLiteral("version") })
EXPECT_TRUE(proxy.callRemoteMethod(token, m, {}).isValid()) << m.toStdString();
}
TEST(ModuleIdentity, TheListingDoesNotDuplicateWhatTheProviderAlreadyLists)
{
// Additive only: a provider that lists name() keeps ITS entry, description
// and all. A duplicate would make `lm` show the method twice and leave
// consumers guessing which one is real.
ensureIdentityApp();
SelfAnsweringProvider provider;
ModuleProxy proxy(&provider);
const QJsonArray iface = proxy.getPluginInterface();
EXPECT_EQ(methodNames(iface).count(QStringLiteral("name")), 1);
for (const QJsonValue& v : iface) {
const QJsonObject o = v.toObject();
if (o.value("name").toString() == QLatin1String("name"))
EXPECT_EQ(o.value("description").toString(), QStringLiteral("hand-written"));
}
}
TEST(ModuleIdentity, ASameNamedMethodTakingArgumentsIsUntouched)
{
// The fallback is gated on an empty argument list, so a module with its own
// `name(which)` reaches its dispatch exactly as before — including when
// that call legitimately fails and returns an invalid QVariant.
ensureIdentityApp();
LegacyProvider provider;
ModuleProxy proxy(&provider);
const QString token = authorize(proxy);
const QVariant r = proxy.callRemoteMethod(
token, QStringLiteral("name"), QVariantList{ QStringLiteral("which") });
EXPECT_FALSE(r.isValid());
EXPECT_EQ(provider.lastMethod, QStringLiteral("name"));
EXPECT_EQ(provider.lastArgs.size(), 1);
}
TEST(ModuleIdentity, IdentityIsStillGatedOnAuthorization)
{
// Identity is a method, not introspection: it must sit behind the same auth
// gate as any other call. The three getPlugin* calls are ungated ON PURPOSE
// (they precede the token exchange); name()/version() are not.
ensureIdentityApp();
LegacyProvider provider;
ModuleProxy proxy(&provider);
authorize(proxy);
const QVariant r =
proxy.callRemoteMethod(QStringLiteral("not-a-token"), QStringLiteral("name"), {});
EXPECT_NE(r.toString(), QStringLiteral("legacy_module"));
EXPECT_EQ(provider.calls, 0);
}