251 lines
16 KiB
C++
251 lines
16 KiB
C++
#include "sync.h"
|
|
#include "json.hpp"
|
|
#include <iostream>
|
|
using namespace ks;
|
|
using Json = nlohmann::json;
|
|
namespace
|
|
{
|
|
int checks{};
|
|
void require(bool condition, const char *message)
|
|
{
|
|
if (!condition) throw std::runtime_error(message);
|
|
++checks;
|
|
}
|
|
template<class F> void rejects(F f, const char *message)
|
|
{
|
|
bool rejected = false;
|
|
try { f(); } catch (const std::exception &) { rejected = true; }
|
|
require(rejected, message);
|
|
}
|
|
Bytes bytes(const Json &j) { auto s = j.dump(); return {s.begin(), s.end()}; }
|
|
Json parse(const Bytes &b) { return Json::parse(b.begin(), b.end()); }
|
|
struct FakeProvider : SyncProvider
|
|
{
|
|
SyncDocument remote;
|
|
int writes{};
|
|
bool race{};
|
|
SyncDocument read() override { return remote; }
|
|
uint64_t write(const Bytes &b, uint64_t expected) override
|
|
{
|
|
if (race) ++remote.revision;
|
|
if (expected != remote.revision) throw std::runtime_error("CAS conflict");
|
|
++writes; remote = {remote.revision + 1, b}; return remote.revision;
|
|
}
|
|
};
|
|
}
|
|
int main()
|
|
{
|
|
auto root = std::filesystem::temp_directory_path() / (L"ks-sync-test-" + std::to_wstring(GetCurrentProcessId()));
|
|
try
|
|
{
|
|
std::filesystem::create_directories(root);
|
|
require(!syncVisible(root), "hidden without opt-in file");
|
|
require(!loadSyncConfig(root).enabled, "disabled by default");
|
|
SyncConfig config; config.server = L"https://bao.example.com"; config.username = L"person"; config.enabled = true;
|
|
validateSyncConfig(config);
|
|
for (const auto *server : {L"http://bao.example.com", L"https://user:pass@bao.example.com", L"https://bao.example.com/x", L"https://bao.example.com?x=1"})
|
|
{
|
|
auto invalid = config; invalid.server = server;
|
|
rejects([&] { validateSyncConfig(invalid); }, "reject unsafe endpoint");
|
|
}
|
|
auto invalid = config; invalid.path = L"../other";
|
|
rejects([&] { validateSyncConfig(invalid); }, "reject traversal");
|
|
invalid = config; invalid.provider = L"unknown";
|
|
rejects([&] { validateSyncConfig(invalid); }, "reject unsupported provider");
|
|
saveSyncConfig(root, config);
|
|
require(syncVisible(root) && loadSyncConfig(root) == config, "connection round trip");
|
|
auto rawConfig = parse(readFile(root / L"sync.json", 16384));
|
|
require(!rawConfig.contains("password") && !rawConfig.contains("token"), "no secrets in config");
|
|
Settings local;
|
|
local.autostart = true; local.elevatedAutostart = true; local.tesseractPath = L"C:\\private\\tesseract.exe";
|
|
local.history = false;
|
|
local.snippets.push_back({L"test", L"private snippet"});
|
|
auto base = sharedSyncSettings(local);
|
|
Settings ordered = local, reordered = local;
|
|
ordered.directIds = {L"00000409", L"00000422"}; ordered.direct = {L"Ctrl+1", L"Ctrl+2"};
|
|
reordered.directIds = {L"00000422", L"00000409"}; reordered.direct = {L"Ctrl+2", L"Ctrl+1"};
|
|
require(sharedSyncSettings(ordered) == sharedSyncSettings(reordered), "layout enumeration does not cause sync churn");
|
|
auto j = parse(base);
|
|
require(!j.contains("startWithSystem") && !j.contains("tesseractPath") && !j.contains("historyEnabled"), "local-only fields excluded");
|
|
auto restored = restoreSyncSettings(base, local, {});
|
|
require(restored.autostart && restored.elevatedAutostart && !restored.history && restored.tesseractPath == local.tesseractPath, "preserve device settings");
|
|
auto hostile = j; hostile["startWithSystem"] = false;
|
|
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject injected device setting");
|
|
hostile = j; hostile["schemaVersion"] = 2;
|
|
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject future schema");
|
|
hostile = j; hostile["layouts"] = Json::array({"00000422"});
|
|
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "missing layouts block destructive normalization");
|
|
auto left = j, right = j;
|
|
left["convertLastWord"] = false; right["switchSystemLayout"] = false;
|
|
auto merged = parse(mergeSyncSettings(base, bytes(left), bytes(right)));
|
|
require(merged["convertLastWord"] == false && merged["switchSystemLayout"] == false, "merge independent edits");
|
|
left = right = j; left["autoCorrectMinLength"] = 4; right["autoCorrectMinLength"] = 5;
|
|
rejects([&] { mergeSyncSettings(base, bytes(left), bytes(right)); }, "same field conflict");
|
|
left = right = j; left["snippets"].push_back({{"trigger", "aaa"}, {"text", "one"}});
|
|
right["snippets"].push_back({{"trigger", "bbb"}, {"text", "two"}});
|
|
require(parse(mergeSyncSettings(base, bytes(left), bytes(right)))["snippets"].size() == 3, "independent snippet additions merge");
|
|
rejects([&] { mergeSyncSettings({}, bytes(left), base); }, "first sync needs explicit choice");
|
|
require(parse(mergeSyncSettings({}, base, base)) == j, "identical first sync safe");
|
|
require(SyncIntervalMs == 600000, "background interval is ten minutes");
|
|
{
|
|
auto l = j, r = j;
|
|
l["autoCorrectMinLength"] = 4; r["autoCorrectMinLength"] = 5;
|
|
l["convertLastWord"] = false; r["switchSystemLayout"] = false;
|
|
auto plan = mergeSyncProfile(base, bytes(l), bytes(r));
|
|
require(plan.conflicts.size() == 1, "one conflict reported independently");
|
|
require(parse(plan.local)["switchSystemLayout"] == false && parse(plan.remote)["convertLastWord"] == false,
|
|
"nonconflicting changes reach both copies");
|
|
require(parse(plan.local)["autoCorrectMinLength"] == 4 && parse(plan.remote)["autoCorrectMinLength"] == 5,
|
|
"unresolved scalar retains both values");
|
|
require(parse(plan.base)["autoCorrectMinLength"] == j["autoCorrectMinLength"], "ancestor retained for unresolved setting");
|
|
saveSyncBase(root, config, plan.base);
|
|
auto again = mergeSyncProfile(loadSyncBase(root, config), plan.local, plan.remote);
|
|
require(again.conflicts.size() == 1, "conflict survives restart checkpoint");
|
|
auto choice = SyncChoice{plan.conflicts.front(), true};
|
|
auto resolved = mergeSyncProfile(plan.base, plan.local, plan.remote, {choice});
|
|
require(resolved.conflicts.empty() && parse(resolved.remote)["autoCorrectMinLength"] == 4,
|
|
"resolve only selected setting");
|
|
require(parse(resolved.remote)["switchSystemLayout"] == false, "choice preserves independent server change");
|
|
auto changed = parse(plan.remote); changed["autoCorrectMinLength"] = 6;
|
|
require(mergeSyncProfile(plan.base, plan.local, bytes(changed), {choice}).conflicts.size() == 1,
|
|
"stale conflict choice never overwrites new server value");
|
|
FakeProvider partial; partial.remote = {7, bytes(r)};
|
|
auto result = synchronize(partial, base, bytes(l), SyncAction::Automatic);
|
|
require(result.success && result.conflicts.size() == 1 && partial.writes == 1,
|
|
"partial synchronization commits independent fields with CAS");
|
|
auto encoded = encodeSyncChoices({choice});
|
|
require(decodeSyncChoices(encoded).front().conflict.key == choice.conflict.key, "choice IPC round trip");
|
|
}
|
|
{
|
|
auto id = j["snippets"][0]["id"].get<std::string>();
|
|
auto l = j, r = j;
|
|
l["snippets"][0]["trigger"] = "renamed";
|
|
r["snippets"][0]["text"] = "edited remotely";
|
|
auto merged = parse(mergeSyncSettings(base, bytes(l), bytes(r)));
|
|
require(merged["snippets"][0]["id"] == id && merged["snippets"][0]["trigger"] == "renamed" &&
|
|
merged["snippets"][0]["text"] == "edited remotely", "stable ID merges rename and text edit");
|
|
l = r = j; l["snippets"] = Json::array(); l["snippetTombstones"] = Json::array({id});
|
|
auto deletion = mergeSyncProfile(base, bytes(l), bytes(r));
|
|
require(deletion.conflicts.empty() && parse(deletion.remote)["snippets"].empty(), "delete beats unchanged snippet");
|
|
require(parse(deletion.remote)["snippetTombstones"] == Json::array({id}), "deletion marker retained");
|
|
auto offline = mergeSyncProfile(base, bytes(r), deletion.remote);
|
|
require(offline.conflicts.empty() && parse(offline.local)["snippets"].empty(), "offline copy cannot resurrect deletion");
|
|
r["snippets"][0]["text"] = "offline edit";
|
|
auto conflict = mergeSyncProfile(base, bytes(l), bytes(r));
|
|
require(conflict.conflicts.size() == 1, "delete versus edit is one snippet conflict");
|
|
auto remove = mergeSyncProfile(conflict.base, conflict.local, conflict.remote, {{conflict.conflicts[0], true}});
|
|
require(remove.conflicts.empty() && parse(remove.remote)["snippets"].empty(), "choose deletion");
|
|
auto keep = mergeSyncProfile(conflict.base, conflict.local, conflict.remote, {{conflict.conflicts[0], false}});
|
|
require(keep.conflicts.empty() && parse(keep.local)["snippetTombstones"].empty() &&
|
|
parse(keep.local)["snippets"][0]["text"] == "offline edit", "choose edited snippet removes tombstone");
|
|
require(mergeSyncProfile(base, bytes(l), bytes(l)).conflicts.empty(), "both delete converges");
|
|
auto legacy = j; legacy.erase("snippetTombstones"); legacy["snippets"][0].erase("id");
|
|
auto migrated = parse(mergeSyncSettings(bytes(legacy), base, bytes(legacy)));
|
|
require(migrated["snippets"][0]["id"] == id, "legacy profiles migrate to identical IDs");
|
|
auto roundtrip = decodeSettings(encodeSettings(restored), {});
|
|
require(snippetId(roundtrip.snippets.front()) == wide(id), "ID survives settings serialization");
|
|
require(newSnippetId() != newSnippetId(), "new snippet IDs are unique");
|
|
}
|
|
{
|
|
auto l = j, r = j;
|
|
l["snippets"].push_back({{"id", "new-a"}, {"trigger", "same"}, {"text", "A"}});
|
|
r["snippets"].push_back({{"id", "new-b"}, {"trigger", "same"}, {"text", "B"}});
|
|
auto collision = mergeSyncProfile(base, bytes(l), bytes(r));
|
|
require(collision.conflicts.size() == 2, "duplicate new triggers expose individual choices");
|
|
auto resolved = mergeSyncProfile(collision.base, collision.local, collision.remote,
|
|
{{collision.conflicts[0], true}, {collision.conflicts[1], true}});
|
|
require(resolved.conflicts.empty() && parse(resolved.remote)["snippets"].size() == 2, "choose one of colliding additions");
|
|
l = r = j; l["toggleHotkey"] = "Ctrl+Alt+F11"; r["caseHotkey"] = "Ctrl+Alt+F11";
|
|
require(mergeSyncProfile(base, bytes(l), bytes(r)).conflicts.size() == 2, "cross-field shortcut collisions exposed");
|
|
l = r = j; l["excludedApps"].push_back("local-app"); r["excludedApps"].push_back("remote-app");
|
|
require(parse(mergeSyncSettings(base, bytes(l), bytes(r)))["excludedApps"].size() == j["excludedApps"].size() + 2,
|
|
"independent exclusion edits merge");
|
|
}
|
|
FakeProvider provider;
|
|
rejects([&] { synchronize(provider, {}, base, SyncAction::Automatic); }, "empty server never auto-populated");
|
|
auto result = synchronize(provider, {}, base, SyncAction::Upload);
|
|
require(result.revision == 1 && provider.writes == 1, "initial upload CAS zero");
|
|
result = synchronize(provider, base, base, SyncAction::Automatic);
|
|
require(provider.writes == 1 && result.revision == 1, "no-op avoids versions");
|
|
result = synchronize(provider, base, bytes(left), SyncAction::Download);
|
|
require(parse(result.settings) == parse(base) && provider.writes == 1, "download never writes");
|
|
provider.race = true;
|
|
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload); }, "racing writer protected");
|
|
require(provider.writes == 1 && provider.remote.settings == base, "CAS failure preserves remote");
|
|
provider.race = false;
|
|
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload,
|
|
[](const Bytes &) { throw std::runtime_error("invalid"); }); }, "validate before upload");
|
|
require(provider.writes == 1, "invalid data not uploaded");
|
|
saveSyncBase(root, config, base);
|
|
require(parse(loadSyncBase(root, config)) == j, "encrypted baseline round trip");
|
|
auto other = config; other.path = L"other/settings";
|
|
require(loadSyncBase(root, other).empty(), "baseline bound to connection");
|
|
auto encrypted = readFile(root / L"sync-base.bin", 1024 * 1024);
|
|
std::string disk(encrypted.begin(), encrypted.end());
|
|
require(disk.find("private snippet") == disk.npos, "baseline encrypted on disk");
|
|
// Exercise real Credential Manager with an isolated test target, never a user endpoint.
|
|
config.username = L"ks-sync-test-" + std::to_wstring(GetCurrentProcessId());
|
|
saveSyncPassword(config, L"test-only-password");
|
|
int logins{}, revokes{}, reads{}, writes{};
|
|
unsigned readStatus = 200, writeStatus = 200;
|
|
bool deleted = false;
|
|
auto transport = [&](const std::wstring &method, const std::wstring &path,
|
|
const std::wstring &token, const Bytes &body) -> SyncHttpResponse {
|
|
if (path == L"/v1/auth/userpass/login/" + config.username)
|
|
{
|
|
require(method == L"POST" && token.empty(), "login route and no bearer token");
|
|
require(parse(body)["password"] == "test-only-password", "password sourced from credential manager");
|
|
++logins;
|
|
return {200, bytes({{"auth", {{"client_token", "test-token"}}}})};
|
|
}
|
|
require(token == L"test-token", "authenticated requests use returned token");
|
|
if (path == L"/v1/auth/token/revoke-self")
|
|
{
|
|
require(method == L"POST", "revoke method"); ++revokes; return {204, {}};
|
|
}
|
|
require(path == L"/v1/kswitcher/data/personal/settings", "KV v2 data route");
|
|
if (method == L"GET")
|
|
{
|
|
++reads;
|
|
if (readStatus != 200 && !deleted) return {readStatus, bytes({{"errors", Json::array()}})};
|
|
return {readStatus, bytes({{"data", {
|
|
{"metadata", {{"version", 7}, {"deletion_time", deleted ? "2026-01-01" : ""}, {"destroyed", false}}},
|
|
{"data", {{"syncSchema", 1}, {"settings", j}}}}}})};
|
|
}
|
|
require(method == L"POST", "write method");
|
|
auto request = parse(body);
|
|
require(request["options"]["cas"] == 7, "write includes exact CAS revision");
|
|
require(request["data"]["syncSchema"] == 2 && request["data"]["settings"] == j, "versioned settings envelope");
|
|
++writes;
|
|
return {writeStatus, bytes({{"data", {{"version", 8}}}})};
|
|
};
|
|
{
|
|
auto bao = makeSyncProvider(config, transport);
|
|
auto doc = bao->read();
|
|
require(doc.revision == 7 && parse(doc.settings) == j, "read protocol response");
|
|
require(bao->write(base, 7) == 8, "write protocol response");
|
|
readStatus = 404;
|
|
require(bao->read().revision == 0, "missing profile");
|
|
deleted = true;
|
|
rejects([&] { bao->read(); }, "soft deleted is not treated as absent");
|
|
deleted = false; readStatus = 403;
|
|
rejects([&] { bao->read(); }, "permission denied is not absent");
|
|
readStatus = 302;
|
|
rejects([&] { bao->read(); }, "redirect response rejected");
|
|
writeStatus = 400;
|
|
rejects([&] { bao->write(base, 7); }, "server CAS refusal propagated");
|
|
}
|
|
require(logins == 1 && revokes == 1 && reads == 5 && writes == 2, "temporary login lifecycle");
|
|
forgetSyncPassword(config); forgetSyncPassword(config);
|
|
++checks;
|
|
std::filesystem::remove_all(root);
|
|
std::cout << checks << " sync checks passed\n";
|
|
return 0;
|
|
}
|
|
catch (const std::exception &e)
|
|
{
|
|
std::cerr << e.what() << "\n";
|
|
return 1;
|
|
}
|
|
}
|