Internal
Public Access
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>
382 lines
14 KiB
C++
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"
|