Files
DarkflameServer/tests/dCommonTests/ZonePublishTests.cpp
Aaron Kimbrell b6636de0d9 fix(triggers): the spawner network deactivate command as the game names it
The shipped trigger files name the command DectivateSpawnerNetwork. The server
now reads that name as the deactivate spawner network command, so those triggers
run, and the edited file checks take it. The correctly spelt name stays as an alias.
The shipped files test no longer lets unknown trigger ids through for the
retail clients.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 20:09:06 -06:00

692 lines
29 KiB
C++

#include <gtest/gtest.h>
#include <cstring>
#include <filesystem>
#include <fstream>
#include "AssetManager.h"
#include "dCommonDependencies.h"
#include "LevelFile.h"
#include "ZoneEditLock.h"
#include "ZoneOverrideStore.h"
#include "ZonePublish.h"
#include <map>
#include <string>
#include "eTriggerCommandType.h"
namespace fs = std::filesystem;
namespace {
struct Bytes {
std::string data;
template<typename T> Bytes& Put(T value) { data.append(reinterpret_cast<const char*>(&value), sizeof(T)); return *this; }
template<typename T> void At(size_t pos, T value) { std::memcpy(data.data() + pos, &value, sizeof(T)); }
Bytes& Text(const std::string& text) { Put<uint8_t>(static_cast<uint8_t>(text.size())); data += text; return *this; }
Bytes& Point(float x, float y, float z) { return Put(x).Put(y).Put(z); }
};
const std::string FOLDER = "01_live_maps/test_zone/";
const std::string LUZ = FOLDER + "test_zone.luz";
// A version 41 zone file with these scenes and terrain file
std::string ZoneBytes(const std::vector<std::string>& scenes, const std::string& terrain = "test_zone.raw") {
Bytes w;
w.Put<uint32_t>(41).Put<uint32_t>(3).Put<uint32_t>(1900);
w.Point(1, 2, 3).Put(1.0f).Put(0.0f).Put(0.0f).Put(0.0f);
w.Put<uint32_t>(static_cast<uint32_t>(scenes.size()));
uint32_t id = 0;
for (const auto& scene : scenes) w.Text(scene).Put<uint32_t>(id++).Put<uint32_t>(0).Text("Scene").Put<uint8_t>(1).Put<uint8_t>(2).Put<uint8_t>(3);
w.Put<uint8_t>(0).Text(terrain).Text("Name").Text("Description");
w.Put<uint32_t>(0); // scene transitions
w.Put<uint32_t>(8).Put<uint32_t>(1).Put<uint32_t>(0); // the path chunk: version 1, no paths
return w.data;
}
// A scene file from before chunks (version 30) with these objects' settings
std::string LevelBytes(const std::vector<std::string>& objects) {
Bytes w;
w.Put<uint16_t>(30).Put<uint16_t>(0);
w.data.append(48 + 12, '\0');
w.Put<uint32_t>(0).Put<uint32_t>(static_cast<uint32_t>(objects.size()));
int32_t lot = 1;
for (const auto& settings : objects) {
w.Put<int64_t>(lot * 10).Put<int32_t>(lot);
lot++;
w.Point(0, 0, 0).Put(1.0f).Put(0.0f).Put(0.0f).Put(0.0f).Put(1.0f);
w.Put<uint32_t>(static_cast<uint32_t>(settings.size()));
for (char c : settings) w.Put<uint16_t>(static_cast<uint16_t>(c));
w.Put<uint32_t>(0);
}
return w.data;
}
const char* GOOD_TRIGGERS = "<triggers nextID=\"2\"><trigger id=\"1\" enabled=\"1\">"
"<event id=\"OnEnter\"><command id=\"fireEvent\" target=\"self\" args=\"go\"/></event></trigger></triggers>";
// The readers log what they refuse
struct Fixture : dCommonDependenciesTest {
fs::path dir;
fs::path root;
ZonePublish::Zone zone;
ZonePublish::Limits limits;
std::vector<ZonePublish::Upload> uploads;
uint32_t counter = 0;
void SetUp() override {
SetUpDependencies();
dir = fs::temp_directory_path() / ("dlu-zone-publish-" + std::to_string(::testing::UnitTest::GetInstance()->random_seed()) + "-" +
::testing::UnitTest::GetInstance()->current_test_info()->name());
fs::remove_all(dir);
fs::create_directories(dir / "in");
root = dir / "override";
zone.zoneId = 1900;
zone.luzPath = LUZ;
zone.clientScenes = { FOLDER + "a.lvl", FOLDER + "b.lvl" };
zone.hasFile = [](const std::string&) { return true; };
}
void TearDown() override {
fs::remove_all(dir);
TearDownDependencies();
Game::logger = nullptr;
}
void Add(const std::string& name, const std::string& data) {
const auto file = dir / "in" / std::to_string(counter++);
std::ofstream(file, std::ios::binary) << data;
uploads.push_back({ name, file, data.size() });
}
void AddGoodSet() {
Add(LUZ, ZoneBytes({ "a.lvl", "b.lvl" }));
Add(FOLDER + "a.lvl", LevelBytes({ "a=0:b" }));
Add(FOLDER + "a.lutriggers", GOOD_TRIGGERS);
}
ZonePublish::Result Run() { return ZonePublish::Validate(zone, uploads, limits); }
static bool Has(const ZonePublish::Result& result, const std::string& path, const std::string& code) {
for (const auto& file : result.files) {
if (file.path != path) continue;
for (const auto& error : file.errors) if (error.code == code) return true;
}
return false;
}
// Apply what Validate passed, as a transport would
ZoneOverrideStore::Applied Apply(const ZonePublish::Result& result, const std::string& id, int64_t now, uint32_t keep = 20, const std::string& note = "") {
std::vector<ZoneOverrideStore::NewFile> files;
for (const auto& file : result.files) files.push_back({ file.path, file.source, file.bytes, file.sha256, file.version });
return ZoneOverrideStore::Apply(root, { zone.zoneId, FOLDER, id, "editor", note, now, keep }, files);
}
static std::string Slurp(const fs::path& path) {
std::ifstream in(path, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(in), {});
}
// Every file under the override folder's maps/, for "nothing was written"
std::vector<std::string> Written() const {
std::vector<std::string> files;
std::error_code code;
for (fs::recursive_directory_iterator it(root, code), end; !code && it != end; it.increment(code)) {
if (it->is_regular_file()) files.push_back(it->path().lexically_relative(root).generic_string());
}
return files;
}
};
using ZonePublishTests = Fixture;
using ZoneOverrideStoreTests = Fixture;
}
TEST(ZonePublishNameTests, RefusesTraversalAbsoluteBackslashAndNul) {
EXPECT_FALSE(ZonePublish::NameProblem("01_live_maps/test_zone/a.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem(""));
EXPECT_TRUE(ZonePublish::NameProblem("../a.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem("01_live_maps/../a.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem("01_live_maps/test_zone/.."));
EXPECT_TRUE(ZonePublish::NameProblem("./a.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem("/etc/passwd"));
EXPECT_TRUE(ZonePublish::NameProblem("C:/windows/a.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem("01_live_maps\\test_zone\\a.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem(std::string("a.lvl\0.luz", 10)));
EXPECT_TRUE(ZonePublish::NameProblem("a//b.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem("a/b.lvl/"));
EXPECT_TRUE(ZonePublish::NameProblem("a/b.lvl."));
EXPECT_TRUE(ZonePublish::NameProblem("a/ b.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem("a/b\n.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem("a/b\xC3\xA9.lvl")); // no bytes outside ASCII, so no look-alike letters
EXPECT_TRUE(ZonePublish::NameProblem(std::string(ZonePublish::MAX_NAME_LENGTH + 1, 'a')));
EXPECT_EQ(ZonePublish::Canonical("01_Live_Maps/Test_Zone/A.LVL"), "01_live_maps/test_zone/a.lvl");
}
TEST_F(ZonePublishTests, ResolveInsideFollowsLinksOutOfTheRoot) {
fs::create_directories(root / "maps");
fs::create_directories(dir / "elsewhere");
EXPECT_TRUE(ZonePublish::ResolveInside(root / "maps", "zone/a.lvl"));
EXPECT_FALSE(ZonePublish::ResolveInside(root / "maps", "../a.lvl"));
EXPECT_FALSE(ZonePublish::ResolveInside(root / "maps", "/a.lvl"));
// A link inside the root that points out of it is followed before the check
std::error_code code;
fs::create_directory_symlink(dir / "elsewhere", root / "maps" / "link", code);
if (!code) EXPECT_FALSE(ZonePublish::ResolveInside(root / "maps", "link/a.lvl"));
}
TEST_F(ZonePublishTests, GoodSetPasses) {
AddGoodSet();
const auto result = Run();
ASSERT_TRUE(result.ok);
ASSERT_EQ(result.files.size(), 3u);
EXPECT_EQ(result.files[0].path, LUZ);
EXPECT_EQ(result.files[0].version, 41u);
EXPECT_EQ(result.files[1].version, 30u);
EXPECT_EQ(result.files[0].sha256.size(), 64u);
EXPECT_EQ(result.files[0].sha256, ZonePublish::Sha256(ZoneBytes({ "a.lvl", "b.lvl" })));
EXPECT_EQ(result.objects, 1u);
}
TEST(ZonePublishHashTests, Sha256MatchesKnownValue) {
EXPECT_EQ(ZonePublish::Sha256("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
}
TEST_F(ZonePublishTests, NamesAreCheckedBeforeAnythingIsRead) {
Add(LUZ, ZoneBytes({ "a.lvl" }));
Add("../../etc/passwd", "x");
Add("/etc/passwd", "x");
Add("01_live_maps\\test_zone\\a.lvl", LevelBytes({}));
Add(std::string("01_live_maps/test_zone/a.lvl\0x", 30), LevelBytes({}));
Add("01_live_maps/other_zone/a.lvl", LevelBytes({})); // another zone's folder
Add(FOLDER + "c.lvl", LevelBytes({})); // not a scene the .luz lists
Add(FOLDER + "notes.txt", "x");
const auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(result.files[0].ok);
for (size_t i = 1; i < result.files.size(); ++i) {
ASSERT_FALSE(result.files[i].ok) << result.files[i].path;
EXPECT_EQ(result.files[i].errors[0].code, "name_not_allowed") << result.files[i].path;
}
}
TEST_F(ZonePublishTests, CaseTricksAreDuplicates) {
AddGoodSet();
Add("01_Live_Maps/Test_Zone/A.LVL", LevelBytes({}));
const auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_EQ(result.files[3].errors[0].code, "duplicate");
}
TEST_F(ZonePublishTests, MissingLuz) {
Add(FOLDER + "a.lvl", LevelBytes({}));
const auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(Has(result, LUZ, "missing_luz"));
}
TEST_F(ZonePublishTests, UnknownSceneIsASceneMismatch) {
// The .luz lists a scene the zone doesn't have in the client's files
Add(LUZ, ZoneBytes({ "a.lvl", "new_scene.lvl" }));
Add(FOLDER + "new_scene.lvl", LevelBytes({}));
const auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(Has(result, LUZ, "scene_mismatch"));
}
TEST_F(ZonePublishTests, SceneNamedOutsideTheFolderIsASceneMismatch) {
Add(LUZ, ZoneBytes({ "../other_zone/a.lvl" }));
const auto result = Run();
EXPECT_TRUE(Has(result, LUZ, "scene_mismatch"));
}
TEST_F(ZonePublishTests, SceneTheZoneLacksIsASceneMismatch) {
// Listed, a scene of the zone, but neither in the set nor on the server
zone.hasFile = [](const std::string& path) { return !path.ends_with("b.lvl"); };
AddGoodSet();
const auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(Has(result, LUZ, "scene_mismatch"));
}
TEST_F(ZonePublishTests, TerrainMismatch) {
Add(LUZ, ZoneBytes({ "a.lvl" }, "test_zone.raw"));
Add(FOLDER + "other.raw", "x");
auto result = Run();
EXPECT_TRUE(Has(result, FOLDER + "other.raw", "terrain_mismatch"));
uploads.clear();
Add(LUZ, ZoneBytes({ "a.lvl" }, "../elsewhere.raw"));
result = Run();
EXPECT_TRUE(Has(result, LUZ, "terrain_mismatch"));
uploads.clear();
zone.hasFile = [](const std::string& path) { return !path.ends_with(".raw"); };
Add(LUZ, ZoneBytes({ "a.lvl" }));
result = Run();
EXPECT_TRUE(Has(result, LUZ, "terrain_mismatch"));
}
TEST_F(ZonePublishTests, TruncatedFilesFailToParse) {
auto luz = ZoneBytes({ "a.lvl" });
Add(LUZ, luz.substr(0, luz.size() - 3));
auto result = Run();
EXPECT_TRUE(Has(result, LUZ, "parse_failed"));
uploads.clear();
auto level = LevelBytes({ "a=0:b", "c=0:d" });
Add(LUZ, luz);
Add(FOLDER + "a.lvl", level.substr(0, level.size() - 2));
result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(Has(result, FOLDER + "a.lvl", "parse_failed"));
EXPECT_TRUE(result.files[1].errors[0].offset.has_value());
uploads.clear();
Add(LUZ, luz + "extra");
result = Run();
EXPECT_TRUE(Has(result, LUZ, "parse_failed"));
uploads.clear();
Add(LUZ, luz);
Add(FOLDER + "test_zone.raw", "not a terrain file");
result = Run();
EXPECT_TRUE(Has(result, FOLDER + "test_zone.raw", "parse_failed"));
}
TEST_F(ZonePublishTests, UnsupportedVersions) {
auto luz = ZoneBytes({ "a.lvl" });
const uint32_t version = 99;
std::memcpy(luz.data(), &version, 4);
Add(LUZ, luz);
auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(Has(result, LUZ, "unsupported_version") || Has(result, LUZ, "parse_failed"));
uploads.clear();
Bytes level;
level.Put<uint16_t>(60).Put<uint16_t>(0).Put<uint8_t>(0).Put<uint32_t>(1);
Add(LUZ, ZoneBytes({ "a.lvl" }));
Add(FOLDER + "a.lvl", level.data);
result = Run();
EXPECT_TRUE(Has(result, FOLDER + "a.lvl", "unsupported_version"));
}
TEST_F(ZonePublishTests, TriggerChecks) {
Add(LUZ, ZoneBytes({ "a.lvl" }));
Add(FOLDER + "a.lutriggers", "<triggers><trigger id=\"1\" enabled=\"1\">"
"<event id=\"OnEnter\"><command id=\"fireEvent\" args=\"go\"/></event>"
"<event id=\"OnMadeUp\"><command id=\"madeUpCommand\" target=\"self\"/></event></trigger></triggers>");
auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(Has(result, FOLDER + "a.lutriggers", "trigger_missing_target"));
EXPECT_TRUE(Has(result, FOLDER + "a.lutriggers", "unknown_trigger_event"));
EXPECT_TRUE(Has(result, FOLDER + "a.lutriggers", "unknown_trigger_command"));
EXPECT_EQ(ZonePublish::CheckTriggers("<triggers><trigger id=\"1\"").at(0).code, "parse_failed");
EXPECT_EQ(ZonePublish::CheckTriggers("<other/>").at(0).code, "parse_failed");
EXPECT_TRUE(ZonePublish::CheckTriggers("").empty());
EXPECT_TRUE(ZonePublish::CheckTriggers(GOOD_TRIGGERS).empty());
// The name the game's fv_pp_01_instance.lutriggers uses is the deactivate command, a different misspelling is not
EXPECT_TRUE(ZonePublish::CheckTriggers("<triggers><trigger id=\"1\"><event id=\"OnCreate\">"
"<command id=\"DectivateSpawnerNetwork\" target=\"objGroup\"/></event></trigger></triggers>").empty());
EXPECT_EQ(TriggerCommandType::StringToTriggerCommandType("DectivateSpawnerNetwork"), eTriggerCommandType::DEACTIVATE_SPAWNER_NETWORK);
EXPECT_EQ(TriggerCommandType::StringToTriggerCommandType("DeactivateSpawnerNetwork"), eTriggerCommandType::DEACTIVATE_SPAWNER_NETWORK);
EXPECT_EQ(ZonePublish::CheckTriggers("<triggers><trigger id=\"1\"><event id=\"OnCreate\">"
"<command id=\"DectivateSpawnerNetworks\" target=\"objGroup\"/></event></trigger></triggers>").at(0).code, "unknown_trigger_command");
}
TEST_F(ZonePublishTests, BadObjectConfigAndObjectCap) {
Add(LUZ, ZoneBytes({ "a.lvl", "b.lvl" }));
Add(FOLDER + "a.lvl", LevelBytes({ "good=0:yes", "broken=:" }));
auto result = Run();
EXPECT_TRUE(Has(result, FOLDER + "a.lvl", "bad_object_config"));
uploads.clear();
limits.maxObjects = 3;
Add(LUZ, ZoneBytes({ "a.lvl", "b.lvl" }));
Add(FOLDER + "a.lvl", LevelBytes({ "a=0:b", "a=0:b" }));
Add(FOLDER + "b.lvl", LevelBytes({ "a=0:b", "a=0:b" }));
result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(Has(result, FOLDER + "b.lvl", "too_many_objects"));
}
TEST_F(ZonePublishTests, SizeLimits) {
limits.maxFileBytes = 64;
Add(LUZ, ZoneBytes({ "a.lvl" }));
Add(FOLDER + "a.lvl", LevelBytes({ std::string(200, 'x') + "=0:b" }));
auto result = Run();
EXPECT_TRUE(Has(result, FOLDER + "a.lvl", "too_large"));
limits = {};
limits.maxFiles = 1;
result = Run();
EXPECT_TRUE(Has(result, FOLDER + "a.lvl", "too_large"));
limits = {};
limits.maxTotalBytes = uploads[0].bytes + 1;
result = Run();
EXPECT_TRUE(Has(result, FOLDER + "a.lvl", "too_large"));
}
// One bad file refuses the whole set, and nothing of it is written
TEST_F(ZonePublishTests, AllOrNothing) {
AddGoodSet();
Add(FOLDER + "b.lvl", "garbage");
const auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(result.files[0].ok);
EXPECT_TRUE(result.files[1].ok);
EXPECT_FALSE(result.files[3].ok);
EXPECT_TRUE(Written().empty());
EXPECT_TRUE(ZoneOverrideStore::Current(root, zone.zoneId, FOLDER).empty());
}
TEST_F(ZoneOverrideStoreTests, ApplyPutsTheSetWhereTheAssetManagerReads) {
AddGoodSet();
const auto level = Slurp(uploads[1].file);
const auto applied = Apply(Run(), "p1", 1000);
EXPECT_FALSE(applied.backupId);
ASSERT_EQ(applied.files.size(), 3u);
EXPECT_EQ(Slurp(root / "maps" / FOLDER / "a.lvl"), level);
EXPECT_FALSE(fs::exists(root / "staging" / "p1"));
const auto current = ZoneOverrideStore::Current(root, zone.zoneId, FOLDER);
ASSERT_EQ(current.size(), 3u);
EXPECT_EQ(current[1].path, FOLDER + "a.lvl");
EXPECT_EQ(current[1].bytes, level.size());
EXPECT_EQ(current[1].sha256, ZonePublish::Sha256(level));
EXPECT_EQ(current[1].who, "editor");
EXPECT_EQ(current[1].when, 1000);
// The asset manager serves it for a name under maps/, whatever the name's case, and nothing else
const auto name = AssetManager::OverrideName("maps\\01_Live_Maps\\Test_Zone\\A.LVL");
ASSERT_TRUE(name);
EXPECT_EQ(Slurp(root / *name), level);
EXPECT_FALSE(AssetManager::OverrideName("macros/a.scm"));
EXPECT_FALSE(AssetManager::OverrideName("maps/../cdclient.fdb"));
EXPECT_FALSE(AssetManager::OverrideName("maps//a.lvl"));
}
TEST_F(ZoneOverrideStoreTests, ASecondSetReplacesTheWholeFirstAndBacksItUp) {
AddGoodSet();
Apply(Run(), "p1", 1000, 20, "first");
uploads.clear();
Add(LUZ, ZoneBytes({ "a.lvl" }));
Add(FOLDER + "a.lvl", LevelBytes({ "second=0:set" }));
const auto second = Slurp(uploads[1].file);
const auto applied = Apply(Run(), "p2", 2000, 20, "second");
ASSERT_TRUE(applied.backupId);
EXPECT_EQ(*applied.backupId, "2000-p2");
EXPECT_EQ(Slurp(root / "maps" / FOLDER / "a.lvl"), second);
// The first set's triggers are not part of the second: gone from what is served
EXPECT_FALSE(fs::exists(root / "maps" / FOLDER / "a.lutriggers"));
const auto backups = ZoneOverrideStore::Backups(root, zone.zoneId);
ASSERT_EQ(backups.size(), 1u);
EXPECT_EQ(backups[0].id, "2000-p2");
EXPECT_EQ(backups[0].time, 2000);
EXPECT_EQ(backups[0].who, "editor");
EXPECT_EQ(backups[0].note, "first");
EXPECT_EQ(backups[0].files.size(), 3u);
}
TEST_F(ZoneOverrideStoreTests, RestoreAndClearAreOneCallEach) {
AddGoodSet();
const auto first = Slurp(uploads[1].file);
Apply(Run(), "p1", 1000);
uploads.clear();
Add(LUZ, ZoneBytes({ "a.lvl" }));
Add(FOLDER + "a.lvl", LevelBytes({ "second=0:set" }));
const auto backupId = *Apply(Run(), "p2", 2000).backupId;
const auto restored = ZoneOverrideStore::Restore(root, { zone.zoneId, FOLDER, "r1", "admin", "", 3000, 20 }, backupId);
EXPECT_EQ(restored.files.size(), 3u);
EXPECT_EQ(Slurp(root / "maps" / FOLDER / "a.lvl"), first);
EXPECT_TRUE(fs::exists(root / "maps" / FOLDER / "a.lutriggers"));
// What the restore replaced is itself kept
ASSERT_TRUE(restored.backupId);
EXPECT_EQ(ZoneOverrideStore::Backups(root, zone.zoneId).size(), 2u);
EXPECT_THROW(ZoneOverrideStore::Restore(root, { zone.zoneId, FOLDER, "r2", "admin", "", 3500, 20 }, "9999-nothere"), ZoneOverrideStore::StoreError);
EXPECT_THROW(ZoneOverrideStore::Restore(root, { zone.zoneId, FOLDER, "r3", "admin", "", 3500, 20 }, "../../maps"), ZoneOverrideStore::StoreError);
EXPECT_EQ(Slurp(root / "maps" / FOLDER / "a.lvl"), first);
const auto cleared = ZoneOverrideStore::Clear(root, { zone.zoneId, FOLDER, "c1", "admin", "", 4000, 20 });
EXPECT_TRUE(cleared.files.empty());
EXPECT_TRUE(cleared.backupId);
EXPECT_FALSE(fs::exists(root / "maps" / FOLDER / "a.lvl"));
EXPECT_TRUE(ZoneOverrideStore::Current(root, zone.zoneId, FOLDER).empty());
EXPECT_EQ(ZoneOverrideStore::Backups(root, zone.zoneId).size(), 3u);
}
TEST_F(ZoneOverrideStoreTests, RestoreRefusesABackupThatWasChanged) {
AddGoodSet();
Apply(Run(), "p1", 1000);
const auto backupId = *ZoneOverrideStore::Clear(root, { zone.zoneId, FOLDER, "c1", "admin", "", 2000, 20 }).backupId;
const auto kept = root / "backups" / "1900" / backupId / "files" / FOLDER / "a.lvl";
fs::remove(kept);
std::ofstream(kept, std::ios::binary) << "changed";
EXPECT_THROW(ZoneOverrideStore::Restore(root, { zone.zoneId, FOLDER, "r1", "admin", "", 3000, 20 }, backupId), ZoneOverrideStore::StoreError);
EXPECT_TRUE(ZoneOverrideStore::Current(root, zone.zoneId, FOLDER).empty());
}
TEST_F(ZoneOverrideStoreTests, KeepsTheNewestBackups) {
for (int i = 0; i < 6; ++i) {
uploads.clear();
Add(LUZ, ZoneBytes({ "a.lvl" }));
Add(FOLDER + "a.lvl", LevelBytes({ "set=1:" + std::to_string(i) }));
Apply(Run(), "p" + std::to_string(i), 1000 + i, 3);
}
const auto backups = ZoneOverrideStore::Backups(root, zone.zoneId);
ASSERT_EQ(backups.size(), 3u);
EXPECT_EQ(backups[0].id, "1005-p5");
EXPECT_EQ(backups[2].id, "1003-p3");
}
TEST_F(ZoneOverrideStoreTests, AnotherZoneInTheSameFolderKeepsItsSet) {
AddGoodSet();
Apply(Run(), "p1", 1000);
// Zone 1901 shares the folder
const auto other = FOLDER + "other_zone.luz";
const auto file = dir / "in" / "other";
std::ofstream(file, std::ios::binary) << "other zone";
ZoneOverrideStore::Apply(root, { 1901, FOLDER, "o1", "editor", "", 1500, 20 }, { { other, file, 10, ZonePublish::Sha256("other zone"), 41 } });
EXPECT_EQ(ZoneOverrideStore::Current(root, 1900, FOLDER).size(), 3u);
ZoneOverrideStore::Clear(root, { 1900, FOLDER, "c1", "admin", "", 2000, 20 });
EXPECT_EQ(Slurp(root / "maps" / other), "other zone");
EXPECT_EQ(ZoneOverrideStore::Current(root, 1901, FOLDER).size(), 1u);
EXPECT_FALSE(fs::exists(root / "maps" / FOLDER / "a.lvl"));
}
TEST_F(ZoneOverrideStoreTests, RefusesNamesOutsideTheZoneFolderAndBadIds) {
const auto file = dir / "in" / "x";
std::ofstream(file) << "x";
const ZoneOverrideStore::Change change{ zone.zoneId, FOLDER, "p1", "editor", "", 1000, 20 };
EXPECT_THROW(ZoneOverrideStore::Apply(root, change, { { "01_live_maps/other_zone/a.lvl", file, 1, "", 0 } }), ZoneOverrideStore::StoreError);
EXPECT_THROW(ZoneOverrideStore::Apply(root, change, { { FOLDER + "../../../escape", file, 1, "", 0 } }), ZoneOverrideStore::StoreError);
EXPECT_THROW(ZoneOverrideStore::Apply(root, change, { { FOLDER + "A.lvl", file, 1, "", 0 } }), ZoneOverrideStore::StoreError);
auto bad = change;
bad.id = "../x";
EXPECT_THROW(ZoneOverrideStore::Apply(root, bad, { { FOLDER + "a.lvl", file, 1, "", 0 } }), ZoneOverrideStore::StoreError);
bad = change;
bad.zoneFolder = "../";
EXPECT_THROW(ZoneOverrideStore::Apply(root, bad, { { "../a.lvl", file, 1, "", 0 } }), ZoneOverrideStore::StoreError);
EXPECT_FALSE(fs::exists(dir / "escape"));
EXPECT_FALSE(ZoneOverrideStore::ValidId(""));
EXPECT_FALSE(ZoneOverrideStore::ValidId("a/b"));
EXPECT_FALSE(ZoneOverrideStore::ValidId("A"));
EXPECT_TRUE(ZoneOverrideStore::ValidId("1000-ab12"));
}
TEST(ZoneEditLockTests, TakeReleaseAndChange) {
using namespace ZoneEditLock;
const Levels levels{ 6, 9 };
const Actor editor{ 10, 6, true }, other{ 11, 6, true }, stranger{ 12, 6, false }, low{ 13, 5, true }, admin{ 14, 9, false };
// Taking: an assigned editor at the level, or an admin
EXPECT_EQ(Take(editor, std::nullopt, levels), eResult::OK);
EXPECT_EQ(Take(admin, std::nullopt, levels), eResult::OK);
EXPECT_EQ(Take(stranger, std::nullopt, levels), eResult::NOT_ASSIGNED);
EXPECT_EQ(Take(low, std::nullopt, levels), eResult::LEVEL_TOO_LOW);
EXPECT_EQ(Take(other, 10u, levels), eResult::LOCKED_BY_OTHER);
EXPECT_EQ(Take(admin, 10u, levels), eResult::LOCKED_BY_OTHER); // an admin releases it first
EXPECT_EQ(Take(editor, 10u, levels), eResult::NO_CHANGE);
EXPECT_EQ(Take(Actor{ 0, 9, true }, std::nullopt, levels), eResult::LEVEL_TOO_LOW);
// Releasing: the holder, or an admin forcing it
EXPECT_EQ(Release(editor, 10u, levels), eResult::OK);
EXPECT_EQ(Release(other, 10u, levels), eResult::LOCKED_BY_OTHER);
EXPECT_EQ(Release(admin, 10u, levels), eResult::OK);
EXPECT_EQ(Release(editor, std::nullopt, levels), eResult::NO_CHANGE);
EXPECT_EQ(Release(low, 13u, levels), eResult::LEVEL_TOO_LOW);
// Changing the files: the holder only, admin or not
EXPECT_EQ(Change(editor, 10u, levels), eResult::OK);
EXPECT_EQ(Change(other, 10u, levels), eResult::LOCKED_BY_OTHER);
EXPECT_EQ(Change(admin, 10u, levels), eResult::LOCKED_BY_OTHER);
EXPECT_EQ(Change(editor, std::nullopt, levels), eResult::NOT_ASSIGNED);
EXPECT_EQ(Change(low, 13u, levels), eResult::LEVEL_TOO_LOW);
// The numbers are told to requesters
EXPECT_EQ(static_cast<uint8_t>(eResult::OK), 0);
EXPECT_EQ(static_cast<uint8_t>(eResult::LEVEL_TOO_LOW), 1);
EXPECT_EQ(static_cast<uint8_t>(eResult::NOT_ASSIGNED), 2);
EXPECT_EQ(static_cast<uint8_t>(eResult::LOCKED_BY_OTHER), 3);
EXPECT_EQ(static_cast<uint8_t>(eResult::NO_CHANGE), 4);
}
TEST(ZoneEditLockTests, ReloadHoldsWhilePlayersAreThere) {
using namespace ZoneEditLock;
EXPECT_EQ(PlanReload(false, 5, true), eReload::SKIPPED);
EXPECT_EQ(PlanReload(true, 0, false), eReload::STARTED);
EXPECT_EQ(PlanReload(true, 3, false), eReload::HELD);
EXPECT_EQ(PlanReload(true, 3, true), eReload::STARTED);
}
// ---- Files sent by the game client's level editor (ZoneFilePush): the same checks, store and backups ----
#include "ZoneFilePush.h"
namespace {
struct PushFixture : Fixture {
uint32_t pushes = 0;
ZoneFilePush::Job Job(ZoneFilePack::eFileType type, const ZoneFilePack::Files& files) {
ZoneFilePush::Job job;
job.zoneId = zone.zoneId;
job.luzPath = LUZ;
job.fileType = type;
job.packedFiles = ZoneFilePack::Pack(type, files);
job.root = root;
job.id = "w" + std::to_string(++pushes);
job.who = "editor";
job.now = 1000 + pushes;
job.clientScenes = zone.clientScenes;
job.clientFiles = { FOLDER + "a.lvl", FOLDER + "b.lvl", FOLDER + "test_zone.raw" };
job.clientLuz = ZoneBytes({ "a.lvl", "b.lvl" });
return job;
}
};
using ZoneFilePushTests = PushFixture;
}
// One scene sent: the set is that scene and the zone's .luz (the client's own, as none was sent)
TEST_F(ZoneFilePushTests, OneFileBecomesASetWithTheZoneFile) {
ZoneFilePack::Files files;
files.scenes = { { "A.lvl", LevelBytes({ "pushed=0:yes" }) } };
const auto result = ZoneFilePush::Run(Job(ZoneFilePack::eFileType::SCENE, files));
ASSERT_TRUE(result.applied) << result.problem;
EXPECT_EQ(result.files, 2u);
EXPECT_FALSE(result.backupId);
EXPECT_EQ(Slurp(root / "maps" / FOLDER / "a.lvl"), LevelBytes({ "pushed=0:yes" }));
EXPECT_EQ(Slurp(root / "maps" / LUZ), ZoneBytes({ "a.lvl", "b.lvl" }));
EXPECT_FALSE(fs::exists(root / "staging" / "w1"));
}
// A second single file keeps the rest of the set, and the set before is a backup
TEST_F(ZoneFilePushTests, OneFileKeepsTheRestOfTheSet) {
ZoneFilePack::Files first;
first.scenes = { { "a.lvl", LevelBytes({ "first=0:a" }) } };
ASSERT_TRUE(ZoneFilePush::Run(Job(ZoneFilePack::eFileType::SCENE, first)).applied);
ZoneFilePack::Files second;
second.triggers = { { "a.lutriggers", GOOD_TRIGGERS } };
const auto result = ZoneFilePush::Run(Job(ZoneFilePack::eFileType::TRIGGERS, second));
ASSERT_TRUE(result.applied) << result.problem;
EXPECT_EQ(result.files, 3u);
ASSERT_TRUE(result.backupId);
EXPECT_EQ(Slurp(root / "maps" / FOLDER / "a.lvl"), LevelBytes({ "first=0:a" }));
EXPECT_EQ(Slurp(root / "maps" / FOLDER / "a.lutriggers"), GOOD_TRIGGERS);
// The backup still holds the first set, whole
const auto backups = ZoneOverrideStore::Backups(root, zone.zoneId);
ASSERT_EQ(backups.size(), 1u);
EXPECT_EQ(backups[0].files.size(), 2u);
EXPECT_EQ(Slurp(root / "backups" / "1900" / backups[0].id / "files" / FOLDER / "a.lvl"), LevelBytes({ "first=0:a" }));
}
// A whole map is checked whole: one bad file in it and the set the zone has stays as it is
TEST_F(ZoneFilePushTests, ARefusedWholeMapLeavesTheSetAlone) {
ZoneFilePack::Files first;
first.triggers = { { "a.lutriggers", GOOD_TRIGGERS } };
ASSERT_TRUE(ZoneFilePush::Run(Job(ZoneFilePack::eFileType::TRIGGERS, first)).applied);
ZoneFilePack::Files whole;
whole.zone = ZoneFilePack::Entry{ "test_zone.luz", ZoneBytes({ "a.lvl" }) };
whole.terrain = ZoneFilePack::Entry{ "test_zone.raw", "not a terrain file" };
whole.scenes = { { "a.lvl", LevelBytes({ "whole=0:map" }) } };
auto result = ZoneFilePush::Run(Job(ZoneFilePack::eFileType::WHOLE_MAP, whole));
EXPECT_FALSE(result.applied);
EXPECT_NE(result.problem.find("test_zone.raw"), std::string::npos);
EXPECT_NE(result.problem.find("parse_failed"), std::string::npos);
// Refused whole: the set before is untouched
EXPECT_EQ(Slurp(root / "maps" / FOLDER / "a.lutriggers"), GOOD_TRIGGERS);
EXPECT_FALSE(fs::exists(root / "maps" / FOLDER / "a.lvl"));
EXPECT_FALSE(fs::exists(root / "staging" / "w2"));
}
// Whatever folder a sent name has, the file can only become one of this zone's own
TEST_F(ZoneFilePushTests, NamesNeverLeaveTheZoneFolder) {
ZoneFilePack::Files files;
files.scenes = { { "x", LevelBytes({}) } };
// (Pack writes the name as given; a client could send any)
files.scenes[0].name = "../../../../etc/a.lvl";
auto result = ZoneFilePush::Run(Job(ZoneFilePack::eFileType::SCENE, files));
ASSERT_TRUE(result.applied) << result.problem;
EXPECT_TRUE(fs::exists(root / "maps" / FOLDER / "a.lvl")); // its base name, in the zone's folder
EXPECT_FALSE(fs::exists(dir / "etc"));
// A base name the zone has no file for is refused, and so is a file of another kind
files.scenes[0].name = "..\\other_zone\\c.lvl";
result = ZoneFilePush::Run(Job(ZoneFilePack::eFileType::SCENE, files));
EXPECT_FALSE(result.applied);
EXPECT_NE(result.problem.find("name_not_allowed"), std::string::npos);
files.scenes[0].name = "passwd";
result = ZoneFilePush::Run(Job(ZoneFilePack::eFileType::SCENE, files));
EXPECT_FALSE(result.applied);
files.scenes[0].name = "..";
result = ZoneFilePush::Run(Job(ZoneFilePack::eFileType::SCENE, files));
EXPECT_FALSE(result.applied);
EXPECT_NE(result.problem.find("unpacked"), std::string::npos);
}
TEST_F(ZoneFilePushTests, DamagedPacksAndFilesAreRefusedWithNothingWritten) {
ZoneFilePack::Files files;
files.scenes = { { "a.lvl", LevelBytes({ "a=0:b", "c=0:d" }) } };
auto job = Job(ZoneFilePack::eFileType::SCENE, files);
job.packedFiles.resize(job.packedFiles.size() - 3);
auto result = ZoneFilePush::Run(job);
EXPECT_FALSE(result.applied);
EXPECT_NE(result.problem.find("unpacked"), std::string::npos);
// A scene cut short inside good packing
auto level = LevelBytes({ "a=0:b", "c=0:d" });
files.scenes[0].data = level.substr(0, level.size() - 2);
result = ZoneFilePush::Run(Job(ZoneFilePack::eFileType::SCENE, files));
EXPECT_FALSE(result.applied);
EXPECT_NE(result.problem.find("parse_failed"), std::string::npos);
// Larger than a file may be
files.scenes[0].data = level;
job = Job(ZoneFilePack::eFileType::SCENE, files);
job.limits.maxFileBytes = 16;
result = ZoneFilePush::Run(job);
EXPECT_FALSE(result.applied);
EXPECT_TRUE(ZoneOverrideStore::Current(root, zone.zoneId, FOLDER).empty());
EXPECT_FALSE(fs::exists(root / "maps" / FOLDER / "a.lvl"));
}