mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(dashboard): update check logic: versions, GitHub release and compare answers, build wording
Pure part of the update check: tag versions compared numerically, the latest
release and compare answers read (GitHub's error bodies refused), and the
running build worded for people ("release v3.0.0", "a local development
build of commit 1a2b3c4d with uncommitted changes"). Tested with small
hand-written samples of the answers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
194
dDashboardServer/notify/UpdateCheck.cpp
Normal file
194
dDashboardServer/notify/UpdateCheck.cpp
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
#include "UpdateCheck.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "json.hpp"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
bool IsNameChar(char c) {
|
||||||
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Plural(uint32_t count, const std::string& one, const std::string& many) {
|
||||||
|
return std::to_string(count) + " " + (count == 1 ? one : many);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ReleaseName(const UpdateCheck::Release& release) {
|
||||||
|
return release.version ? "v" + release.version->ToString() : release.tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t Count(const nlohmann::json& object, const char* key) {
|
||||||
|
const auto it = object.find(key);
|
||||||
|
if (it == object.end() || !it->is_number_integer() || it->get<int64_t>() < 0) return 0;
|
||||||
|
return static_cast<uint32_t>(std::min<int64_t>(it->get<int64_t>(), UINT32_MAX));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Text(const nlohmann::json& object, const char* key) {
|
||||||
|
const auto it = object.find(key);
|
||||||
|
return it != object.end() && it->is_string() ? it->get<std::string>() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace UpdateCheck {
|
||||||
|
std::string Version::ToString() const {
|
||||||
|
return std::to_string(major) + "." + std::to_string(minor) + "." + std::to_string(patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<Version> ParseVersion(std::string_view tag) {
|
||||||
|
if (!tag.empty() && (tag.front() == 'v' || tag.front() == 'V')) tag.remove_prefix(1);
|
||||||
|
uint32_t parts[3]{};
|
||||||
|
size_t part = 0;
|
||||||
|
bool any = false;
|
||||||
|
for (size_t i = 0; i < tag.size() && part < 3; i++) {
|
||||||
|
const char c = tag[i];
|
||||||
|
if (c >= '0' && c <= '9') {
|
||||||
|
const uint64_t next = static_cast<uint64_t>(parts[part]) * 10 + (c - '0');
|
||||||
|
if (next > UINT32_MAX) return std::nullopt;
|
||||||
|
parts[part] = static_cast<uint32_t>(next);
|
||||||
|
any = true;
|
||||||
|
} else if (c == '.' && any && i + 1 < tag.size() && tag[i + 1] >= '0' && tag[i + 1] <= '9') {
|
||||||
|
part++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!any) return std::nullopt;
|
||||||
|
return Version{ parts[0], parts[1], parts[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
Build CurrentBuild() {
|
||||||
|
Build build;
|
||||||
|
build.kind = BuildInfo::buildKind;
|
||||||
|
build.version = { BuildInfo::versionMajor, BuildInfo::versionMinor, BuildInfo::versionPatch };
|
||||||
|
build.commit = std::string(BuildInfo::commit);
|
||||||
|
build.branch = std::string(BuildInfo::branch);
|
||||||
|
build.dirty = BuildInfo::dirty;
|
||||||
|
return build;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string DescribeBuild(const Build& build) {
|
||||||
|
const std::string changes = build.dirty ? " with uncommitted changes" : "";
|
||||||
|
const std::string ofCommit = build.commit.empty() ? "" : " of commit " + build.ShortCommit();
|
||||||
|
switch (build.kind) {
|
||||||
|
case BuildInfo::eBuildKind::RELEASE:
|
||||||
|
return "release v" + build.version.ToString() + changes;
|
||||||
|
case BuildInfo::eBuildKind::LOCAL:
|
||||||
|
return "a local development build" + ofCommit + (build.commit.empty() ? " (no git commit recorded)" : "") + changes;
|
||||||
|
case BuildInfo::eBuildKind::CI:
|
||||||
|
return "a CI build" + ofCommit + changes;
|
||||||
|
default:
|
||||||
|
return "an unidentified build" + ofCommit + changes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string BuildLabel(const Build& build) {
|
||||||
|
const std::string commit = build.commit.empty() ? "" : " " + build.ShortCommit() + (build.dirty ? "*" : "");
|
||||||
|
switch (build.kind) {
|
||||||
|
case BuildInfo::eBuildKind::RELEASE: return "v" + build.version.ToString() + (build.dirty ? "*" : "");
|
||||||
|
case BuildInfo::eBuildKind::LOCAL: return "Local dev build" + commit;
|
||||||
|
case BuildInfo::eBuildKind::CI: return "CI build" + commit;
|
||||||
|
default: return "Unidentified build" + commit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<Release> ParseRelease(const std::string& body, std::string& error) {
|
||||||
|
const auto json = nlohmann::json::parse(body, nullptr, false);
|
||||||
|
if (!json.is_object()) {
|
||||||
|
error = "GitHub's answer about releases wasn't JSON";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
Release release;
|
||||||
|
release.tag = Text(json, "tag_name");
|
||||||
|
if (release.tag.empty()) {
|
||||||
|
const auto message = Text(json, "message");
|
||||||
|
error = message.empty() ? "GitHub's answer had no release in it" : "GitHub: " + message;
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
release.name = Text(json, "name");
|
||||||
|
release.url = Text(json, "html_url");
|
||||||
|
release.publishedAt = Text(json, "published_at");
|
||||||
|
release.version = ParseVersion(release.tag);
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<Comparison> ParseComparison(const std::string& body, std::string& error) {
|
||||||
|
const auto json = nlohmann::json::parse(body, nullptr, false);
|
||||||
|
if (!json.is_object()) {
|
||||||
|
error = "GitHub's comparison wasn't JSON";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
Comparison comparison;
|
||||||
|
comparison.status = Text(json, "status");
|
||||||
|
// GitHub's error bodies have a "status" too (the HTTP code), but no counts
|
||||||
|
const auto aheadBy = json.find("ahead_by");
|
||||||
|
if (comparison.status.empty() || aheadBy == json.end() || !aheadBy->is_number_integer()) {
|
||||||
|
const auto message = Text(json, "message");
|
||||||
|
error = message.empty() ? "GitHub's answer had no comparison in it" : "GitHub: " + message;
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
comparison.commitsBehind = Count(json, "ahead_by");
|
||||||
|
comparison.commitsAhead = Count(json, "behind_by");
|
||||||
|
comparison.url = Text(json, "html_url");
|
||||||
|
return comparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ErrorMessage(const std::string& body) {
|
||||||
|
const auto json = nlohmann::json::parse(body, nullptr, false);
|
||||||
|
return json.is_object() ? Text(json, "message") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsValidRepo(std::string_view repo) {
|
||||||
|
const auto slash = repo.find('/');
|
||||||
|
if (slash == std::string_view::npos || slash == 0 || slash + 1 >= repo.size() || repo.size() > 140) return false;
|
||||||
|
const auto owner = repo.substr(0, slash);
|
||||||
|
const auto name = repo.substr(slash + 1);
|
||||||
|
return std::ranges::all_of(owner, IsNameChar) && std::ranges::all_of(name, IsNameChar) && name != "." && name != "..";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsSafeBranch(std::string_view branch) {
|
||||||
|
if (branch.empty() || branch.size() > 200 || branch.front() == '/' || branch.find("..") != std::string_view::npos) return false;
|
||||||
|
return std::ranges::all_of(branch, [](char c) { return IsNameChar(c) || c == '/'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ReleaseIsNewer(const Build& build, const Status& status) {
|
||||||
|
return status.release && status.release->version && *status.release->version > build.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
eState State(const Build& build, const Status& status) {
|
||||||
|
if (!status.release && !status.comparison) return status.checkedAt == 0 ? eState::UNKNOWN : eState::FAILED;
|
||||||
|
if (ReleaseIsNewer(build, status)) return eState::UPDATE_AVAILABLE;
|
||||||
|
if (build.kind != BuildInfo::eBuildKind::RELEASE && status.comparison && status.comparison->commitsBehind > 0) return eState::UPDATE_AVAILABLE;
|
||||||
|
return eState::UP_TO_DATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Summary(const Build& build, const Status& status) {
|
||||||
|
const auto running = "this server is running " + DescribeBuild(build);
|
||||||
|
const auto state = State(build, status);
|
||||||
|
if (state == eState::UNKNOWN) return "Not checked for updates yet";
|
||||||
|
if (state == eState::FAILED) return "Could not check for updates: " + (status.error.empty() ? std::string("no answer") : status.error);
|
||||||
|
|
||||||
|
const bool devBehind = build.kind != BuildInfo::eBuildKind::RELEASE && status.comparison && status.comparison->commitsBehind > 0;
|
||||||
|
if (state == eState::UPDATE_AVAILABLE) {
|
||||||
|
std::string line = "Update available: ";
|
||||||
|
if (ReleaseIsNewer(build, status)) {
|
||||||
|
line += "release " + ReleaseName(*status.release);
|
||||||
|
if (devBehind) line += ", and ";
|
||||||
|
}
|
||||||
|
if (devBehind) line += Plural(status.comparison->commitsBehind, "newer commit", "newer commits") + " on " + status.comparedBranch;
|
||||||
|
line += " (" + running + ")";
|
||||||
|
const auto& url = devBehind ? status.comparison->url : status.release->url;
|
||||||
|
if (!url.empty()) line += ": " + url;
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string line = "Up to date";
|
||||||
|
if (build.kind != BuildInfo::eBuildKind::RELEASE && status.comparison) {
|
||||||
|
line += " with " + status.comparedBranch;
|
||||||
|
if (status.comparison->commitsAhead > 0) line += " (and " + Plural(status.comparison->commitsAhead, "commit", "commits") + " not on it)";
|
||||||
|
} else if (status.release) {
|
||||||
|
line += ": the newest release is " + ReleaseName(*status.release);
|
||||||
|
}
|
||||||
|
line += "; " + running;
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
dDashboardServer/notify/UpdateCheck.h
Normal file
101
dDashboardServer/notify/UpdateCheck.h
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "BuildInfo.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pure half of the update check: what build is running, how to read GitHub's answers about the configured
|
||||||
|
* repository, and how to say what they mean. The network half (a worker thread, ETags, the schedule) is in
|
||||||
|
* UpdateChecker. Nothing here reads the config or the network, so it is all testable from recorded answers.
|
||||||
|
*/
|
||||||
|
namespace UpdateCheck {
|
||||||
|
struct Version {
|
||||||
|
uint32_t major{};
|
||||||
|
uint32_t minor{};
|
||||||
|
uint32_t patch{};
|
||||||
|
|
||||||
|
auto operator<=>(const Version&) const = default;
|
||||||
|
std::string ToString() const; // "3.0.1"
|
||||||
|
};
|
||||||
|
|
||||||
|
// "v3.0.1", "3.0.1", "V3.1" or "v3.0.1-rc1": the numbers up to the first thing that isn't one. Empty without a number.
|
||||||
|
std::optional<Version> ParseVersion(std::string_view tag);
|
||||||
|
|
||||||
|
// What was built: filled from BuildInfo in the server, by hand in tests
|
||||||
|
struct Build {
|
||||||
|
BuildInfo::eBuildKind kind{};
|
||||||
|
Version version;
|
||||||
|
std::string commit; // full hash, empty without git
|
||||||
|
std::string branch; // empty when detached or without git
|
||||||
|
bool dirty{};
|
||||||
|
|
||||||
|
std::string ShortCommit() const { return commit.substr(0, 8); }
|
||||||
|
};
|
||||||
|
|
||||||
|
Build CurrentBuild();
|
||||||
|
|
||||||
|
// "release v3.0.0", "a local development build of commit 1a2b3c4d with uncommitted changes", "a CI build of commit
|
||||||
|
// 1a2b3c4d": the running build in a phrase that fits after "This server is running "
|
||||||
|
std::string DescribeBuild(const Build& build);
|
||||||
|
|
||||||
|
// Short form for a badge: "v3.0.0", "Local dev build 1a2b3c4d*", "CI build 1a2b3c4d"
|
||||||
|
std::string BuildLabel(const Build& build);
|
||||||
|
|
||||||
|
// ---- GitHub's answers ----
|
||||||
|
|
||||||
|
// GET /repos/{repo}/releases/latest (the newest release that is neither a draft nor a pre-release)
|
||||||
|
struct Release {
|
||||||
|
std::string tag;
|
||||||
|
std::string name;
|
||||||
|
std::string url; // the release notes on github.com
|
||||||
|
std::string publishedAt; // ISO 8601, as GitHub sends it
|
||||||
|
std::optional<Version> version; // from the tag
|
||||||
|
};
|
||||||
|
// Empty (with error set) for anything that isn't a release object
|
||||||
|
std::optional<Release> ParseRelease(const std::string& body, std::string& error);
|
||||||
|
|
||||||
|
// GET /repos/{repo}/compare/{build commit}...{branch}: where the branch is relative to the build
|
||||||
|
struct Comparison {
|
||||||
|
std::string status; // identical, ahead (the branch has newer commits), behind (the build has commits it lacks) or diverged
|
||||||
|
uint32_t commitsBehind{}; // commits on the branch the build doesn't have (GitHub's ahead_by)
|
||||||
|
uint32_t commitsAhead{}; // commits in the build the branch doesn't have (GitHub's behind_by)
|
||||||
|
std::string url; // the compare page on github.com
|
||||||
|
};
|
||||||
|
std::optional<Comparison> ParseComparison(const std::string& body, std::string& error);
|
||||||
|
|
||||||
|
// A GitHub error body's message ("Not Found", "API rate limit exceeded for ..."), or empty
|
||||||
|
std::string ErrorMessage(const std::string& body);
|
||||||
|
|
||||||
|
// Only owner/name of letters, digits, '-', '_' and '.'
|
||||||
|
bool IsValidRepo(std::string_view repo);
|
||||||
|
// A branch name safe to put in a compare URL (letters, digits, '-', '_', '.', '/')
|
||||||
|
bool IsSafeBranch(std::string_view branch);
|
||||||
|
|
||||||
|
// ---- What the check found ----
|
||||||
|
|
||||||
|
enum class eState : uint8_t {
|
||||||
|
UNKNOWN = 0, // not checked yet (or turned off)
|
||||||
|
UP_TO_DATE = 1,
|
||||||
|
UPDATE_AVAILABLE = 2,
|
||||||
|
FAILED = 3, // nothing could be found out
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Status {
|
||||||
|
std::optional<Release> release; // the newest release, when known
|
||||||
|
std::optional<Comparison> comparison; // development builds, when the build's commit is on GitHub
|
||||||
|
std::string comparedBranch; // the branch the comparison is against
|
||||||
|
std::string error; // what went wrong, if anything did (the rest may still be known)
|
||||||
|
int64_t checkedAt{}; // unix time of the last check, 0 before the first
|
||||||
|
};
|
||||||
|
|
||||||
|
// Whether the newest release is newer than the build's version
|
||||||
|
bool ReleaseIsNewer(const Build& build, const Status& status);
|
||||||
|
eState State(const Build& build, const Status& status);
|
||||||
|
|
||||||
|
// One line for the logs, e.g. "Update available: release v3.1.0 (this server is running release v3.0.0)"
|
||||||
|
std::string Summary(const Build& build, const Status& status);
|
||||||
|
}
|
||||||
@@ -65,6 +65,8 @@ set(DWEBTESTS_SOURCES
|
|||||||
"AlertsAndTwoFactorTests.cpp"
|
"AlertsAndTwoFactorTests.cpp"
|
||||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth/Totp.cpp"
|
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth/Totp.cpp"
|
||||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/notify/WebhookFormat.cpp"
|
"${PROJECT_SOURCE_DIR}/dDashboardServer/notify/WebhookFormat.cpp"
|
||||||
|
"UpdateCheckTests.cpp"
|
||||||
|
"${PROJECT_SOURCE_DIR}/dDashboardServer/notify/UpdateCheck.cpp"
|
||||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/email/OAuth2.cpp"
|
"${PROJECT_SOURCE_DIR}/dDashboardServer/email/OAuth2.cpp"
|
||||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/email/SmtpClient.cpp"
|
"${PROJECT_SOURCE_DIR}/dDashboardServer/email/SmtpClient.cpp"
|
||||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth/JWTUtils.cpp"
|
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth/JWTUtils.cpp"
|
||||||
|
|||||||
11
tests/dWebTests/UpdateCheckSamples/compare_behind.json
Normal file
11
tests/dWebTests/UpdateCheckSamples/compare_behind.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"url": "https://api.github.com/repos/example/server/compare/1a2b3c4d5e6f...main",
|
||||||
|
"html_url": "https://github.com/example/server/compare/1a2b3c4d5e6f...main",
|
||||||
|
"permalink_url": "https://github.com/example/server/compare/example:1a2b3c4...example:9f8e7d6",
|
||||||
|
"status": "ahead",
|
||||||
|
"ahead_by": 12,
|
||||||
|
"behind_by": 0,
|
||||||
|
"total_commits": 12,
|
||||||
|
"commits": [],
|
||||||
|
"files": []
|
||||||
|
}
|
||||||
7
tests/dWebTests/UpdateCheckSamples/compare_diverged.json
Normal file
7
tests/dWebTests/UpdateCheckSamples/compare_diverged.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"html_url": "https://github.com/example/server/compare/1a2b3c4d5e6f...main",
|
||||||
|
"status": "diverged",
|
||||||
|
"ahead_by": 1,
|
||||||
|
"behind_by": 3,
|
||||||
|
"total_commits": 1
|
||||||
|
}
|
||||||
5
tests/dWebTests/UpdateCheckSamples/not_found.json
Normal file
5
tests/dWebTests/UpdateCheckSamples/not_found.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"message": "Not Found",
|
||||||
|
"documentation_url": "https://docs.github.com/rest/releases/releases#get-the-latest-release",
|
||||||
|
"status": "404"
|
||||||
|
}
|
||||||
14
tests/dWebTests/UpdateCheckSamples/release_latest.json
Normal file
14
tests/dWebTests/UpdateCheckSamples/release_latest.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"url": "https://api.github.com/repos/example/server/releases/100",
|
||||||
|
"html_url": "https://github.com/example/server/releases/tag/v3.1.0",
|
||||||
|
"id": 100,
|
||||||
|
"tag_name": "v3.1.0",
|
||||||
|
"target_commitish": "main",
|
||||||
|
"name": "Version 3.1.0",
|
||||||
|
"draft": false,
|
||||||
|
"prerelease": false,
|
||||||
|
"created_at": "2026-08-01T10:00:00Z",
|
||||||
|
"published_at": "2026-08-02T12:30:00Z",
|
||||||
|
"assets": [],
|
||||||
|
"body": "Hand-written sample for the update check tests."
|
||||||
|
}
|
||||||
6
tests/dWebTests/UpdateCheckSamples/release_odd_tag.json
Normal file
6
tests/dWebTests/UpdateCheckSamples/release_odd_tag.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"html_url": "https://github.com/example/server/releases/tag/nightly",
|
||||||
|
"tag_name": "nightly",
|
||||||
|
"name": "",
|
||||||
|
"published_at": "2026-08-02T12:30:00Z"
|
||||||
|
}
|
||||||
199
tests/dWebTests/UpdateCheckTests.cpp
Normal file
199
tests/dWebTests/UpdateCheckTests.cpp
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include "UpdateCheck.h"
|
||||||
|
|
||||||
|
using namespace UpdateCheck;
|
||||||
|
using BuildInfo::eBuildKind;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Hand-written samples in the shape of GitHub's REST answers
|
||||||
|
std::string Sample(const std::string& name) {
|
||||||
|
std::ifstream in(std::filesystem::path(DLU_SOURCE_DIR) / "tests" / "dWebTests" / "UpdateCheckSamples" / name);
|
||||||
|
std::stringstream text;
|
||||||
|
text << in.rdbuf();
|
||||||
|
EXPECT_FALSE(text.str().empty()) << name;
|
||||||
|
return text.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
Build Make(eBuildKind kind, bool dirty = false, std::string commit = "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d") {
|
||||||
|
return { .kind = kind, .version = { 3, 0, 0 }, .commit = std::move(commit), .branch = "main", .dirty = dirty };
|
||||||
|
}
|
||||||
|
|
||||||
|
Release ReleaseOf(const std::string& tag) {
|
||||||
|
return { .tag = tag, .url = "https://github.com/example/server/releases/tag/" + tag, .version = ParseVersion(tag) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(UpdateCheckTests, VersionsParse) {
|
||||||
|
EXPECT_EQ(ParseVersion("v3.0.1"), (Version{ 3, 0, 1 }));
|
||||||
|
EXPECT_EQ(ParseVersion("3.0.1"), (Version{ 3, 0, 1 }));
|
||||||
|
EXPECT_EQ(ParseVersion("V3.1"), (Version{ 3, 1, 0 }));
|
||||||
|
EXPECT_EQ(ParseVersion("v10"), (Version{ 10, 0, 0 }));
|
||||||
|
EXPECT_EQ(ParseVersion("v3.0.1-rc1"), (Version{ 3, 0, 1 }));
|
||||||
|
EXPECT_EQ(ParseVersion("v3.0.1+g1a2b"), (Version{ 3, 0, 1 }));
|
||||||
|
EXPECT_EQ(ParseVersion("v3."), (Version{ 3, 0, 0 }));
|
||||||
|
EXPECT_FALSE(ParseVersion("nightly"));
|
||||||
|
EXPECT_FALSE(ParseVersion(""));
|
||||||
|
EXPECT_FALSE(ParseVersion("v"));
|
||||||
|
EXPECT_FALSE(ParseVersion("v99999999999"));
|
||||||
|
EXPECT_EQ((Version{ 3, 0, 1 }).ToString(), "3.0.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(UpdateCheckTests, VersionsCompareNumerically) {
|
||||||
|
EXPECT_LT(*ParseVersion("v3.0.9"), *ParseVersion("v3.0.10"));
|
||||||
|
EXPECT_LT(*ParseVersion("v3.9.0"), *ParseVersion("v3.10.0"));
|
||||||
|
EXPECT_LT(*ParseVersion("v2.99.99"), *ParseVersion("v3.0.0"));
|
||||||
|
EXPECT_EQ(*ParseVersion("v3.1"), *ParseVersion("3.1.0"));
|
||||||
|
EXPECT_GT(*ParseVersion("v3.0.1"), *ParseVersion("v3.0.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(UpdateCheckTests, ReleaseParses) {
|
||||||
|
std::string error;
|
||||||
|
const auto release = ParseRelease(Sample("release_latest.json"), error);
|
||||||
|
ASSERT_TRUE(release) << error;
|
||||||
|
EXPECT_EQ(release->tag, "v3.1.0");
|
||||||
|
EXPECT_EQ(release->name, "Version 3.1.0");
|
||||||
|
EXPECT_EQ(release->url, "https://github.com/example/server/releases/tag/v3.1.0");
|
||||||
|
EXPECT_EQ(release->publishedAt, "2026-08-02T12:30:00Z");
|
||||||
|
EXPECT_EQ(release->version, (Version{ 3, 1, 0 }));
|
||||||
|
|
||||||
|
// A tag that isn't a version is still a release, just never "newer"
|
||||||
|
const auto odd = ParseRelease(Sample("release_odd_tag.json"), error);
|
||||||
|
ASSERT_TRUE(odd);
|
||||||
|
EXPECT_EQ(odd->tag, "nightly");
|
||||||
|
EXPECT_FALSE(odd->version);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(UpdateCheckTests, ErrorsAndGarbageDontParse) {
|
||||||
|
std::string error;
|
||||||
|
EXPECT_FALSE(ParseRelease(Sample("not_found.json"), error));
|
||||||
|
EXPECT_EQ(error, "GitHub: Not Found");
|
||||||
|
EXPECT_EQ(ErrorMessage(Sample("not_found.json")), "Not Found");
|
||||||
|
EXPECT_FALSE(ParseRelease("<html>rate limited</html>", error));
|
||||||
|
EXPECT_FALSE(ParseRelease("[]", error));
|
||||||
|
EXPECT_FALSE(ParseRelease(R"({"tag_name": 5})", error));
|
||||||
|
EXPECT_FALSE(ParseComparison(Sample("not_found.json"), error)); // it has "status": "404"
|
||||||
|
EXPECT_EQ(error, "GitHub: Not Found");
|
||||||
|
EXPECT_FALSE(ParseComparison("", error));
|
||||||
|
EXPECT_EQ(ErrorMessage("not json"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(UpdateCheckTests, ComparisonsParse) {
|
||||||
|
std::string error;
|
||||||
|
const auto behind = ParseComparison(Sample("compare_behind.json"), error);
|
||||||
|
ASSERT_TRUE(behind) << error;
|
||||||
|
EXPECT_EQ(behind->status, "ahead");
|
||||||
|
EXPECT_EQ(behind->commitsBehind, 12u); // the branch is ahead of the build by 12
|
||||||
|
EXPECT_EQ(behind->commitsAhead, 0u);
|
||||||
|
EXPECT_EQ(behind->url, "https://github.com/example/server/compare/1a2b3c4d5e6f...main");
|
||||||
|
|
||||||
|
const auto diverged = ParseComparison(Sample("compare_diverged.json"), error);
|
||||||
|
ASSERT_TRUE(diverged);
|
||||||
|
EXPECT_EQ(diverged->commitsBehind, 1u);
|
||||||
|
EXPECT_EQ(diverged->commitsAhead, 3u);
|
||||||
|
|
||||||
|
// Negative or missing counts read as 0
|
||||||
|
const auto odd = ParseComparison(R"({"status": "identical", "ahead_by": -4})", error);
|
||||||
|
ASSERT_TRUE(odd);
|
||||||
|
EXPECT_EQ(odd->commitsBehind, 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(UpdateCheckTests, BuildWording) {
|
||||||
|
EXPECT_EQ(DescribeBuild(Make(eBuildKind::RELEASE)), "release v3.0.0");
|
||||||
|
EXPECT_EQ(DescribeBuild(Make(eBuildKind::LOCAL)), "a local development build of commit 1a2b3c4d");
|
||||||
|
EXPECT_EQ(DescribeBuild(Make(eBuildKind::LOCAL, true)), "a local development build of commit 1a2b3c4d with uncommitted changes");
|
||||||
|
EXPECT_EQ(DescribeBuild(Make(eBuildKind::LOCAL, false, "")), "a local development build (no git commit recorded)");
|
||||||
|
EXPECT_EQ(DescribeBuild(Make(eBuildKind::CI)), "a CI build of commit 1a2b3c4d");
|
||||||
|
EXPECT_EQ(DescribeBuild(Make(eBuildKind::UNKNOWN)), "an unidentified build of commit 1a2b3c4d");
|
||||||
|
|
||||||
|
EXPECT_EQ(BuildLabel(Make(eBuildKind::RELEASE)), "v3.0.0");
|
||||||
|
EXPECT_EQ(BuildLabel(Make(eBuildKind::LOCAL, true)), "Local dev build 1a2b3c4d*");
|
||||||
|
EXPECT_EQ(BuildLabel(Make(eBuildKind::LOCAL, false, "")), "Local dev build");
|
||||||
|
EXPECT_EQ(BuildLabel(Make(eBuildKind::CI)), "CI build 1a2b3c4d");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(UpdateCheckTests, ReleaseBuildState) {
|
||||||
|
const auto build = Make(eBuildKind::RELEASE);
|
||||||
|
Status status;
|
||||||
|
EXPECT_EQ(State(build, status), eState::UNKNOWN);
|
||||||
|
EXPECT_EQ(Summary(build, status), "Not checked for updates yet");
|
||||||
|
|
||||||
|
status.checkedAt = 1;
|
||||||
|
status.error = "Couldn't reach GitHub for the latest release: Timeout was reached";
|
||||||
|
EXPECT_EQ(State(build, status), eState::FAILED);
|
||||||
|
EXPECT_EQ(Summary(build, status), "Could not check for updates: Couldn't reach GitHub for the latest release: Timeout was reached");
|
||||||
|
|
||||||
|
status.error.clear();
|
||||||
|
status.release = ReleaseOf("v3.0.0");
|
||||||
|
EXPECT_EQ(State(build, status), eState::UP_TO_DATE);
|
||||||
|
EXPECT_EQ(Summary(build, status), "Up to date: the newest release is v3.0.0; this server is running release v3.0.0");
|
||||||
|
|
||||||
|
status.release = ReleaseOf("v3.1.0");
|
||||||
|
EXPECT_TRUE(ReleaseIsNewer(build, status));
|
||||||
|
EXPECT_EQ(State(build, status), eState::UPDATE_AVAILABLE);
|
||||||
|
EXPECT_EQ(Summary(build, status), "Update available: release v3.1.0 (this server is running release v3.0.0): https://github.com/example/server/releases/tag/v3.1.0");
|
||||||
|
|
||||||
|
// Release builds don't care about commits on a branch
|
||||||
|
status.release = ReleaseOf("v3.0.0");
|
||||||
|
status.comparison = Comparison{ .status = "ahead", .commitsBehind = 5 };
|
||||||
|
EXPECT_EQ(State(build, status), eState::UP_TO_DATE);
|
||||||
|
|
||||||
|
// An older or unversioned newest release isn't an update
|
||||||
|
status.comparison.reset();
|
||||||
|
status.release = ReleaseOf("v2.9.0");
|
||||||
|
EXPECT_EQ(State(build, status), eState::UP_TO_DATE);
|
||||||
|
status.release = ReleaseOf("nightly");
|
||||||
|
EXPECT_EQ(State(build, status), eState::UP_TO_DATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(UpdateCheckTests, DevelopmentBuildState) {
|
||||||
|
const auto build = Make(eBuildKind::LOCAL, true);
|
||||||
|
Status status;
|
||||||
|
status.checkedAt = 1;
|
||||||
|
status.release = ReleaseOf("v3.0.0");
|
||||||
|
status.comparedBranch = "main";
|
||||||
|
status.comparison = Comparison{ .status = "ahead", .commitsBehind = 12, .url = "https://github.com/example/server/compare/1a2b3c4d...main" };
|
||||||
|
EXPECT_EQ(State(build, status), eState::UPDATE_AVAILABLE);
|
||||||
|
EXPECT_EQ(Summary(build, status), "Update available: 12 newer commits on main (this server is running a local development build of commit 1a2b3c4d "
|
||||||
|
"with uncommitted changes): https://github.com/example/server/compare/1a2b3c4d...main");
|
||||||
|
|
||||||
|
status.comparison->commitsBehind = 1;
|
||||||
|
status.release = ReleaseOf("v3.1.0");
|
||||||
|
EXPECT_EQ(Summary(build, status), "Update available: release v3.1.0, and 1 newer commit on main (this server is running a local development build "
|
||||||
|
"of commit 1a2b3c4d with uncommitted changes): https://github.com/example/server/compare/1a2b3c4d...main");
|
||||||
|
|
||||||
|
status.release = ReleaseOf("v3.0.0");
|
||||||
|
status.comparison = Comparison{ .status = "behind", .commitsAhead = 3 };
|
||||||
|
EXPECT_EQ(State(build, status), eState::UP_TO_DATE);
|
||||||
|
EXPECT_EQ(Summary(build, status), "Up to date with main (and 3 commits not on it); this server is running a local development build of commit 1a2b3c4d with uncommitted changes");
|
||||||
|
|
||||||
|
// The commit isn't on GitHub: only the release is known
|
||||||
|
status.comparison.reset();
|
||||||
|
status.error = "Commit 1a2b3c4d isn't on GitHub";
|
||||||
|
EXPECT_EQ(State(build, status), eState::UP_TO_DATE);
|
||||||
|
EXPECT_EQ(Summary(Make(eBuildKind::CI), status), "Up to date: the newest release is v3.0.0; this server is running a CI build of commit 1a2b3c4d");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(UpdateCheckTests, RepoAndBranchNamesAreChecked) {
|
||||||
|
EXPECT_TRUE(IsValidRepo("DarkflameUniverse/DarkflameServer"));
|
||||||
|
EXPECT_TRUE(IsValidRepo("some-one/my.fork_2"));
|
||||||
|
EXPECT_FALSE(IsValidRepo("DarkflameServer"));
|
||||||
|
EXPECT_FALSE(IsValidRepo("/DarkflameServer"));
|
||||||
|
EXPECT_FALSE(IsValidRepo("a/"));
|
||||||
|
EXPECT_FALSE(IsValidRepo("a/b/c"));
|
||||||
|
EXPECT_FALSE(IsValidRepo("a/.."));
|
||||||
|
EXPECT_FALSE(IsValidRepo("a/b?x=1"));
|
||||||
|
EXPECT_FALSE(IsValidRepo("https://github.com/a/b"));
|
||||||
|
|
||||||
|
EXPECT_TRUE(IsSafeBranch("main"));
|
||||||
|
EXPECT_TRUE(IsSafeBranch("dev/someone/experimental"));
|
||||||
|
EXPECT_FALSE(IsSafeBranch(""));
|
||||||
|
EXPECT_FALSE(IsSafeBranch("a..b"));
|
||||||
|
EXPECT_FALSE(IsSafeBranch("a b"));
|
||||||
|
EXPECT_FALSE(IsSafeBranch("a?b"));
|
||||||
|
EXPECT_FALSE(IsSafeBranch("/main"));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user