diff --git a/dDashboardServer/notify/UpdateCheck.cpp b/dDashboardServer/notify/UpdateCheck.cpp new file mode 100644 index 000000000..38947d85d --- /dev/null +++ b/dDashboardServer/notify/UpdateCheck.cpp @@ -0,0 +1,194 @@ +#include "UpdateCheck.h" + +#include + +#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() < 0) return 0; + return static_cast(std::min(it->get(), 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() : ""; + } +} + +namespace UpdateCheck { + std::string Version::ToString() const { + return std::to_string(major) + "." + std::to_string(minor) + "." + std::to_string(patch); + } + + std::optional 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(parts[part]) * 10 + (c - '0'); + if (next > UINT32_MAX) return std::nullopt; + parts[part] = static_cast(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 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 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; + } +} diff --git a/dDashboardServer/notify/UpdateCheck.h b/dDashboardServer/notify/UpdateCheck.h new file mode 100644 index 000000000..e5232c515 --- /dev/null +++ b/dDashboardServer/notify/UpdateCheck.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include +#include + +#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 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; // from the tag + }; + // Empty (with error set) for anything that isn't a release object + std::optional 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 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; // the newest release, when known + std::optional 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); +} diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt index b174734cc..1cd3528e9 100644 --- a/tests/dWebTests/CMakeLists.txt +++ b/tests/dWebTests/CMakeLists.txt @@ -65,6 +65,8 @@ set(DWEBTESTS_SOURCES "AlertsAndTwoFactorTests.cpp" "${PROJECT_SOURCE_DIR}/dDashboardServer/auth/Totp.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/SmtpClient.cpp" "${PROJECT_SOURCE_DIR}/dDashboardServer/auth/JWTUtils.cpp" diff --git a/tests/dWebTests/UpdateCheckSamples/compare_behind.json b/tests/dWebTests/UpdateCheckSamples/compare_behind.json new file mode 100644 index 000000000..3eb23da51 --- /dev/null +++ b/tests/dWebTests/UpdateCheckSamples/compare_behind.json @@ -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": [] +} diff --git a/tests/dWebTests/UpdateCheckSamples/compare_diverged.json b/tests/dWebTests/UpdateCheckSamples/compare_diverged.json new file mode 100644 index 000000000..0b66e09b5 --- /dev/null +++ b/tests/dWebTests/UpdateCheckSamples/compare_diverged.json @@ -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 +} diff --git a/tests/dWebTests/UpdateCheckSamples/not_found.json b/tests/dWebTests/UpdateCheckSamples/not_found.json new file mode 100644 index 000000000..d782ff234 --- /dev/null +++ b/tests/dWebTests/UpdateCheckSamples/not_found.json @@ -0,0 +1,5 @@ +{ + "message": "Not Found", + "documentation_url": "https://docs.github.com/rest/releases/releases#get-the-latest-release", + "status": "404" +} diff --git a/tests/dWebTests/UpdateCheckSamples/release_latest.json b/tests/dWebTests/UpdateCheckSamples/release_latest.json new file mode 100644 index 000000000..46c539c7b --- /dev/null +++ b/tests/dWebTests/UpdateCheckSamples/release_latest.json @@ -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." +} diff --git a/tests/dWebTests/UpdateCheckSamples/release_odd_tag.json b/tests/dWebTests/UpdateCheckSamples/release_odd_tag.json new file mode 100644 index 000000000..adfa74767 --- /dev/null +++ b/tests/dWebTests/UpdateCheckSamples/release_odd_tag.json @@ -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" +} diff --git a/tests/dWebTests/UpdateCheckTests.cpp b/tests/dWebTests/UpdateCheckTests.cpp new file mode 100644 index 000000000..937f78858 --- /dev/null +++ b/tests/dWebTests/UpdateCheckTests.cpp @@ -0,0 +1,199 @@ +#include +#include +#include + +#include + +#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("rate limited", 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")); +}