Internal
Public Access
Compare commits
7
Commits
v2026.9.14.2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04ce6f7904 | ||
|
|
9842a44de0 | ||
|
|
eb63bde870 | ||
|
|
3fab2f9de3 | ||
|
|
4f5cf8ecd9 | ||
|
|
df99c78998 | ||
|
|
dffca3afef |
+16
-1
@@ -1,6 +1,6 @@
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
|
||||
project(OrbitHub VERSION 2026.9.14.2 LANGUAGES CXX)
|
||||
project(OrbitHub VERSION 2026.9.15 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@@ -16,6 +16,17 @@ find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql)
|
||||
|
||||
qt_standard_project_setup()
|
||||
|
||||
option(ORBITHUB_BUILD_TESTS "Build unit tests (requires Qt6::Test)" ON)
|
||||
if(ORBITHUB_BUILD_TESTS)
|
||||
find_package(Qt6 6.2 QUIET COMPONENTS Test)
|
||||
if(TARGET Qt6::Test)
|
||||
enable_testing()
|
||||
else()
|
||||
message(STATUS "Qt6::Test not found -- skipping unit tests (set ORBITHUB_BUILD_TESTS=OFF to silence this)")
|
||||
set(ORBITHUB_BUILD_TESTS OFF)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
add_subdirectory(third_party/KodoTerm)
|
||||
|
||||
if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/FreeRDP/CMakeLists.txt")
|
||||
@@ -272,3 +283,7 @@ install(FILES third_party/KodoTerm/LICENSE
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
|
||||
RENAME LICENSE-KodoTerm
|
||||
)
|
||||
|
||||
if(ORBITHUB_BUILD_TESTS)
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
|
||||
@@ -16,14 +16,14 @@ OrbitHub is in active development.
|
||||
- Milestones completed: M0-M5, and M7-M9
|
||||
- Current milestone: Milestone 10 (v1.0 Stabilization)
|
||||
- Deferred milestone: Milestone 6 (VNC Fully Working)
|
||||
- Latest checkpoint tag: `v2026.9.14.2`
|
||||
- Latest checkpoint tag: `v2026.9.15`
|
||||
- VNC implementation milestone (M6) is currently deferred
|
||||
|
||||
Progress and milestone details:
|
||||
- [docs/PROGRESS.md](docs/PROGRESS.md)
|
||||
|
||||
Latest release (installers for Windows, Linux, and macOS):
|
||||
- [v2026.9.14.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2)
|
||||
- [v2026.9.15](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15)
|
||||
|
||||
User Guide:
|
||||
- [docs/USER_GUIDE.md](docs/USER_GUIDE.md) (also available as a PDF attached to each release, and in-app via `Help -> User Guide`)
|
||||
|
||||
@@ -202,6 +202,7 @@ Git:
|
||||
- Release: [v2026.9.8.3](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3) — same-day patch adding an in-app User Guide and standalone User Guide PDF
|
||||
- Release: [v2026.9.14](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14) — fixes distorted RDP text on HiDPI monitors and reduces RDP resize-related display glitches
|
||||
- Release: [v2026.9.14.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2) — same-day patch fixing RDP display corruption (missing/misplaced taskbar) after resizing the session window (the client never resized its own display buffer for channel-driven RDP resizes)
|
||||
- Release: [v2026.9.15](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15) — adds Import/Export for profile lists (File menu)
|
||||
|
||||
## Milestone 10 - v1.0 Stabilization
|
||||
|
||||
|
||||
@@ -19,5 +19,5 @@ modules:
|
||||
sources:
|
||||
- type: git
|
||||
url: https://git.darksingularity.org/DarkSingularity/orbithub.git
|
||||
tag: v2026.9.14.2
|
||||
commit: 3e621219f12e9168b152140d927d672f7877b379
|
||||
tag: v2026.9.15
|
||||
commit: dffca3afef80b5a3ca4832e0b7f748775cb90328
|
||||
|
||||
@@ -17,6 +17,11 @@ mkdir -p "$DIST_DIR"
|
||||
rm -rf "$STAGE_DIR"
|
||||
mkdir -p "$PKG_ROOT/DEBIAN"
|
||||
|
||||
# Read VERSION only after the build (which reconfigures CMakeCache.txt if
|
||||
# CMakeLists.txt changed since the build dir was last configured) --
|
||||
# reading it beforehand risks packaging a stale version string.
|
||||
cmake --build "$BUILD_DIR" -j
|
||||
|
||||
VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
|
||||
ARCH="$(dpkg --print-architecture)"
|
||||
|
||||
@@ -25,7 +30,6 @@ if [[ -z "$VERSION" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cmake --build "$BUILD_DIR" -j
|
||||
cmake --install "$BUILD_DIR" --prefix "$PKG_ROOT/usr"
|
||||
|
||||
cat > "$PKG_ROOT/DEBIAN/control" <<EOF
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="2026.9.15" date="2026-09-15">
|
||||
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15</url>
|
||||
<description>
|
||||
<p>Adds Import/Export for profile lists (File menu).</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="2026.9.14.2" date="2026-09-14">
|
||||
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2</url>
|
||||
<description>
|
||||
|
||||
@@ -14,12 +14,6 @@ if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "Unable to determine project version from $BUILD_DIR/CMakeCache.txt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MACDEPLOYQT="$(brew --prefix qt@6)/bin/macdeployqt"
|
||||
if [[ ! -x "$MACDEPLOYQT" ]]; then
|
||||
echo "macdeployqt not found at $MACDEPLOYQT" >&2
|
||||
@@ -31,7 +25,17 @@ mkdir -p "$DIST_DIR"
|
||||
rm -rf "$STAGE_DIR" "$INSTALL_PREFIX"
|
||||
mkdir -p "$STAGE_DIR"
|
||||
|
||||
# Read VERSION only after the build (which reconfigures CMakeCache.txt if
|
||||
# CMakeLists.txt changed since the build dir was last configured) --
|
||||
# reading it beforehand risks packaging a stale version string.
|
||||
cmake --build "$BUILD_DIR" -j
|
||||
|
||||
VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "Unable to determine project version from $BUILD_DIR/CMakeCache.txt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cmake --install "$BUILD_DIR" --prefix "$INSTALL_PREFIX"
|
||||
|
||||
if [[ ! -d "$INSTALL_PREFIX/$APP_BUNDLE" ]]; then
|
||||
|
||||
+134
-2
@@ -9,7 +9,11 @@
|
||||
#include <QVariant>
|
||||
#include <QStringList>
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace {
|
||||
std::atomic<int> g_testConnectionCounter{0};
|
||||
|
||||
QString buildDatabasePath()
|
||||
{
|
||||
QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||
@@ -176,7 +180,7 @@ void bindProfileFields(QSqlQuery& query, const Profile& profile)
|
||||
: QStringLiteral("Negotiate"));
|
||||
query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)
|
||||
: QStringLiteral("Balanced"));
|
||||
query.addBindValue(normalizedTags(profile.tags));
|
||||
query.addBindValue(nonNullTrimmed(normalizedTags(profile.tags)));
|
||||
}
|
||||
|
||||
Profile profileFromQuery(const QSqlQuery& query)
|
||||
@@ -259,6 +263,16 @@ ProfileRepository::ProfileRepository() : m_connectionName(QStringLiteral("orbith
|
||||
}
|
||||
}
|
||||
|
||||
ProfileRepository::ProfileRepository(const QString& databasePathOverride)
|
||||
: m_connectionName(QStringLiteral("orbithub_test_%1")
|
||||
.arg(g_testConnectionCounter.fetch_add(1))),
|
||||
m_databasePathOverride(databasePathOverride)
|
||||
{
|
||||
if (!initializeDatabase()) {
|
||||
QSqlDatabase::removeDatabase(m_connectionName);
|
||||
}
|
||||
}
|
||||
|
||||
ProfileRepository::~ProfileRepository()
|
||||
{
|
||||
if (QSqlDatabase::contains(m_connectionName)) {
|
||||
@@ -332,6 +346,123 @@ bool ProfileRepository::createFolder(const QString& folderPath) const
|
||||
return true;
|
||||
}
|
||||
|
||||
// Deleting a folder never destroys profiles: everything directly inside it
|
||||
// (profiles and subfolders alike) is shifted up to take the deleted
|
||||
// folder's place, exactly as if that one path segment had been removed
|
||||
// from each of their paths. This is the least-surprising behavior for what
|
||||
// is fundamentally just an organizational label, not a container that owns
|
||||
// its contents.
|
||||
bool ProfileRepository::deleteFolder(const QString& folderPath) const
|
||||
{
|
||||
if (!QSqlDatabase::contains(m_connectionName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString normalized = normalizedFolderPath(folderPath);
|
||||
if (normalized.isEmpty()) {
|
||||
setLastError(QStringLiteral("Folder path is required."));
|
||||
return false;
|
||||
}
|
||||
|
||||
setLastError(QString());
|
||||
|
||||
QString parentPath;
|
||||
const int lastSlash = normalized.lastIndexOf(QChar::fromLatin1('/'));
|
||||
if (lastSlash >= 0) {
|
||||
parentPath = normalized.left(lastSlash);
|
||||
}
|
||||
|
||||
const QString prefix = normalized + QStringLiteral("/");
|
||||
auto remap = [&](const QString& oldPath) {
|
||||
if (oldPath == normalized) {
|
||||
return nonNullTrimmed(parentPath);
|
||||
}
|
||||
const QString remainder = oldPath.mid(prefix.length());
|
||||
return nonNullTrimmed(parentPath.isEmpty() ? remainder
|
||||
: parentPath + QStringLiteral("/") + remainder);
|
||||
};
|
||||
|
||||
QSqlDatabase database = QSqlDatabase::database(m_connectionName);
|
||||
if (!database.transaction()) {
|
||||
setLastError(database.lastError().text());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reassign affected profiles. Matching is done in C++ (not SQL LIKE)
|
||||
// so a folder name containing '%' or '_' can't be misinterpreted as a
|
||||
// wildcard.
|
||||
QSqlQuery selectProfiles(database);
|
||||
if (!selectProfiles.exec(QStringLiteral("SELECT id, folder_path FROM profiles"))) {
|
||||
setLastError(selectProfiles.lastError().text());
|
||||
database.rollback();
|
||||
return false;
|
||||
}
|
||||
std::vector<std::pair<qint64, QString>> profileUpdates;
|
||||
while (selectProfiles.next()) {
|
||||
const QString path = normalizedFolderPath(selectProfiles.value(1).toString());
|
||||
if (path == normalized || path.startsWith(prefix)) {
|
||||
profileUpdates.emplace_back(selectProfiles.value(0).toLongLong(), remap(path));
|
||||
}
|
||||
}
|
||||
for (const auto& [id, newPath] : profileUpdates) {
|
||||
QSqlQuery update(database);
|
||||
update.prepare(QStringLiteral("UPDATE profiles SET folder_path = ? WHERE id = ?"));
|
||||
update.addBindValue(newPath);
|
||||
update.addBindValue(id);
|
||||
if (!update.exec()) {
|
||||
setLastError(update.lastError().text());
|
||||
database.rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reassign (or drop, for the folder itself) affected explicit folder
|
||||
// markers the same way.
|
||||
QSqlQuery selectFolders(database);
|
||||
if (!selectFolders.exec(QStringLiteral("SELECT path FROM profile_folders"))) {
|
||||
setLastError(selectFolders.lastError().text());
|
||||
database.rollback();
|
||||
return false;
|
||||
}
|
||||
QStringList affectedFolders;
|
||||
while (selectFolders.next()) {
|
||||
const QString path = normalizedFolderPath(selectFolders.value(0).toString());
|
||||
if (path == normalized || path.startsWith(prefix)) {
|
||||
affectedFolders.push_back(path);
|
||||
}
|
||||
}
|
||||
for (const QString& oldPath : affectedFolders) {
|
||||
QSqlQuery remove(database);
|
||||
remove.prepare(QStringLiteral("DELETE FROM profile_folders WHERE path = ?"));
|
||||
remove.addBindValue(oldPath);
|
||||
if (!remove.exec()) {
|
||||
setLastError(remove.lastError().text());
|
||||
database.rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString newPath = remap(oldPath);
|
||||
if (oldPath != normalized && !newPath.isEmpty()) {
|
||||
QSqlQuery insert(database);
|
||||
insert.prepare(QStringLiteral("INSERT OR IGNORE INTO profile_folders(path) VALUES (?)"));
|
||||
insert.addBindValue(newPath);
|
||||
if (!insert.exec()) {
|
||||
setLastError(insert.lastError().text());
|
||||
database.rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!database.commit()) {
|
||||
setLastError(database.lastError().text());
|
||||
database.rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery,
|
||||
ProfileSortOrder sortOrder) const
|
||||
{
|
||||
@@ -485,7 +616,8 @@ bool ProfileRepository::deleteProfile(qint64 id) const
|
||||
bool ProfileRepository::initializeDatabase()
|
||||
{
|
||||
QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
|
||||
database.setDatabaseName(buildDatabasePath());
|
||||
database.setDatabaseName(
|
||||
m_databasePathOverride.isEmpty() ? buildDatabasePath() : m_databasePathOverride);
|
||||
|
||||
if (!database.open()) {
|
||||
m_initError = database.lastError().text();
|
||||
|
||||
@@ -35,6 +35,9 @@ class ProfileRepository
|
||||
{
|
||||
public:
|
||||
ProfileRepository();
|
||||
// databasePathOverride lets tests point the repository at an isolated,
|
||||
// disposable SQLite file instead of the real app-data location.
|
||||
explicit ProfileRepository(const QString& databasePathOverride);
|
||||
~ProfileRepository();
|
||||
|
||||
QString initError() const;
|
||||
@@ -44,6 +47,7 @@ public:
|
||||
ProfileSortOrder sortOrder = ProfileSortOrder::NameAsc) const;
|
||||
std::vector<QString> listFolders() const;
|
||||
bool createFolder(const QString& folderPath) const;
|
||||
bool deleteFolder(const QString& folderPath) const;
|
||||
std::optional<Profile> getProfile(qint64 id) const;
|
||||
std::optional<Profile> createProfile(const Profile& profile) const;
|
||||
bool updateProfile(const Profile& profile) const;
|
||||
@@ -51,6 +55,7 @@ public:
|
||||
|
||||
private:
|
||||
QString m_connectionName;
|
||||
QString m_databasePathOverride;
|
||||
QString m_initError;
|
||||
mutable QString m_lastError;
|
||||
|
||||
|
||||
@@ -7,8 +7,13 @@
|
||||
#include <QAction>
|
||||
#include <QAbstractItemView>
|
||||
#include <QComboBox>
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QHeaderView>
|
||||
#include <QHBoxLayout>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QInputDialog>
|
||||
@@ -85,6 +90,53 @@ bool profileHasTag(const Profile& profile, const QString& requestedTag)
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
constexpr int kProfileExportFormatVersion = 1;
|
||||
|
||||
QJsonObject profileToJson(const Profile& profile)
|
||||
{
|
||||
QJsonObject object;
|
||||
object.insert(QStringLiteral("name"), profile.name);
|
||||
object.insert(QStringLiteral("host"), profile.host);
|
||||
object.insert(QStringLiteral("port"), profile.port);
|
||||
object.insert(QStringLiteral("username"), profile.username);
|
||||
object.insert(QStringLiteral("domain"), profile.domain);
|
||||
object.insert(QStringLiteral("folderPath"), profile.folderPath);
|
||||
object.insert(QStringLiteral("protocol"), profile.protocol);
|
||||
object.insert(QStringLiteral("authMode"), profile.authMode);
|
||||
object.insert(QStringLiteral("privateKeyPath"), profile.privateKeyPath);
|
||||
object.insert(QStringLiteral("knownHostsPolicy"), profile.knownHostsPolicy);
|
||||
object.insert(QStringLiteral("rdpSecurityMode"), profile.rdpSecurityMode);
|
||||
object.insert(QStringLiteral("rdpPerformanceProfile"), profile.rdpPerformanceProfile);
|
||||
object.insert(QStringLiteral("tags"), profile.tags);
|
||||
return object;
|
||||
}
|
||||
|
||||
// Deliberately excludes id (import always creates new rows -- an imported
|
||||
// profile's id has no meaning in the destination database) and any
|
||||
// credential material (none is ever persisted on Profile in the first
|
||||
// place, see ProfileRepository).
|
||||
Profile profileFromJson(const QJsonObject& object)
|
||||
{
|
||||
Profile profile;
|
||||
profile.name = object.value(QStringLiteral("name")).toString();
|
||||
profile.host = object.value(QStringLiteral("host")).toString();
|
||||
profile.port = object.value(QStringLiteral("port")).toInt(22);
|
||||
profile.username = object.value(QStringLiteral("username")).toString();
|
||||
profile.domain = object.value(QStringLiteral("domain")).toString();
|
||||
profile.folderPath = object.value(QStringLiteral("folderPath")).toString();
|
||||
profile.protocol = object.value(QStringLiteral("protocol")).toString(QStringLiteral("SSH"));
|
||||
profile.authMode = object.value(QStringLiteral("authMode")).toString(QStringLiteral("Password"));
|
||||
profile.privateKeyPath = object.value(QStringLiteral("privateKeyPath")).toString();
|
||||
profile.knownHostsPolicy =
|
||||
object.value(QStringLiteral("knownHostsPolicy")).toString(QStringLiteral("Ask"));
|
||||
profile.rdpSecurityMode =
|
||||
object.value(QStringLiteral("rdpSecurityMode")).toString(QStringLiteral("Negotiate"));
|
||||
profile.rdpPerformanceProfile =
|
||||
object.value(QStringLiteral("rdpPerformanceProfile")).toString(QStringLiteral("Balanced"));
|
||||
profile.tags = object.value(QStringLiteral("tags")).toString();
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
|
||||
ProfilesWindow::ProfilesWindow(QWidget* parent)
|
||||
@@ -535,6 +587,8 @@ void ProfilesWindow::showTreeContextMenu(const QPoint& pos)
|
||||
|
||||
const QString contextFolder = folderPathForItem(item);
|
||||
const bool isProfileItem = item != nullptr && item->data(0, kProfileIdRole).isValid();
|
||||
const bool isFolderItem =
|
||||
item != nullptr && !isProfileItem && item->data(0, kFolderPathRole).isValid();
|
||||
|
||||
QMenu menu(this);
|
||||
QAction* newConnectionAction = menu.addAction(QStringLiteral("New Connection"));
|
||||
@@ -542,12 +596,16 @@ void ProfilesWindow::showTreeContextMenu(const QPoint& pos)
|
||||
QAction* connectAction = nullptr;
|
||||
QAction* editAction = nullptr;
|
||||
QAction* deleteAction = nullptr;
|
||||
QAction* deleteFolderAction = nullptr;
|
||||
|
||||
if (isProfileItem) {
|
||||
menu.addSeparator();
|
||||
connectAction = menu.addAction(QStringLiteral("Connect"));
|
||||
editAction = menu.addAction(QStringLiteral("Edit"));
|
||||
deleteAction = menu.addAction(QStringLiteral("Delete"));
|
||||
} else if (isFolderItem) {
|
||||
menu.addSeparator();
|
||||
deleteFolderAction = menu.addAction(QStringLiteral("Delete Folder"));
|
||||
}
|
||||
|
||||
QAction* chosen = menu.exec(m_profilesTree->viewport()->mapToGlobal(pos));
|
||||
@@ -577,6 +635,10 @@ void ProfilesWindow::showTreeContextMenu(const QPoint& pos)
|
||||
deleteSelectedProfile();
|
||||
return;
|
||||
}
|
||||
if (isFolderItem && chosen == deleteFolderAction) {
|
||||
deleteFolderInContext(contextFolder);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void ProfilesWindow::createFolderInContext(const QString& baseFolderPath)
|
||||
@@ -614,6 +676,72 @@ void ProfilesWindow::createFolderInContext(const QString& baseFolderPath)
|
||||
loadProfiles();
|
||||
}
|
||||
|
||||
void ProfilesWindow::deleteFolderInContext(const QString& folderPath)
|
||||
{
|
||||
const QString normalized = normalizeFolderPathForView(folderPath);
|
||||
if (normalized.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deleting a folder never deletes profiles -- everything inside it
|
||||
// (profiles and subfolders) shifts up to take its place. Count what's
|
||||
// affected so the confirmation is honest about that, rather than
|
||||
// reading like the usual destructive "Delete" action.
|
||||
const QString prefix = normalized + QStringLiteral("/");
|
||||
int profileCount = 0;
|
||||
for (const Profile& profile : m_repository->listProfiles()) {
|
||||
if (profile.folderPath == normalized || profile.folderPath.startsWith(prefix)) {
|
||||
++profileCount;
|
||||
}
|
||||
}
|
||||
int subfolderCount = 0;
|
||||
for (const QString& folder : m_repository->listFolders()) {
|
||||
if (folder != normalized && folder.startsWith(prefix)) {
|
||||
++subfolderCount;
|
||||
}
|
||||
}
|
||||
|
||||
if (profileCount > 0 || subfolderCount > 0) {
|
||||
QStringList parentParts = splitFolderPath(normalized);
|
||||
parentParts.removeLast();
|
||||
const QString destination = parentParts.isEmpty()
|
||||
? QStringLiteral("the top level")
|
||||
: QStringLiteral("'%1'").arg(parentParts.join(QStringLiteral("/")));
|
||||
|
||||
QStringList details;
|
||||
if (profileCount > 0) {
|
||||
details.push_back(QStringLiteral("%1 profile(s)").arg(profileCount));
|
||||
}
|
||||
if (subfolderCount > 0) {
|
||||
details.push_back(QStringLiteral("%1 subfolder(s)").arg(subfolderCount));
|
||||
}
|
||||
|
||||
const QMessageBox::StandardButton confirm = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("Delete Folder"),
|
||||
QStringLiteral("Delete folder '%1'?\n\nNothing will be deleted: %2 currently inside "
|
||||
"it will move up to %3.")
|
||||
.arg(normalized, details.join(QStringLiteral(" and ")), destination),
|
||||
QMessageBox::Yes | QMessageBox::No,
|
||||
QMessageBox::No);
|
||||
if (confirm != QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!m_repository->deleteFolder(normalized)) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Delete Folder"),
|
||||
QStringLiteral("Failed to delete folder: %1")
|
||||
.arg(m_repository->lastError().isEmpty()
|
||||
? QStringLiteral("unknown error")
|
||||
: m_repository->lastError()));
|
||||
return;
|
||||
}
|
||||
|
||||
loadProfiles();
|
||||
}
|
||||
|
||||
void ProfilesWindow::persistFolderAssignmentsFromTree()
|
||||
{
|
||||
if (!isFolderViewEnabled() || m_profilesTree == nullptr) {
|
||||
@@ -930,3 +1058,128 @@ void ProfilesWindow::createFolderInCurrentContext()
|
||||
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
|
||||
createFolderInContext(folderPath);
|
||||
}
|
||||
|
||||
void ProfilesWindow::exportProfiles()
|
||||
{
|
||||
const QString fileName = QFileDialog::getSaveFileName(
|
||||
this, QStringLiteral("Export Profiles"), QStringLiteral("orbithub-profiles.json"),
|
||||
QStringLiteral("JSON Files (*.json)"));
|
||||
if (fileName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonArray folders;
|
||||
for (const QString& folderPath : m_repository->listFolders()) {
|
||||
folders.append(folderPath);
|
||||
}
|
||||
|
||||
QJsonArray profiles;
|
||||
for (const Profile& profile : m_repository->listProfiles()) {
|
||||
profiles.append(profileToJson(profile));
|
||||
}
|
||||
|
||||
QJsonObject root;
|
||||
root.insert(QStringLiteral("orbithubProfileExport"), kProfileExportFormatVersion);
|
||||
root.insert(QStringLiteral("folders"), folders);
|
||||
root.insert(QStringLiteral("profiles"), profiles);
|
||||
|
||||
QFile file(fileName);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Export Profiles"),
|
||||
QStringLiteral("Failed to write %1: %2")
|
||||
.arg(fileName, file.errorString()));
|
||||
return;
|
||||
}
|
||||
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
|
||||
file.close();
|
||||
|
||||
QMessageBox::information(this,
|
||||
QStringLiteral("Export Profiles"),
|
||||
QStringLiteral("Exported %1 profile(s) to %2.")
|
||||
.arg(profiles.size())
|
||||
.arg(fileName));
|
||||
}
|
||||
|
||||
void ProfilesWindow::importProfiles()
|
||||
{
|
||||
const QString fileName = QFileDialog::getOpenFileName(
|
||||
this, QStringLiteral("Import Profiles"), QString(), QStringLiteral("JSON Files (*.json)"));
|
||||
if (fileName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QFile file(fileName);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Import Profiles"),
|
||||
QStringLiteral("Failed to read %1: %2")
|
||||
.arg(fileName, file.errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonParseError parseError{};
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError);
|
||||
file.close();
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Import Profiles"),
|
||||
QStringLiteral("%1 is not a valid OrbitHub profile export: %2")
|
||||
.arg(fileName, parseError.errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject root = doc.object();
|
||||
if (!root.value(QStringLiteral("profiles")).isArray()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Import Profiles"),
|
||||
QStringLiteral("%1 does not contain a profile list.").arg(fileName));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const QJsonValue& folderValue : root.value(QStringLiteral("folders")).toArray()) {
|
||||
const QString folderPath = folderValue.toString();
|
||||
if (!folderPath.isEmpty()) {
|
||||
m_repository->createFolder(folderPath);
|
||||
}
|
||||
}
|
||||
|
||||
const QJsonArray profilesArray = root.value(QStringLiteral("profiles")).toArray();
|
||||
int imported = 0;
|
||||
QStringList failures;
|
||||
for (const QJsonValue& profileValue : profilesArray) {
|
||||
if (!profileValue.isObject()) {
|
||||
continue;
|
||||
}
|
||||
const Profile profile = profileFromJson(profileValue.toObject());
|
||||
if (profile.name.isEmpty() || profile.host.isEmpty()) {
|
||||
failures.push_back(QStringLiteral("(unnamed profile): missing name or host"));
|
||||
continue;
|
||||
}
|
||||
// No need to createFolder(profile.folderPath) here: the tree view
|
||||
// already synthesizes folder nodes from a profile's own folderPath
|
||||
// (see ProfilesWindow::loadProfiles). Explicit profile_folders rows
|
||||
// are only for folders with no profiles in them, and those are
|
||||
// already recreated above from the export's top-level "folders"
|
||||
// list -- doing it again per-profile would just add spurious
|
||||
// entries not present in the original export.
|
||||
if (m_repository->createProfile(profile).has_value()) {
|
||||
++imported;
|
||||
} else {
|
||||
failures.push_back(QStringLiteral("%1: %2").arg(profile.name, m_repository->lastError()));
|
||||
}
|
||||
}
|
||||
|
||||
loadProfiles();
|
||||
|
||||
QString summary = QStringLiteral("Imported %1 of %2 profile(s) from %3.")
|
||||
.arg(imported)
|
||||
.arg(profilesArray.size())
|
||||
.arg(fileName);
|
||||
if (!failures.isEmpty()) {
|
||||
summary += QStringLiteral("\n\nFailed:\n%1").arg(failures.join(QChar::fromLatin1('\n')));
|
||||
QMessageBox::warning(this, QStringLiteral("Import Profiles"), summary);
|
||||
} else {
|
||||
QMessageBox::information(this, QStringLiteral("Import Profiles"), summary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ public:
|
||||
|
||||
void createProfileInCurrentContext();
|
||||
void createFolderInCurrentContext();
|
||||
void exportProfiles();
|
||||
void importProfiles();
|
||||
|
||||
signals:
|
||||
void connectRequested(const Profile& profile);
|
||||
@@ -63,6 +65,7 @@ private:
|
||||
QString folderPathForItem(const QTreeWidgetItem* item) const;
|
||||
void showTreeContextMenu(const QPoint& pos);
|
||||
void createFolderInContext(const QString& baseFolderPath);
|
||||
void deleteFolderInContext(const QString& folderPath);
|
||||
void persistFolderAssignmentsFromTree();
|
||||
void collectProfileAssignments(const QTreeWidgetItem* item,
|
||||
const QString& parentFolderPath,
|
||||
|
||||
+61
-59
@@ -89,59 +89,13 @@ constexpr double kDefaultDpi = 96.0;
|
||||
constexpr double kMillimetersPerInch = 25.4;
|
||||
|
||||
#ifdef ORBITHUB_HAS_FREERDP
|
||||
QString normalizedRdpSecurityMode(const QString& value)
|
||||
{
|
||||
const QString mode = value.trimmed();
|
||||
if (mode.compare(QStringLiteral("NLA"), Qt::CaseInsensitive) == 0) {
|
||||
return QStringLiteral("NLA");
|
||||
}
|
||||
if (mode.compare(QStringLiteral("TLS"), Qt::CaseInsensitive) == 0) {
|
||||
return QStringLiteral("TLS");
|
||||
}
|
||||
if (mode.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
|
||||
return QStringLiteral("RDP");
|
||||
}
|
||||
return QStringLiteral("Negotiate");
|
||||
}
|
||||
|
||||
// MS-RDPEDISP restricts DesktopScaleFactor/DeviceScaleFactor to exactly
|
||||
// these three values; FreeRDP's own reference client enforces the same
|
||||
// set (client/common/cmdline.c, parse_scale_options). Anything else is
|
||||
// silently ignored by the server, so map the real, continuous
|
||||
// devicePixelRatio down to the nearest one.
|
||||
UINT32 nearestFreeRdpScaleValue(qreal ratio)
|
||||
{
|
||||
if (ratio <= 1.2) {
|
||||
return 100;
|
||||
}
|
||||
if (ratio <= 1.6) {
|
||||
return 140;
|
||||
}
|
||||
return 180;
|
||||
}
|
||||
|
||||
QString normalizedRdpPerformanceProfile(const QString& value)
|
||||
{
|
||||
const QString profile = value.trimmed();
|
||||
if (profile.compare(QStringLiteral("Best Quality"), Qt::CaseInsensitive) == 0) {
|
||||
return QStringLiteral("Best Quality");
|
||||
}
|
||||
if (profile.compare(QStringLiteral("Best Performance"), Qt::CaseInsensitive) == 0) {
|
||||
return QStringLiteral("Best Performance");
|
||||
}
|
||||
if (profile.compare(QStringLiteral("Auto Detect"), Qt::CaseInsensitive) == 0) {
|
||||
return QStringLiteral("Auto Detect");
|
||||
}
|
||||
return QStringLiteral("Balanced");
|
||||
}
|
||||
|
||||
bool applyRdpSecurityMode(rdpSettings* settings, const QString& mode)
|
||||
{
|
||||
if (settings == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString normalized = normalizedRdpSecurityMode(mode);
|
||||
const QString normalized = RdpSessionBackend::normalizedRdpSecurityMode(mode);
|
||||
|
||||
BOOL rdp = FALSE;
|
||||
BOOL tls = FALSE;
|
||||
@@ -175,7 +129,7 @@ bool applyRdpPerformanceProfile(rdpSettings* settings, const QString& profile)
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString normalized = normalizedRdpPerformanceProfile(profile);
|
||||
const QString normalized = RdpSessionBackend::normalizedRdpPerformanceProfile(profile);
|
||||
UINT32 connectionType = CONNECTION_TYPE_BROADBAND_HIGH;
|
||||
BOOL networkAutoDetect = FALSE;
|
||||
if (normalized == QStringLiteral("Best Quality")) {
|
||||
@@ -875,8 +829,55 @@ BOOL orbitAuthenticateEx(freerdp* instance,
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
UINT32 scancodeFromNativeScanCode(quint32 nativeScanCode)
|
||||
QString RdpSessionBackend::normalizedRdpSecurityMode(const QString& value)
|
||||
{
|
||||
const QString mode = value.trimmed();
|
||||
if (mode.compare(QStringLiteral("NLA"), Qt::CaseInsensitive) == 0) {
|
||||
return QStringLiteral("NLA");
|
||||
}
|
||||
if (mode.compare(QStringLiteral("TLS"), Qt::CaseInsensitive) == 0) {
|
||||
return QStringLiteral("TLS");
|
||||
}
|
||||
if (mode.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
|
||||
return QStringLiteral("RDP");
|
||||
}
|
||||
return QStringLiteral("Negotiate");
|
||||
}
|
||||
|
||||
// MS-RDPEDISP restricts DesktopScaleFactor/DeviceScaleFactor to exactly
|
||||
// these three values; FreeRDP's own reference client enforces the same
|
||||
// set (client/common/cmdline.c, parse_scale_options). Anything else is
|
||||
// silently ignored by the server, so map the real, continuous
|
||||
// devicePixelRatio down to the nearest one.
|
||||
quint32 RdpSessionBackend::nearestFreeRdpScaleValue(qreal ratio)
|
||||
{
|
||||
if (ratio <= 1.2) {
|
||||
return 100;
|
||||
}
|
||||
if (ratio <= 1.6) {
|
||||
return 140;
|
||||
}
|
||||
return 180;
|
||||
}
|
||||
|
||||
QString RdpSessionBackend::normalizedRdpPerformanceProfile(const QString& value)
|
||||
{
|
||||
const QString profile = value.trimmed();
|
||||
if (profile.compare(QStringLiteral("Best Quality"), Qt::CaseInsensitive) == 0) {
|
||||
return QStringLiteral("Best Quality");
|
||||
}
|
||||
if (profile.compare(QStringLiteral("Best Performance"), Qt::CaseInsensitive) == 0) {
|
||||
return QStringLiteral("Best Performance");
|
||||
}
|
||||
if (profile.compare(QStringLiteral("Auto Detect"), Qt::CaseInsensitive) == 0) {
|
||||
return QStringLiteral("Auto Detect");
|
||||
}
|
||||
return QStringLiteral("Balanced");
|
||||
}
|
||||
|
||||
quint32 RdpSessionBackend::scancodeFromNativeScanCode(quint32 nativeScanCode)
|
||||
{
|
||||
if (nativeScanCode == 0) {
|
||||
return RDP_SCANCODE_UNKNOWN;
|
||||
@@ -935,10 +936,12 @@ UINT32 scancodeFromNativeScanCode(quint32 nativeScanCode)
|
||||
#endif
|
||||
}
|
||||
|
||||
UINT32 scancodeForQtKey(int key, Qt::KeyboardModifiers modifiers, quint32 nativeScanCode)
|
||||
quint32 RdpSessionBackend::scancodeForQtKey(int key,
|
||||
Qt::KeyboardModifiers modifiers,
|
||||
quint32 nativeScanCode)
|
||||
{
|
||||
const bool keypad = modifiers.testFlag(Qt::KeypadModifier);
|
||||
const UINT32 nativeScancode = scancodeFromNativeScanCode(nativeScanCode);
|
||||
const quint32 nativeScancode = scancodeFromNativeScanCode(nativeScanCode);
|
||||
|
||||
switch (key) {
|
||||
case Qt::Key_Escape:
|
||||
@@ -1163,7 +1166,7 @@ UINT32 scancodeForQtKey(int key, Qt::KeyboardModifiers modifiers, quint32 native
|
||||
}
|
||||
}
|
||||
|
||||
QString mapRdpError(UINT32 code)
|
||||
QString RdpSessionBackend::mapRdpError(quint32 code)
|
||||
{
|
||||
switch (code) {
|
||||
case FREERDP_ERROR_CONNECT_LOGON_FAILURE:
|
||||
@@ -1226,7 +1229,7 @@ QString mapRdpError(UINT32 code)
|
||||
return QStringLiteral("RDP connection failed (0x%1).").arg(code, 8, 16, QChar('0'));
|
||||
}
|
||||
|
||||
bool isExpectedDisconnectCode(UINT32 code)
|
||||
bool RdpSessionBackend::isExpectedDisconnectCode(quint32 code)
|
||||
{
|
||||
switch (code) {
|
||||
case FREERDP_ERROR_SUCCESS:
|
||||
@@ -1256,7 +1259,7 @@ bool isExpectedDisconnectCode(UINT32 code)
|
||||
}
|
||||
}
|
||||
|
||||
bool isExpectedConnectAbortCode(UINT32 code)
|
||||
bool RdpSessionBackend::isExpectedConnectAbortCode(quint32 code)
|
||||
{
|
||||
switch (code) {
|
||||
case FREERDP_ERROR_SUCCESS:
|
||||
@@ -1268,7 +1271,7 @@ bool isExpectedConnectAbortCode(UINT32 code)
|
||||
}
|
||||
}
|
||||
|
||||
QString disconnectMessageForCode(UINT32 code)
|
||||
QString RdpSessionBackend::disconnectMessageForCode(quint32 code)
|
||||
{
|
||||
switch (code) {
|
||||
case FREERDP_ERROR_IDLE_TIMEOUT:
|
||||
@@ -1304,7 +1307,7 @@ QString disconnectMessageForCode(UINT32 code)
|
||||
}
|
||||
}
|
||||
|
||||
QString rdpErrorRaw(UINT32 code)
|
||||
QString RdpSessionBackend::rdpErrorRaw(quint32 code)
|
||||
{
|
||||
const char* name = freerdp_get_last_error_name(code);
|
||||
const QString text = (name != nullptr && name[0] != '\0') ? QString::fromUtf8(name)
|
||||
@@ -1312,7 +1315,6 @@ QString rdpErrorRaw(UINT32 code)
|
||||
return QStringLiteral("%1 (0x%2)").arg(text).arg(code, 8, 16, QChar('0'));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
|
||||
: SessionBackend(profile, parent),
|
||||
@@ -2434,7 +2436,7 @@ void RdpSessionBackend::emitConnectionFailureAsync(const QString& displayMessage
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
int RdpSessionBackend::sanitizeDesktopWidth(int width) const
|
||||
int RdpSessionBackend::sanitizeDesktopWidth(int width)
|
||||
{
|
||||
if (width <= 0) {
|
||||
return kDefaultDesktopWidth;
|
||||
@@ -2442,7 +2444,7 @@ int RdpSessionBackend::sanitizeDesktopWidth(int width) const
|
||||
return qBound(kMinDesktopWidth, width, kMaxDesktopWidth);
|
||||
}
|
||||
|
||||
int RdpSessionBackend::sanitizeDesktopHeight(int height) const
|
||||
int RdpSessionBackend::sanitizeDesktopHeight(int height)
|
||||
{
|
||||
if (height <= 0) {
|
||||
return kDefaultDesktopHeight;
|
||||
|
||||
@@ -19,6 +19,24 @@ public:
|
||||
explicit RdpSessionBackend(const Profile& profile, QObject* parent = nullptr);
|
||||
~RdpSessionBackend() override;
|
||||
|
||||
// Pure, state-free helpers exposed as public statics purely so tests
|
||||
// can exercise them without a live FreeRDP connection. UINT32 values
|
||||
// are surfaced as quint32 here to keep FreeRDP/WinPR types out of this
|
||||
// header (uint32_t is what UINT32 always is on every platform this
|
||||
// project targets).
|
||||
static QString normalizedRdpSecurityMode(const QString& value);
|
||||
static QString normalizedRdpPerformanceProfile(const QString& value);
|
||||
static quint32 nearestFreeRdpScaleValue(qreal ratio);
|
||||
static quint32 scancodeFromNativeScanCode(quint32 nativeScanCode);
|
||||
static quint32 scancodeForQtKey(int key, Qt::KeyboardModifiers modifiers, quint32 nativeScanCode);
|
||||
static QString mapRdpError(quint32 code);
|
||||
static bool isExpectedDisconnectCode(quint32 code);
|
||||
static bool isExpectedConnectAbortCode(quint32 code);
|
||||
static QString disconnectMessageForCode(quint32 code);
|
||||
static QString rdpErrorRaw(quint32 code);
|
||||
static int sanitizeDesktopWidth(int width);
|
||||
static int sanitizeDesktopHeight(int height);
|
||||
|
||||
public slots:
|
||||
void connectSession(const SessionConnectOptions& options) override;
|
||||
void disconnectSession() override;
|
||||
@@ -124,8 +142,6 @@ public:
|
||||
private:
|
||||
void emitStateAsync(SessionState state, const QString& message);
|
||||
void emitConnectionFailureAsync(const QString& displayMessage, const QString& rawMessage);
|
||||
int sanitizeDesktopWidth(int width) const;
|
||||
int sanitizeDesktopHeight(int height) const;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
@@ -170,6 +170,9 @@ SessionWindow::SessionWindow(QWidget* parent)
|
||||
QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile"));
|
||||
QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder"));
|
||||
fileMenu->addSeparator();
|
||||
QAction* importProfilesAction = fileMenu->addAction(QStringLiteral("Import Profiles..."));
|
||||
QAction* exportProfilesAction = fileMenu->addAction(QStringLiteral("Export Profiles..."));
|
||||
fileMenu->addSeparator();
|
||||
QAction* quitAction = fileMenu->addAction(QStringLiteral("Quit"));
|
||||
|
||||
connect(newProfileAction,
|
||||
@@ -180,6 +183,14 @@ SessionWindow::SessionWindow(QWidget* parent)
|
||||
&QAction::triggered,
|
||||
this,
|
||||
[this]() { m_profilesWidget->createFolderInCurrentContext(); });
|
||||
connect(importProfilesAction,
|
||||
&QAction::triggered,
|
||||
this,
|
||||
[this]() { m_profilesWidget->importProfiles(); });
|
||||
connect(exportProfilesAction,
|
||||
&QAction::triggered,
|
||||
this,
|
||||
[this]() { m_profilesWidget->exportProfiles(); });
|
||||
connect(quitAction, &QAction::triggered, this, []() { qApp->quit(); });
|
||||
|
||||
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
|
||||
|
||||
+16
-10
@@ -7,16 +7,14 @@
|
||||
#include <QTextStream>
|
||||
#include <QUuid>
|
||||
|
||||
namespace {
|
||||
QString escapeForShellSingleQuotes(const QString& value)
|
||||
SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
|
||||
: SshSessionBackend(profile, QStringLiteral("ssh"), parent)
|
||||
{
|
||||
QString escaped = value;
|
||||
escaped.replace(QStringLiteral("'"), QStringLiteral("'\"'\"'"));
|
||||
return escaped;
|
||||
}
|
||||
}
|
||||
|
||||
SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
|
||||
SshSessionBackend::SshSessionBackend(const Profile& profile,
|
||||
const QString& sshProgramOverride,
|
||||
QObject* parent)
|
||||
: SessionBackend(profile, parent),
|
||||
m_process(new QProcess(this)),
|
||||
m_connectedProbeTimer(new QTimer(this)),
|
||||
@@ -27,7 +25,8 @@ SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
|
||||
m_waitingForHostKeyConfirmation(false),
|
||||
m_passwordSubmitted(false),
|
||||
m_terminalColumns(0),
|
||||
m_terminalRows(0)
|
||||
m_terminalRows(0),
|
||||
m_sshProgram(sshProgramOverride)
|
||||
{
|
||||
m_connectedProbeTimer->setSingleShot(true);
|
||||
|
||||
@@ -395,7 +394,7 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
|
||||
args << target;
|
||||
|
||||
m_process->setProcessEnvironment(environment);
|
||||
m_process->setProgram(QStringLiteral("ssh"));
|
||||
m_process->setProgram(m_sshProgram);
|
||||
m_process->setArguments(args);
|
||||
m_process->setProcessChannelMode(QProcess::SeparateChannels);
|
||||
|
||||
@@ -471,7 +470,7 @@ void SshSessionBackend::cleanupAskPassScript()
|
||||
}
|
||||
}
|
||||
|
||||
QString SshSessionBackend::mapSshError(const QString& rawError) const
|
||||
QString SshSessionBackend::mapSshError(const QString& rawError)
|
||||
{
|
||||
const QString raw = rawError.trimmed();
|
||||
if (raw.contains(QStringLiteral("Permission denied"), Qt::CaseInsensitive)) {
|
||||
@@ -510,6 +509,13 @@ QString SshSessionBackend::mapSshError(const QString& rawError) const
|
||||
return QStringLiteral("SSH connection failed.");
|
||||
}
|
||||
|
||||
QString SshSessionBackend::escapeForShellSingleQuotes(const QString& value)
|
||||
{
|
||||
QString escaped = value;
|
||||
escaped.replace(QStringLiteral("'"), QStringLiteral("'\"'\"'"));
|
||||
return escaped;
|
||||
}
|
||||
|
||||
QString SshSessionBackend::knownHostsFileForNullDevice() const
|
||||
{
|
||||
#ifdef Q_OS_WIN
|
||||
|
||||
@@ -13,8 +13,16 @@ class SshSessionBackend : public SessionBackend
|
||||
|
||||
public:
|
||||
explicit SshSessionBackend(const Profile& profile, QObject* parent = nullptr);
|
||||
// Test-only: overrides the executable launched instead of "ssh", so
|
||||
// tests can point it at a controllable fixture script.
|
||||
SshSessionBackend(const Profile& profile, const QString& sshProgramOverride, QObject* parent);
|
||||
~SshSessionBackend() override;
|
||||
|
||||
// Pure, state-free helpers exposed as public statics purely so tests
|
||||
// can exercise them directly without spinning up a real ssh process.
|
||||
static QString mapSshError(const QString& rawError);
|
||||
static QString escapeForShellSingleQuotes(const QString& value);
|
||||
|
||||
public slots:
|
||||
void connectSession(const SessionConnectOptions& options) override;
|
||||
void disconnectSession() override;
|
||||
@@ -46,6 +54,7 @@ private:
|
||||
bool m_passwordSubmitted;
|
||||
int m_terminalColumns;
|
||||
int m_terminalRows;
|
||||
QString m_sshProgram;
|
||||
|
||||
void setState(SessionState state, const QString& message);
|
||||
bool startSshProcess(const SessionConnectOptions& options);
|
||||
@@ -53,7 +62,6 @@ private:
|
||||
QProcessEnvironment& environment,
|
||||
QString& error);
|
||||
void cleanupAskPassScript();
|
||||
QString mapSshError(const QString& rawError) const;
|
||||
QString knownHostsFileForNullDevice() const;
|
||||
void applyTerminalSizeIfAvailable();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
add_executable(test_profile_repository
|
||||
test_profile_repository.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/profile_repository.cpp
|
||||
)
|
||||
target_include_directories(test_profile_repository PRIVATE ${CMAKE_SOURCE_DIR}/src)
|
||||
target_link_libraries(test_profile_repository PRIVATE Qt6::Core Qt6::Sql Qt6::Test)
|
||||
add_test(NAME test_profile_repository COMMAND test_profile_repository)
|
||||
|
||||
add_executable(test_ssh_session_backend
|
||||
test_ssh_session_backend.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/ssh_session_backend.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/session_backend.h
|
||||
)
|
||||
target_include_directories(test_ssh_session_backend PRIVATE ${CMAKE_SOURCE_DIR}/src)
|
||||
target_link_libraries(test_ssh_session_backend PRIVATE Qt6::Core Qt6::Gui Qt6::Test)
|
||||
target_compile_definitions(test_ssh_session_backend PRIVATE
|
||||
ORBITHUB_TEST_FIXTURES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures"
|
||||
)
|
||||
add_test(NAME test_ssh_session_backend COMMAND test_ssh_session_backend)
|
||||
|
||||
if(TARGET freerdp AND TARGET winpr)
|
||||
add_executable(test_rdp_session_backend
|
||||
test_rdp_session_backend.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/rdp_session_backend.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/session_backend.h
|
||||
)
|
||||
target_include_directories(test_rdp_session_backend PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/src
|
||||
${CMAKE_SOURCE_DIR}/third_party/FreeRDP/include
|
||||
${CMAKE_SOURCE_DIR}/third_party/FreeRDP/winpr/include
|
||||
${CMAKE_BINARY_DIR}/third_party/FreeRDP/include
|
||||
${CMAKE_BINARY_DIR}/third_party/FreeRDP/winpr/include
|
||||
)
|
||||
target_compile_definitions(test_rdp_session_backend PRIVATE ORBITHUB_HAS_FREERDP)
|
||||
target_link_libraries(test_rdp_session_backend PRIVATE Qt6::Core Qt6::Gui Qt6::Test freerdp winpr)
|
||||
if(TARGET freerdp-client)
|
||||
target_link_libraries(test_rdp_session_backend PRIVATE freerdp-client)
|
||||
endif()
|
||||
add_test(NAME test_rdp_session_backend COMMAND test_rdp_session_backend)
|
||||
else()
|
||||
message(STATUS "FreeRDP targets not available -- skipping test_rdp_session_backend")
|
||||
endif()
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
# Minimal, deterministic stand-in for the real `ssh` binary, used by
|
||||
# SshSessionBackend's state-machine tests so they never touch a real
|
||||
# network or SSH server. Behavior is selected by which fixture hostname
|
||||
# appears among argv (SshSessionBackend always passes the profile's
|
||||
# host, optionally as user@host, as the final argument).
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
*@succeed|succeed)
|
||||
echo "Welcome to the fake host."
|
||||
# Stay alive echoing stdin back (simulates an interactive
|
||||
# session) until the backend terminates us.
|
||||
while IFS= read -r line; do
|
||||
echo "$line"
|
||||
done
|
||||
exit 0
|
||||
;;
|
||||
*@fail-auth|fail-auth)
|
||||
echo "Permission denied (publickey,password)." >&2
|
||||
exit 255
|
||||
;;
|
||||
*@refuse|refuse)
|
||||
echo "ssh: connect to host refuse port 22: Connection refused" >&2
|
||||
exit 255
|
||||
;;
|
||||
esac
|
||||
done
|
||||
echo "fake_ssh.sh: no recognized fixture host in arguments: $*" >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,375 @@
|
||||
#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 createProfileRejectsMissingUsernameForSsh();
|
||||
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::createProfileRejectsMissingUsernameForSsh()
|
||||
{
|
||||
Profile profile = makeSshProfile();
|
||||
profile.username.clear();
|
||||
QVERIFY(!m_repo->createProfile(profile).has_value());
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,281 @@
|
||||
#include "rdp_session_backend.h"
|
||||
|
||||
#include <QTest>
|
||||
|
||||
#include <freerdp/error.h>
|
||||
#include <freerdp/locale/keyboard.h>
|
||||
#include <freerdp/scancode.h>
|
||||
|
||||
class TestRdpSessionBackend : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void normalizedRdpSecurityModeRecognizesKnownValues();
|
||||
void normalizedRdpSecurityModeFallsBackToNegotiate();
|
||||
void normalizedRdpPerformanceProfileRecognizesKnownValues();
|
||||
void normalizedRdpPerformanceProfileFallsBackToBalanced();
|
||||
void nearestFreeRdpScaleValueMapsToLegalValues();
|
||||
|
||||
void sanitizeDesktopWidthClampsToLegalRange();
|
||||
void sanitizeDesktopHeightClampsToLegalRange();
|
||||
|
||||
void scancodeFromNativeScanCodeHandlesZero();
|
||||
#if defined(Q_OS_LINUX)
|
||||
void scancodeFromNativeScanCodeDelegatesToX11TableOnLinux();
|
||||
void scancodeFromNativeScanCodeDoesNotTreatX11KeycodeAsPcAtScancode();
|
||||
#endif
|
||||
|
||||
void scancodeForQtKeyMapsDirectKeys();
|
||||
void scancodeForQtKeyRespectsKeypadModifier();
|
||||
void scancodeForQtKeyDisambiguatesLeftRightModifiers();
|
||||
void scancodeForQtKeyReturnsUnknownForUnhandledKey();
|
||||
|
||||
void mapRdpErrorRecognizesAuthFailureCodes();
|
||||
void mapRdpErrorRecognizesAccountStateCodes();
|
||||
void mapRdpErrorRecognizesNetworkCodes();
|
||||
void mapRdpErrorFallsBackForUnknownCode();
|
||||
|
||||
void isExpectedDisconnectCodeRecognizesBenignCodes();
|
||||
void isExpectedDisconnectCodeRejectsAuthFailure();
|
||||
|
||||
void isExpectedConnectAbortCodeRecognizesCancellation();
|
||||
void isExpectedConnectAbortCodeRejectsAuthFailure();
|
||||
|
||||
void disconnectMessageForCodeRecognizesKnownCodes();
|
||||
void disconnectMessageForCodeFallsBackForUnknownCode();
|
||||
|
||||
void rdpErrorRawIncludesHexCode();
|
||||
};
|
||||
|
||||
void TestRdpSessionBackend::normalizedRdpSecurityModeRecognizesKnownValues()
|
||||
{
|
||||
QCOMPARE(RdpSessionBackend::normalizedRdpSecurityMode(QStringLiteral("nla")),
|
||||
QStringLiteral("NLA"));
|
||||
QCOMPARE(RdpSessionBackend::normalizedRdpSecurityMode(QStringLiteral(" TLS ")),
|
||||
QStringLiteral("TLS"));
|
||||
QCOMPARE(RdpSessionBackend::normalizedRdpSecurityMode(QStringLiteral("Rdp")),
|
||||
QStringLiteral("RDP"));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::normalizedRdpSecurityModeFallsBackToNegotiate()
|
||||
{
|
||||
QCOMPARE(RdpSessionBackend::normalizedRdpSecurityMode(QStringLiteral("bogus")),
|
||||
QStringLiteral("Negotiate"));
|
||||
QCOMPARE(RdpSessionBackend::normalizedRdpSecurityMode(QString()),
|
||||
QStringLiteral("Negotiate"));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::normalizedRdpPerformanceProfileRecognizesKnownValues()
|
||||
{
|
||||
QCOMPARE(RdpSessionBackend::normalizedRdpPerformanceProfile(QStringLiteral("best quality")),
|
||||
QStringLiteral("Best Quality"));
|
||||
QCOMPARE(RdpSessionBackend::normalizedRdpPerformanceProfile(QStringLiteral(" Best Performance ")),
|
||||
QStringLiteral("Best Performance"));
|
||||
QCOMPARE(RdpSessionBackend::normalizedRdpPerformanceProfile(QStringLiteral("auto detect")),
|
||||
QStringLiteral("Auto Detect"));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::normalizedRdpPerformanceProfileFallsBackToBalanced()
|
||||
{
|
||||
QCOMPARE(RdpSessionBackend::normalizedRdpPerformanceProfile(QStringLiteral("bogus")),
|
||||
QStringLiteral("Balanced"));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::nearestFreeRdpScaleValueMapsToLegalValues()
|
||||
{
|
||||
// MS-RDPEDISP legally permits only {100, 140, 180} -- anything else is
|
||||
// silently ignored by the server.
|
||||
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(1.0), quint32(100));
|
||||
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(1.2), quint32(100));
|
||||
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(1.25), quint32(140));
|
||||
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(1.6), quint32(140));
|
||||
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(2.0), quint32(180));
|
||||
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(3.0), quint32(180));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::sanitizeDesktopWidthClampsToLegalRange()
|
||||
{
|
||||
QCOMPARE(RdpSessionBackend::sanitizeDesktopWidth(0), 1280);
|
||||
QCOMPARE(RdpSessionBackend::sanitizeDesktopWidth(-100), 1280);
|
||||
QCOMPARE(RdpSessionBackend::sanitizeDesktopWidth(100), 640);
|
||||
QCOMPARE(RdpSessionBackend::sanitizeDesktopWidth(1920), 1920);
|
||||
QCOMPARE(RdpSessionBackend::sanitizeDesktopWidth(99999), 8192);
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::sanitizeDesktopHeightClampsToLegalRange()
|
||||
{
|
||||
QCOMPARE(RdpSessionBackend::sanitizeDesktopHeight(0), 720);
|
||||
QCOMPARE(RdpSessionBackend::sanitizeDesktopHeight(-100), 720);
|
||||
QCOMPARE(RdpSessionBackend::sanitizeDesktopHeight(100), 360);
|
||||
QCOMPARE(RdpSessionBackend::sanitizeDesktopHeight(1080), 1080);
|
||||
QCOMPARE(RdpSessionBackend::sanitizeDesktopHeight(99999), 4320);
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::scancodeFromNativeScanCodeHandlesZero()
|
||||
{
|
||||
QCOMPARE(RdpSessionBackend::scancodeFromNativeScanCode(0), quint32(RDP_SCANCODE_UNKNOWN));
|
||||
}
|
||||
|
||||
#if defined(Q_OS_LINUX)
|
||||
void TestRdpSessionBackend::scancodeFromNativeScanCodeDelegatesToX11TableOnLinux()
|
||||
{
|
||||
// Our wrapper must be a faithful passthrough to FreeRDP's own
|
||||
// authoritative X11-keycode table, not a reimplementation of it.
|
||||
const quint32 apostropheKeycode = 0x30;
|
||||
#if defined(__GNUC__) || defined(__clang__)
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
#endif
|
||||
const quint32 expected =
|
||||
static_cast<quint32>(freerdp_keyboard_get_rdp_scancode_from_x11_keycode(apostropheKeycode));
|
||||
#if defined(__GNUC__) || defined(__clang__)
|
||||
#pragma GCC diagnostic pop
|
||||
#endif
|
||||
QCOMPARE(RdpSessionBackend::scancodeFromNativeScanCode(apostropheKeycode), expected);
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::scancodeFromNativeScanCodeDoesNotTreatX11KeycodeAsPcAtScancode()
|
||||
{
|
||||
// Regression guard for the historical bug this table replaced: X11
|
||||
// keycode 0x30 (apostrophe/quote) must NOT resolve to whatever a naive
|
||||
// "treat the X11 keycode as a PC/AT set-1 scancode" interpretation
|
||||
// would give (PC/AT 0x30 is the B key).
|
||||
const quint32 apostropheKeycode = 0x30;
|
||||
const quint32 naivePcAtInterpretation = MAKE_RDP_SCANCODE(apostropheKeycode, FALSE);
|
||||
QVERIFY(RdpSessionBackend::scancodeFromNativeScanCode(apostropheKeycode)
|
||||
!= naivePcAtInterpretation);
|
||||
}
|
||||
#endif
|
||||
|
||||
void TestRdpSessionBackend::scancodeForQtKeyMapsDirectKeys()
|
||||
{
|
||||
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Escape, Qt::NoModifier, 0),
|
||||
quint32(RDP_SCANCODE_ESCAPE));
|
||||
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_A, Qt::NoModifier, 0),
|
||||
quint32(RDP_SCANCODE_KEY_A));
|
||||
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_F1, Qt::NoModifier, 0),
|
||||
quint32(RDP_SCANCODE_F1));
|
||||
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Space, Qt::NoModifier, 0),
|
||||
quint32(RDP_SCANCODE_SPACE));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::scancodeForQtKeyRespectsKeypadModifier()
|
||||
{
|
||||
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Insert, Qt::NoModifier, 0),
|
||||
quint32(RDP_SCANCODE_INSERT));
|
||||
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Insert, Qt::KeypadModifier, 0),
|
||||
quint32(RDP_SCANCODE_NUMPAD0));
|
||||
|
||||
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Delete, Qt::NoModifier, 0),
|
||||
quint32(RDP_SCANCODE_DELETE));
|
||||
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Delete, Qt::KeypadModifier, 0),
|
||||
quint32(RDP_SCANCODE_DECIMAL));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::scancodeForQtKeyDisambiguatesLeftRightModifiers()
|
||||
{
|
||||
// With no reliable native scancode (0 -> RDP_SCANCODE_UNKNOWN, which
|
||||
// matches neither side), both Shift and Control must default to their
|
||||
// left variant rather than picking arbitrarily.
|
||||
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Shift, Qt::NoModifier, 0),
|
||||
quint32(RDP_SCANCODE_LSHIFT));
|
||||
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Control, Qt::NoModifier, 0),
|
||||
quint32(RDP_SCANCODE_LCONTROL));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::scancodeForQtKeyReturnsUnknownForUnhandledKey()
|
||||
{
|
||||
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_MediaPlay, Qt::NoModifier, 0),
|
||||
quint32(RDP_SCANCODE_UNKNOWN));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::mapRdpErrorRecognizesAuthFailureCodes()
|
||||
{
|
||||
const QString expected = QStringLiteral("Authentication failed. Check username and password.");
|
||||
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_LOGON_FAILURE), expected);
|
||||
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_WRONG_PASSWORD), expected);
|
||||
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_ACCESS_DENIED), expected);
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::mapRdpErrorRecognizesAccountStateCodes()
|
||||
{
|
||||
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_ACCOUNT_DISABLED),
|
||||
QStringLiteral("Account is disabled."));
|
||||
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_ACCOUNT_LOCKED_OUT),
|
||||
QStringLiteral("Account is locked out."));
|
||||
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_ACCOUNT_EXPIRED),
|
||||
QStringLiteral("Account has expired."));
|
||||
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_PASSWORD_EXPIRED),
|
||||
QStringLiteral("Password has expired."));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::mapRdpErrorRecognizesNetworkCodes()
|
||||
{
|
||||
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_DNS_NAME_NOT_FOUND),
|
||||
QStringLiteral("Host could not be resolved."));
|
||||
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_TRANSPORT_FAILED),
|
||||
QStringLiteral("Network transport failed while connecting."));
|
||||
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_SECURITY_NEGO_CONNECT_FAILED),
|
||||
QStringLiteral("RDP security negotiation failed. Try a different RDP security mode."));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::mapRdpErrorFallsBackForUnknownCode()
|
||||
{
|
||||
// Not a code mapRdpError special-cases; must still return something
|
||||
// non-empty rather than crashing or returning an empty string.
|
||||
const QString result = RdpSessionBackend::mapRdpError(0x7FFFFFFF);
|
||||
QVERIFY(!result.isEmpty());
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::isExpectedDisconnectCodeRecognizesBenignCodes()
|
||||
{
|
||||
QVERIFY(RdpSessionBackend::isExpectedDisconnectCode(FREERDP_ERROR_SUCCESS));
|
||||
QVERIFY(RdpSessionBackend::isExpectedDisconnectCode(FREERDP_ERROR_NONE));
|
||||
QVERIFY(RdpSessionBackend::isExpectedDisconnectCode(FREERDP_ERROR_LOGOFF_BY_USER));
|
||||
QVERIFY(RdpSessionBackend::isExpectedDisconnectCode(FREERDP_ERROR_IDLE_TIMEOUT));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::isExpectedDisconnectCodeRejectsAuthFailure()
|
||||
{
|
||||
// An authentication failure must be treated as a real error, never as
|
||||
// an expected/benign disconnect -- otherwise the user would see no
|
||||
// error message at all for a failed login.
|
||||
QVERIFY(!RdpSessionBackend::isExpectedDisconnectCode(FREERDP_ERROR_CONNECT_LOGON_FAILURE));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::isExpectedConnectAbortCodeRecognizesCancellation()
|
||||
{
|
||||
QVERIFY(RdpSessionBackend::isExpectedConnectAbortCode(FREERDP_ERROR_CONNECT_CANCELLED));
|
||||
QVERIFY(RdpSessionBackend::isExpectedConnectAbortCode(FREERDP_ERROR_SUCCESS));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::isExpectedConnectAbortCodeRejectsAuthFailure()
|
||||
{
|
||||
QVERIFY(!RdpSessionBackend::isExpectedConnectAbortCode(FREERDP_ERROR_CONNECT_LOGON_FAILURE));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::disconnectMessageForCodeRecognizesKnownCodes()
|
||||
{
|
||||
QCOMPARE(RdpSessionBackend::disconnectMessageForCode(FREERDP_ERROR_IDLE_TIMEOUT),
|
||||
QStringLiteral("RDP session disconnected due to idle timeout."));
|
||||
QCOMPARE(RdpSessionBackend::disconnectMessageForCode(FREERDP_ERROR_LOGOFF_BY_USER),
|
||||
QStringLiteral("RDP session signed out."));
|
||||
QCOMPARE(RdpSessionBackend::disconnectMessageForCode(FREERDP_ERROR_CONNECT_CANCELLED),
|
||||
QStringLiteral("Connection cancelled."));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::disconnectMessageForCodeFallsBackForUnknownCode()
|
||||
{
|
||||
QCOMPARE(RdpSessionBackend::disconnectMessageForCode(0x7FFFFFFF),
|
||||
QStringLiteral("RDP session ended."));
|
||||
}
|
||||
|
||||
void TestRdpSessionBackend::rdpErrorRawIncludesHexCode()
|
||||
{
|
||||
const QString result = RdpSessionBackend::rdpErrorRaw(FREERDP_ERROR_SUCCESS);
|
||||
QVERIFY(result.contains(QStringLiteral("(0x00000000)")));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(TestRdpSessionBackend)
|
||||
#include "test_rdp_session_backend.moc"
|
||||
@@ -0,0 +1,236 @@
|
||||
#include "ssh_session_backend.h"
|
||||
|
||||
#include <QTest>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#ifndef ORBITHUB_TEST_FIXTURES_DIR
|
||||
#error "ORBITHUB_TEST_FIXTURES_DIR must be defined by the build"
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
Profile makeProfile(const QString& fixtureHost)
|
||||
{
|
||||
Profile profile;
|
||||
profile.name = QStringLiteral("Test Profile");
|
||||
profile.host = fixtureHost;
|
||||
profile.port = 22;
|
||||
profile.username = QStringLiteral("tester");
|
||||
profile.protocol = QStringLiteral("SSH");
|
||||
profile.authMode = QStringLiteral("Password");
|
||||
return profile;
|
||||
}
|
||||
|
||||
SessionConnectOptions makeOptions()
|
||||
{
|
||||
SessionConnectOptions options;
|
||||
options.password = QStringLiteral("dummy-password");
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
class TestSshSessionBackend : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
// Pure-function coverage -- no process involved.
|
||||
void mapSshErrorRecognizesKnownPatterns();
|
||||
void mapSshErrorFallsBackForUnknownText();
|
||||
void mapSshErrorHandlesEmptyInput();
|
||||
void escapeForShellSingleQuotesNeutralizesQuotes();
|
||||
void escapeForShellSingleQuotesLeavesPlainTextAlone();
|
||||
|
||||
// State-machine coverage, driven against tests/fixtures/fake_ssh.sh
|
||||
// instead of a real ssh binary or network.
|
||||
void init();
|
||||
void cleanup();
|
||||
void successfulConnectReachesConnectedThenDisconnects();
|
||||
void authFailureReachesFailedStateWithMappedMessage();
|
||||
void connectionRefusedReachesFailedState();
|
||||
void sendInputEchoesThroughOutputReceived();
|
||||
void reconnectRestartsAndReachesConnectedAgain();
|
||||
|
||||
private:
|
||||
QString fixturePath() const;
|
||||
void createBackend(const QString& fixtureHost);
|
||||
|
||||
std::unique_ptr<SshSessionBackend> m_backend;
|
||||
SessionState m_lastState = SessionState::Disconnected;
|
||||
QString m_lastErrorDisplay;
|
||||
QString m_lastErrorRaw;
|
||||
QString m_receivedOutput;
|
||||
};
|
||||
|
||||
QString TestSshSessionBackend::fixturePath() const
|
||||
{
|
||||
return QStringLiteral(ORBITHUB_TEST_FIXTURES_DIR "/fake_ssh.sh");
|
||||
}
|
||||
|
||||
void TestSshSessionBackend::createBackend(const QString& fixtureHost)
|
||||
{
|
||||
m_backend =
|
||||
std::make_unique<SshSessionBackend>(makeProfile(fixtureHost), fixturePath(), nullptr);
|
||||
connect(m_backend.get(),
|
||||
&SessionBackend::stateChanged,
|
||||
this,
|
||||
[this](SessionState state, const QString&) { m_lastState = state; });
|
||||
connect(m_backend.get(),
|
||||
&SessionBackend::connectionError,
|
||||
this,
|
||||
[this](const QString& display, const QString& raw) {
|
||||
m_lastErrorDisplay = display;
|
||||
m_lastErrorRaw = raw;
|
||||
});
|
||||
connect(m_backend.get(),
|
||||
&SessionBackend::outputReceived,
|
||||
this,
|
||||
[this](const QString& chunk) { m_receivedOutput += chunk; });
|
||||
}
|
||||
|
||||
void TestSshSessionBackend::mapSshErrorRecognizesKnownPatterns()
|
||||
{
|
||||
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("Permission denied (publickey,password).")),
|
||||
QStringLiteral("Authentication failed. Check username and credentials."));
|
||||
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("Host key verification failed.")),
|
||||
QStringLiteral("Host key verification failed."));
|
||||
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: Could not resolve hostname bogus")),
|
||||
QStringLiteral("Host could not be resolved."));
|
||||
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: connect to host x port 22: Connection timed out")),
|
||||
QStringLiteral("Connection timed out."));
|
||||
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: connect to host x port 22: Connection refused")),
|
||||
QStringLiteral("Connection refused by remote host."));
|
||||
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: connect to host x port 22: No route to host")),
|
||||
QStringLiteral("No route to host."));
|
||||
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("Identity file /nope not accessible: No such file.")),
|
||||
QStringLiteral("Private key file is not accessible."));
|
||||
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("posix_spawn: /usr/bin/ssh-askpass: No such file or directory")),
|
||||
QStringLiteral("SSH password helper is missing or failed to launch."));
|
||||
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("open /some/other/path: No such file or directory")),
|
||||
QStringLiteral("Required file was not found."));
|
||||
}
|
||||
|
||||
void TestSshSessionBackend::mapSshErrorFallsBackForUnknownText()
|
||||
{
|
||||
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("some completely novel ssh error text")),
|
||||
QStringLiteral("SSH connection failed."));
|
||||
}
|
||||
|
||||
void TestSshSessionBackend::mapSshErrorHandlesEmptyInput()
|
||||
{
|
||||
QCOMPARE(SshSessionBackend::mapSshError(QString()),
|
||||
QStringLiteral("SSH connection failed for an unknown reason."));
|
||||
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral(" ")),
|
||||
QStringLiteral("SSH connection failed for an unknown reason."));
|
||||
}
|
||||
|
||||
void TestSshSessionBackend::escapeForShellSingleQuotesNeutralizesQuotes()
|
||||
{
|
||||
// A password containing a single quote must not be able to break out
|
||||
// of the single-quoted printf argument in the askpass script -- this
|
||||
// is the actual security boundary, not just cosmetic escaping.
|
||||
const QString malicious = QStringLiteral("pw' ; rm -rf ~ ; echo '");
|
||||
const QString escaped = SshSessionBackend::escapeForShellSingleQuotes(malicious);
|
||||
const QString reconstructedScriptArg = QStringLiteral("'") + escaped + QStringLiteral("'");
|
||||
// Every single quote in the reconstructed argument must be either the
|
||||
// outer boundary quote (open at index 0, close at the very end) or the
|
||||
// start of a full '"'"' re-opening sequence -- never a bare, unescaped
|
||||
// quote that could close the argument early.
|
||||
int index = 0;
|
||||
while (index < reconstructedScriptArg.length()) {
|
||||
if (reconstructedScriptArg.at(index) != QChar::fromLatin1('\'')) {
|
||||
++index;
|
||||
continue;
|
||||
}
|
||||
if (index == 0 || index == reconstructedScriptArg.length() - 1) {
|
||||
++index;
|
||||
continue;
|
||||
}
|
||||
QCOMPARE(reconstructedScriptArg.mid(index, 5), QStringLiteral("'\"'\"'"));
|
||||
index += 5;
|
||||
}
|
||||
}
|
||||
|
||||
void TestSshSessionBackend::escapeForShellSingleQuotesLeavesPlainTextAlone()
|
||||
{
|
||||
QCOMPARE(SshSessionBackend::escapeForShellSingleQuotes(QStringLiteral("plain-password-123")),
|
||||
QStringLiteral("plain-password-123"));
|
||||
}
|
||||
|
||||
void TestSshSessionBackend::init()
|
||||
{
|
||||
#ifdef Q_OS_WIN
|
||||
// fixtures/fake_ssh.sh is a POSIX shell script; there's no Windows
|
||||
// fixture yet, so skip only the tests that actually launch it. The
|
||||
// pure-function tests above (mapSshError*, escapeForShellSingleQuotes*)
|
||||
// don't touch the fixture and still run everywhere.
|
||||
const QByteArray currentTest = QTest::currentTestFunction();
|
||||
if (!currentTest.startsWith("mapSshError") && !currentTest.startsWith("escapeForShellSingleQuotes")) {
|
||||
QSKIP("No Windows equivalent of tests/fixtures/fake_ssh.sh yet");
|
||||
}
|
||||
#endif
|
||||
|
||||
m_lastState = SessionState::Disconnected;
|
||||
m_lastErrorDisplay.clear();
|
||||
m_lastErrorRaw.clear();
|
||||
m_receivedOutput.clear();
|
||||
// Individual tests call createBackend() with the fixture host they
|
||||
// need; most want "succeed", so provide it as the default here.
|
||||
createBackend(QStringLiteral("succeed"));
|
||||
}
|
||||
|
||||
void TestSshSessionBackend::cleanup()
|
||||
{
|
||||
if (m_backend) {
|
||||
m_backend->disconnectSession();
|
||||
}
|
||||
m_backend.reset();
|
||||
}
|
||||
|
||||
void TestSshSessionBackend::successfulConnectReachesConnectedThenDisconnects()
|
||||
{
|
||||
m_backend->connectSession(makeOptions());
|
||||
QTRY_COMPARE(m_lastState, SessionState::Connected);
|
||||
|
||||
m_backend->disconnectSession();
|
||||
QTRY_COMPARE(m_lastState, SessionState::Disconnected);
|
||||
}
|
||||
|
||||
void TestSshSessionBackend::authFailureReachesFailedStateWithMappedMessage()
|
||||
{
|
||||
createBackend(QStringLiteral("fail-auth"));
|
||||
m_backend->connectSession(makeOptions());
|
||||
QTRY_COMPARE(m_lastState, SessionState::Failed);
|
||||
QCOMPARE(m_lastErrorDisplay, QStringLiteral("Authentication failed. Check username and credentials."));
|
||||
QVERIFY(m_lastErrorRaw.contains(QStringLiteral("Permission denied")));
|
||||
}
|
||||
|
||||
void TestSshSessionBackend::connectionRefusedReachesFailedState()
|
||||
{
|
||||
createBackend(QStringLiteral("refuse"));
|
||||
m_backend->connectSession(makeOptions());
|
||||
QTRY_COMPARE(m_lastState, SessionState::Failed);
|
||||
QCOMPARE(m_lastErrorDisplay, QStringLiteral("Connection refused by remote host."));
|
||||
}
|
||||
|
||||
void TestSshSessionBackend::sendInputEchoesThroughOutputReceived()
|
||||
{
|
||||
m_backend->connectSession(makeOptions());
|
||||
QTRY_COMPARE(m_lastState, SessionState::Connected);
|
||||
|
||||
m_backend->sendInput(QStringLiteral("hello-from-test\n"));
|
||||
QTRY_VERIFY(m_receivedOutput.contains(QStringLiteral("hello-from-test")));
|
||||
}
|
||||
|
||||
void TestSshSessionBackend::reconnectRestartsAndReachesConnectedAgain()
|
||||
{
|
||||
m_backend->connectSession(makeOptions());
|
||||
QTRY_COMPARE(m_lastState, SessionState::Connected);
|
||||
|
||||
m_lastState = SessionState::Connecting;
|
||||
m_backend->reconnectSession(makeOptions());
|
||||
QTRY_COMPARE(m_lastState, SessionState::Connected);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(TestSshSessionBackend)
|
||||
#include "test_ssh_session_backend.moc"
|
||||
Reference in New Issue
Block a user