mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-30 21:41:10 +00:00
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>
202 lines
7.7 KiB
C++
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);
|
|
}
|