Files
orbithub/tests/test_profile_repository.cpp
T
ksmithandClaude Sonnet 5 ab4a34fc01 Fix mRemoteNG import failing on entries without a username
The previous username relaxation only touched ProfileDialog's own
save-time validation. ProfileRepository::isProfileValid() had the
identical "username required for SSH/RDP" check independently, called
directly by insertProfile()/updateProfile() -- exactly the path
mRemoteNG import uses, since it builds Profile objects and inserts
them directly rather than going through the dialog. Every imported
SSH/RDP entry without a recorded username was rejected outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 08:17:05 -06:00

382 lines
14 KiB
C++

#include "profile_repository.h"
#include <QTemporaryDir>
#include <QTest>
#include <memory>
namespace {
Profile makeSshProfile(const QString& name = QStringLiteral("Prod SSH Box"))
{
Profile profile;
profile.name = name;
profile.host = QStringLiteral("prod.example.com");
profile.port = 22;
profile.username = QStringLiteral("deploy");
profile.protocol = QStringLiteral("SSH");
profile.authMode = QStringLiteral("Password");
profile.tags = QStringLiteral("prod,linux");
return profile;
}
Profile makeRdpProfile(const QString& name = QStringLiteral("Windows RDP Box"))
{
Profile profile;
profile.name = name;
profile.host = QStringLiteral("win.example.com");
profile.port = 3389;
profile.username = QStringLiteral("admin");
profile.domain = QStringLiteral("CORP");
profile.protocol = QStringLiteral("RDP");
profile.rdpSecurityMode = QStringLiteral("NLA");
profile.rdpPerformanceProfile = QStringLiteral("Best Performance");
return profile;
}
}
class TestProfileRepository : public QObject
{
Q_OBJECT
private slots:
void init();
void cleanup();
void initializesCleanly();
void createAndGetSshProfile();
void createAndGetRdpProfile();
void createProfileRejectsMissingName();
void createProfileRejectsMissingHost();
void createProfileRejectsInvalidPort();
void createProfileAllowsMissingUsernameForSsh();
void createProfileRejectsMissingPrivateKeyForKeyAuth();
void createProfileRejectsDuplicateName();
void updateProfilePersistsChanges();
void deleteProfileRemovesIt();
void getProfileReturnsNulloptForUnknownId();
void listProfilesFiltersBySearchQuery();
void listProfilesSortsByRequestedOrder();
void tagsAreTrimmedDedupedAndJoined();
void emptyTagsRoundTripAsEmpty();
void folderCreateAndListRoundTrips();
void folderCreateIgnoresDuplicates();
void folderPathIsNormalized();
void deleteEmptyFolderRemovesIt();
void deleteFolderMovesDirectProfilesToParent();
void deleteRootLevelFolderMovesProfilesToRoot();
void deleteFolderShiftsSubfoldersAndTheirProfilesUp();
void deleteFolderRejectsEmptyPath();
void deleteNonexistentFolderSucceedsAsNoOp();
private:
std::unique_ptr<QTemporaryDir> m_tempDir;
std::unique_ptr<ProfileRepository> m_repo;
};
void TestProfileRepository::init()
{
m_tempDir = std::make_unique<QTemporaryDir>();
QVERIFY(m_tempDir->isValid());
m_repo = std::make_unique<ProfileRepository>(m_tempDir->filePath(QStringLiteral("test.sqlite")));
}
void TestProfileRepository::cleanup()
{
m_repo.reset();
m_tempDir.reset();
}
void TestProfileRepository::initializesCleanly()
{
QCOMPARE(m_repo->initError(), QString());
QCOMPARE(m_repo->listProfiles().size(), size_t(0));
QCOMPARE(m_repo->listFolders().size(), size_t(0));
}
void TestProfileRepository::createAndGetSshProfile()
{
const Profile input = makeSshProfile();
const std::optional<Profile> created = m_repo->createProfile(input);
QVERIFY(created.has_value());
QVERIFY(created->id > 0);
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
QCOMPARE(fetched->name, input.name);
QCOMPARE(fetched->host, input.host);
QCOMPARE(fetched->port, input.port);
QCOMPARE(fetched->username, input.username);
QCOMPARE(fetched->protocol, QStringLiteral("SSH"));
QCOMPARE(fetched->authMode, QStringLiteral("Password"));
QCOMPARE(fetched->tags, QStringLiteral("prod, linux"));
}
void TestProfileRepository::createAndGetRdpProfile()
{
const Profile input = makeRdpProfile();
const std::optional<Profile> created = m_repo->createProfile(input);
QVERIFY(created.has_value());
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
QCOMPARE(fetched->protocol, QStringLiteral("RDP"));
QCOMPARE(fetched->domain, QStringLiteral("CORP"));
QCOMPARE(fetched->rdpSecurityMode, QStringLiteral("NLA"));
QCOMPARE(fetched->rdpPerformanceProfile, QStringLiteral("Best Performance"));
QCOMPARE(fetched->port, 3389);
// Auth-mode/private-key fields are SSH-only and must not leak through
// for a non-SSH protocol.
QCOMPARE(fetched->authMode, QStringLiteral("Password"));
QCOMPARE(fetched->privateKeyPath, QString());
}
void TestProfileRepository::createProfileRejectsMissingName()
{
Profile profile = makeSshProfile();
profile.name.clear();
QVERIFY(!m_repo->createProfile(profile).has_value());
QVERIFY(!m_repo->lastError().isEmpty());
}
void TestProfileRepository::createProfileRejectsMissingHost()
{
Profile profile = makeSshProfile();
profile.host.clear();
QVERIFY(!m_repo->createProfile(profile).has_value());
QVERIFY(!m_repo->lastError().isEmpty());
}
void TestProfileRepository::createProfileRejectsInvalidPort()
{
Profile profile = makeSshProfile();
profile.port = 0;
QVERIFY(!m_repo->createProfile(profile).has_value());
profile.port = 70000;
QVERIFY(!m_repo->createProfile(profile).has_value());
}
void TestProfileRepository::createProfileAllowsMissingUsernameForSsh()
{
// SSH/RDP no longer require a username at save time (issue #21) --
// among other things, this unblocks importing mRemoteNG entries that
// don't have one recorded, which used to fail outright. The user is
// asked for it at connect time instead (see SessionTab).
Profile profile = makeSshProfile();
profile.username.clear();
const auto created = m_repo->createProfile(profile);
QVERIFY(created.has_value());
QVERIFY(created->username.isEmpty());
}
void TestProfileRepository::createProfileRejectsMissingPrivateKeyForKeyAuth()
{
Profile profile = makeSshProfile();
profile.authMode = QStringLiteral("Private Key");
profile.privateKeyPath.clear();
QVERIFY(!m_repo->createProfile(profile).has_value());
}
void TestProfileRepository::createProfileRejectsDuplicateName()
{
QVERIFY(m_repo->createProfile(makeSshProfile(QStringLiteral("Same Name"))).has_value());
QVERIFY(!m_repo->createProfile(makeSshProfile(QStringLiteral("Same Name"))).has_value());
}
void TestProfileRepository::updateProfilePersistsChanges()
{
const std::optional<Profile> created = m_repo->createProfile(makeSshProfile());
QVERIFY(created.has_value());
Profile updated = created.value();
updated.host = QStringLiteral("new-host.example.com");
updated.port = 2222;
updated.tags = QStringLiteral("updated");
QVERIFY(m_repo->updateProfile(updated));
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
QCOMPARE(fetched->host, QStringLiteral("new-host.example.com"));
QCOMPARE(fetched->port, 2222);
QCOMPARE(fetched->tags, QStringLiteral("updated"));
}
void TestProfileRepository::deleteProfileRemovesIt()
{
const std::optional<Profile> created = m_repo->createProfile(makeSshProfile());
QVERIFY(created.has_value());
QVERIFY(m_repo->deleteProfile(created->id));
QVERIFY(!m_repo->getProfile(created->id).has_value());
}
void TestProfileRepository::getProfileReturnsNulloptForUnknownId()
{
QVERIFY(!m_repo->getProfile(999999).has_value());
}
void TestProfileRepository::listProfilesFiltersBySearchQuery()
{
QVERIFY(m_repo->createProfile(makeSshProfile(QStringLiteral("Alpha"))).has_value());
QVERIFY(m_repo->createProfile(makeRdpProfile(QStringLiteral("Beta"))).has_value());
const auto byName = m_repo->listProfiles(QStringLiteral("Alpha"));
QCOMPARE(byName.size(), size_t(1));
QCOMPARE(byName[0].name, QStringLiteral("Alpha"));
const auto byHost = m_repo->listProfiles(QStringLiteral("win.example"));
QCOMPARE(byHost.size(), size_t(1));
QCOMPARE(byHost[0].name, QStringLiteral("Beta"));
const auto byTag = m_repo->listProfiles(QStringLiteral("linux"));
QCOMPARE(byTag.size(), size_t(1));
QCOMPARE(byTag[0].name, QStringLiteral("Alpha"));
QCOMPARE(m_repo->listProfiles(QStringLiteral("nonexistent")).size(), size_t(0));
}
void TestProfileRepository::listProfilesSortsByRequestedOrder()
{
QVERIFY(m_repo->createProfile(makeSshProfile(QStringLiteral("Zeta"))).has_value());
QVERIFY(m_repo->createProfile(makeRdpProfile(QStringLiteral("Alpha"))).has_value());
const auto byName = m_repo->listProfiles(QString(), ProfileSortOrder::NameAsc);
QCOMPARE(byName.size(), size_t(2));
QCOMPARE(byName[0].name, QStringLiteral("Alpha"));
QCOMPARE(byName[1].name, QStringLiteral("Zeta"));
const auto byProtocol = m_repo->listProfiles(QString(), ProfileSortOrder::ProtocolAsc);
QCOMPARE(byProtocol[0].protocol, QStringLiteral("RDP"));
QCOMPARE(byProtocol[1].protocol, QStringLiteral("SSH"));
}
void TestProfileRepository::tagsAreTrimmedDedupedAndJoined()
{
Profile profile = makeSshProfile();
profile.tags = QStringLiteral(" prod ,, Prod , linux ,linux");
const std::optional<Profile> created = m_repo->createProfile(profile);
QVERIFY(created.has_value());
// createProfile()'s return value echoes the input as-is; normalization
// only happens on the DB round trip, so re-fetch to observe it.
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
// Case-insensitive de-dup keeps the first-seen casing of each tag.
QCOMPARE(fetched->tags, QStringLiteral("prod, linux"));
}
void TestProfileRepository::emptyTagsRoundTripAsEmpty()
{
const std::optional<Profile> created = m_repo->createProfile(makeRdpProfile());
QVERIFY(created.has_value());
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
QCOMPARE(fetched->tags, QString());
}
void TestProfileRepository::folderCreateAndListRoundTrips()
{
QVERIFY(m_repo->createFolder(QStringLiteral("Work/Servers")));
const auto folders = m_repo->listFolders();
QCOMPARE(folders.size(), size_t(1));
QCOMPARE(folders[0], QStringLiteral("Work/Servers"));
}
void TestProfileRepository::folderCreateIgnoresDuplicates()
{
QVERIFY(m_repo->createFolder(QStringLiteral("Work")));
QVERIFY(m_repo->createFolder(QStringLiteral("Work")));
QCOMPARE(m_repo->listFolders().size(), size_t(1));
}
void TestProfileRepository::folderPathIsNormalized()
{
QVERIFY(m_repo->createFolder(QStringLiteral("\\Work\\\\Servers\\")));
const auto folders = m_repo->listFolders();
QCOMPARE(folders.size(), size_t(1));
QCOMPARE(folders[0], QStringLiteral("Work/Servers"));
}
void TestProfileRepository::deleteEmptyFolderRemovesIt()
{
QVERIFY(m_repo->createFolder(QStringLiteral("Empty")));
QVERIFY(m_repo->deleteFolder(QStringLiteral("Empty")));
QCOMPARE(m_repo->listFolders().size(), size_t(0));
}
void TestProfileRepository::deleteFolderMovesDirectProfilesToParent()
{
QVERIFY(m_repo->createFolder(QStringLiteral("Work")));
Profile profile = makeSshProfile();
profile.folderPath = QStringLiteral("Work");
const std::optional<Profile> created = m_repo->createProfile(profile);
QVERIFY(created.has_value());
QVERIFY(m_repo->deleteFolder(QStringLiteral("Work")));
QCOMPARE(m_repo->listFolders().size(), size_t(0));
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
// Deleting a folder is never destructive to profiles -- they shift up
// to take its place, here landing at the root since "Work" had no
// parent of its own.
QCOMPARE(fetched->folderPath, QString());
}
void TestProfileRepository::deleteRootLevelFolderMovesProfilesToRoot()
{
Profile profile = makeRdpProfile();
profile.folderPath = QStringLiteral("Solo");
const std::optional<Profile> created = m_repo->createProfile(profile);
QVERIFY(created.has_value());
QVERIFY(m_repo->deleteFolder(QStringLiteral("Solo")));
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
QCOMPARE(fetched->folderPath, QString());
}
void TestProfileRepository::deleteFolderShiftsSubfoldersAndTheirProfilesUp()
{
QVERIFY(m_repo->createFolder(QStringLiteral("Work/Servers")));
Profile inTarget = makeSshProfile(QStringLiteral("InWork"));
inTarget.folderPath = QStringLiteral("Work");
const std::optional<Profile> createdInTarget = m_repo->createProfile(inTarget);
QVERIFY(createdInTarget.has_value());
Profile inSubfolder = makeRdpProfile(QStringLiteral("InServers"));
inSubfolder.folderPath = QStringLiteral("Work/Servers");
const std::optional<Profile> createdInSubfolder = m_repo->createProfile(inSubfolder);
QVERIFY(createdInSubfolder.has_value());
QVERIFY(m_repo->deleteFolder(QStringLiteral("Work")));
// "Work/Servers" shifts up to become root-level "Servers"; the profile
// that was directly in "Work" moves to root; nothing is deleted.
const auto folders = m_repo->listFolders();
QCOMPARE(folders.size(), size_t(1));
QCOMPARE(folders[0], QStringLiteral("Servers"));
const std::optional<Profile> fetchedInTarget = m_repo->getProfile(createdInTarget->id);
QVERIFY(fetchedInTarget.has_value());
QCOMPARE(fetchedInTarget->folderPath, QString());
const std::optional<Profile> fetchedInSubfolder = m_repo->getProfile(createdInSubfolder->id);
QVERIFY(fetchedInSubfolder.has_value());
QCOMPARE(fetchedInSubfolder->folderPath, QStringLiteral("Servers"));
}
void TestProfileRepository::deleteFolderRejectsEmptyPath()
{
QVERIFY(!m_repo->deleteFolder(QString()));
QVERIFY(!m_repo->lastError().isEmpty());
}
void TestProfileRepository::deleteNonexistentFolderSucceedsAsNoOp()
{
QVERIFY(m_repo->deleteFolder(QStringLiteral("Never/Created")));
}
QTEST_GUILESS_MAIN(TestProfileRepository)
#include "test_profile_repository.moc"