Add safe bank failure diagnostics for T5 and T6
Address #14 with request correlation, bounded parser reasons, startup version reporting, and rate-limited storage and settlement logs. Preserve accounting and API identity contracts, extend portable tests, and update release versions and rollout guidance.
This commit is contained in:
+84
-9
@@ -72,8 +72,8 @@ function engine(game, options = {}) {
|
||||
if (game === 't6') player.guid = identity;
|
||||
const entry = { player, active: true };
|
||||
const recovered = (options.recovered ?? []).map(report => ({...report}));
|
||||
const saved = recovered.map(report => JSON.parse(report.body)), writes = [], sent = [], messages = [], order = [], loaded = [], dvarsRead = [];
|
||||
let serial = 0;
|
||||
const saved = recovered.map(report => JSON.parse(report.body)), writes = [], sent = [], messages = [], order = [], loaded = [], dvarsRead = [], logs = [];
|
||||
let serial = 0, now = 1000;
|
||||
const dvars = { zombiemode: '1', tws_server_id: 'server', tws_bank_token: privateToken, ...options.dvars };
|
||||
function assertScope(server, url) {
|
||||
assert.equal(server, dvars.tws_server_id);
|
||||
@@ -84,13 +84,14 @@ function engine(game, options = {}) {
|
||||
GetDvar: name => { dvarsRead.push(name); return dvars[name] ?? ''; },
|
||||
getSubStr: (value, start, end) => value.substring(start, end), registerHelpCommand: () => {},
|
||||
isDefined: v => v !== undefined && v !== null, is_true: v => !!v, isAlive: p => p.alive,
|
||||
int: Math.trunc,
|
||||
int: Math.trunc, getTime: () => now, strTok: (text, delimiter) => text.split(delimiter),
|
||||
bankVersion: () => "1.2.0",
|
||||
...(game === 't5' ? {getGuid: p => { assert.equal(p, player); return identity; }} : {}),
|
||||
gscType: value => typeof value === 'number' ? Number.isInteger(value) ? 'int' : 'float' : typeof value,
|
||||
gscType: value => typeof value === 'number' ? Number.isInteger(value) ? 'int' : 'float' : value && typeof value === 'object' ? 'array' : typeof value,
|
||||
jsonSerialize: a => JSON.stringify(Object.fromEntries(Object.entries(a))),
|
||||
bank_say: (_entry, message) => messages.push(message),
|
||||
telemetryNewMatch: () => `${options.idPrefix ?? 'request'}-${++serial}`,
|
||||
bankLoad: (server, url) => { assertScope(server, url); loaded.push({ server, url }); return recovered; },
|
||||
bankLoad: (server, url) => { assertScope(server, url); loaded.push({ server, url }); return options.loadFailure ? undefined : recovered; },
|
||||
bankStore: (server, url, body) => {
|
||||
assertScope(server, url);
|
||||
const data = JSON.parse(body); assertContract(data.decision, data);
|
||||
@@ -112,14 +113,14 @@ function engine(game, options = {}) {
|
||||
}
|
||||
const state = action === 'commit' ? 'committed' : action === 'cancel' ? 'cancelled' : action === 'balance' ? 'balance' : options.state ?? 'prepared';
|
||||
const reply = { valid: true, state, request_id: action === 'balance' ? '' : options.wrongId ? 'wrong' : JSON.parse(sent.at(-1).body).request_id, amount: options.amount ?? 2000, balance: options.balance ?? 5000, code: options.code ?? 'ok' };
|
||||
return [JSON.stringify(reply), !options.timeout, '', options.missingDiagnostics ? undefined : { http_status: options.status ?? 200 }];
|
||||
return [options.missingResponse ? undefined : JSON.stringify(reply), !options.timeout, privateToken, options.missingDiagnostics ? undefined : options.diagnostics ?? { http_status: options.status ?? 200, curl_code: options.timeout ? 28 : 0, dns_ms:1, connect_ms:2, tls_ms:3, total_ms:options.timeout ? 15000 : 4 }];
|
||||
},
|
||||
bankResponse: data => options.malformed ? { valid: false } : {valid:true, ...JSON.parse(data)},
|
||||
print: () => {}, println: () => {}
|
||||
bankResponse: data => options.malformed ? { valid: false, reason: options.reason ?? "invalid_json" } : {valid:true, ...JSON.parse(data)},
|
||||
print: message => logs.push(message), println: message => logs.push(message)
|
||||
});
|
||||
vm.runInContext([...functionsByName].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), c);
|
||||
c.init();
|
||||
return { c, player, entry, saved, writes, sent, messages, order, loaded, dvarsRead, run: args => c.bank_transfer(entry,args) };
|
||||
return { c, player, entry, saved, writes, sent, messages, order, loaded, dvarsRead, logs, advance: ms => { now += ms; }, run: args => c.bank_transfer(entry,args) };
|
||||
}
|
||||
let checks = 0;
|
||||
function journalEntries(engine) {
|
||||
@@ -185,6 +186,80 @@ for (const game of ['t5','t6']) {
|
||||
assert.equal(engine(game, {dvars}).loaded.length, 0);
|
||||
}
|
||||
});
|
||||
test('startup reports versions, scope, credential validation and journal count safely', () => {
|
||||
const e = engine(game);
|
||||
assert.match(e.logs.join('\n'), /script=2\.0\.2 plugin=1\.2\.0/);
|
||||
assert(e.logs.some(line => line.includes(`endpoint=${publicEndpoint} server=server credentials=valid`)));
|
||||
assert(e.logs.some(line => line.includes('journal_load=success pending=0')));
|
||||
const invalid = engine(game, {dvars:{tws_bank_token:'secret-invalid-token'}});
|
||||
assert(invalid.logs.some(line => line.includes('credentials=invalid')));
|
||||
assert(!invalid.logs.join('').includes('secret-invalid-token'));
|
||||
const load = engine(game, {loadFailure:true});
|
||||
assert.match(load.logs.join('\n'), /journal_load=failure pending=unknown bank=disabled/);
|
||||
assert.equal(load.c.level.bank_write, undefined);
|
||||
});
|
||||
test('requests classify failures and preserve the API identity contract', () => {
|
||||
for (const [options, outcome] of [
|
||||
[{}, 'ok'], [{noHandle:true}, 'request_creation'], [{timeout:true}, 'transport_failure'],
|
||||
[{status:401}, 'http_rejection'], [{missingDiagnostics:true}, 'missing_diagnostics'],
|
||||
[{missingResponse:true}, 'missing_response'], [{malformed:true}, 'invalid_ack_invalid_json'],
|
||||
[{malformed:true,reason:'schema'}, 'invalid_ack_schema'], [{wrongId:true}, 'request_id_mismatch'],
|
||||
[{state:'rejected', code:'pending'}, 'application_rejection'], [{state:'committed'}, 'unexpected_state']
|
||||
]) {
|
||||
const e = engine(game, options); e.run(['.d','2000']);
|
||||
const line = e.logs.find(line => line.includes('action=deposit'));
|
||||
assert(line, JSON.stringify(options)); assert(line.includes(`outcome=${outcome} `), line);
|
||||
assert.match(line, /correlation=bank-1000-1 action=deposit request_id=request-1/);
|
||||
assert.match(line, /http_status=\w+ curl_code=\w+ dns_ms=\w+ connect_ms=\w+ tls_ms=\w+ total_ms=\w+/);
|
||||
}
|
||||
const e = engine(game); e.run(['.b']); e.run(['.b']);
|
||||
const lines = e.logs.filter(line => line.includes('action=balance'));
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[0], /correlation=bank-1000-1/); assert.match(lines[1], /correlation=bank-1000-2/);
|
||||
for (const request of e.sent) assert(!Object.hasOwn(JSON.parse(request.body), 'request_id'));
|
||||
assert.equal(e.saved.length, 0);
|
||||
});
|
||||
test('diagnostics exclude secrets, arbitrary codes, bodies and malformed timing fields', () => {
|
||||
for (const diagnostics of [undefined, {}, 'secret', {http_status:200, curl_code:privateToken, dns_ms:-1, connect_ms:{}, tls_ms:1e30, total_ms:'raw-body'}]) {
|
||||
const e = engine(game, {diagnostics, state:'rejected', code:privateToken}); e.run(['.d','2000']);
|
||||
const text = e.logs.join('\n');
|
||||
for (const secret of [privateToken, 'Authorization', 'Bearer ', 'raw-body', '18446744073709551615']) assert(!text.includes(secret));
|
||||
}
|
||||
const e = engine(game, {malformed:true, reason:privateToken}); e.run(['.b']);
|
||||
assert.match(e.logs.at(-1), /invalid_ack_unknown/); assert(!e.logs.join('').includes(privateToken));
|
||||
for (const code of [privateToken, 'oops\ninjected', '^1red', 'x'.repeat(1000), 'custom_code']) {
|
||||
assert.equal(e.c.bank_response_code(code), 'unknown');
|
||||
}
|
||||
for (const diagnostics of [undefined, {}, [], false, 'secret', {http_status:'200'}, {http_status:-1}])
|
||||
assert.equal(e.c.bank_diagnostic_number(diagnostics, 'http_status'), 'unavailable');
|
||||
});
|
||||
test('failures are limited by reason, with summaries, changed status and immediate recovery', () => {
|
||||
const options = {status:401}, e = engine(game, options);
|
||||
e.run(['.b']); e.run(['.b']);
|
||||
assert.equal(e.logs.filter(line => line.includes('action=balance')).length, 1);
|
||||
e.advance(30000); e.run(['.b']); assert.match(e.logs.at(-1), /suppressed=1/);
|
||||
options.status = 503; e.run(['.b']); assert.match(e.logs.at(-1), /http_status=503/);
|
||||
options.status = 200; e.run(['.b']); assert.match(e.logs.at(-1), /outcome=ok.*recovery=true/);
|
||||
});
|
||||
test('settlement reports retry progress, reason changes, storage failures and eventual recovery', () => {
|
||||
const options = {}, e = engine(game, options); e.run(['.d','2000']);
|
||||
options.timeout = true;
|
||||
const sender = e.c.bank_sender();
|
||||
assert.equal(sender.next().value, 1);
|
||||
assert.match(e.logs.at(-1), /decision=commit attempt=1 pending_seconds=0 settlement=pending next_retry_seconds=1/);
|
||||
const count = e.logs.length;
|
||||
e.advance(1000); assert.equal(sender.next().value, 2); assert.equal(e.logs.length, count);
|
||||
options.timeout = false; options.removeFailure = true;
|
||||
e.advance(2000); assert.equal(sender.next().value, 4);
|
||||
assert.match(e.logs.at(-1), /attempt=3 pending_seconds=3 settlement=journal_remove_failure next_retry_seconds=4.*suppressed=1/);
|
||||
options.removeFailure = false; e.advance(4000); sender.next();
|
||||
assert.match(e.logs.at(-1), /attempt=4 pending_seconds=7 journal_remove=success settlement=resolved next_retry_seconds=0 recovery=true/);
|
||||
assert.equal(e.saved.length, 0); assert.equal(e.player.score, 1000);
|
||||
for (const decision of ['cancel','commit']) {
|
||||
const failed = engine(game, {storageFailure:decision}); failed.run(['.d','2000']);
|
||||
assert(failed.logs.some(line => line.includes(`journal_write decision=${decision} request_id=request-1 outcome=storage_failure`)));
|
||||
}
|
||||
});
|
||||
test('strict positive integer syntax', () => {
|
||||
const {c} = engine(game);
|
||||
for (const bad of ['', '0', '-1', '1.5', '+1', '1e3', '1000001', 'all now']) assert.equal(c.bank_amount(bad),0);
|
||||
|
||||
@@ -43,10 +43,30 @@ with tempfile.TemporaryDirectory(prefix="tws-bank-") as temp:
|
||||
# engine interfaces. This does not validate the Windows ABI/VM linker.
|
||||
stub = (ROOT / "tools/tests/http_component_stub.hpp").read_text()
|
||||
stub = stub.replace("std::vector<script_value> get_keys() const", "void emplace_back(const script_value& value) { (*data)[std::to_string(data->size())] = value; }\n std::vector<script_value> get_keys() const")
|
||||
stub = stub.replace('template <typename F> void add(const char*, F) {}', r"""
|
||||
inline std::map<std::string, std::function<scripting::script_value(const std::vector<std::string>&)>> functions;
|
||||
template <typename F> void add(const char* name, F callback)
|
||||
{
|
||||
functions[name] = [callback](const std::vector<std::string>& args) -> scripting::script_value
|
||||
{
|
||||
if constexpr (std::is_invocable_v<F>) return callback();
|
||||
else if constexpr (std::is_invocable_v<F, std::string>) return callback(args.at(0));
|
||||
else if constexpr (std::is_invocable_v<F, std::string, std::string>) return callback(args.at(0), args.at(1));
|
||||
else return callback(args.at(0), args.at(1), args.at(2));
|
||||
};
|
||||
}
|
||||
""")
|
||||
(directory / "component_stub.hpp").write_text(stub)
|
||||
component = re.sub(r'^#include[^\n]*\n', '', (plugin / "src/component/bank.cpp").read_text(), flags=re.MULTILINE)
|
||||
source = directory / "component.cpp"
|
||||
source.write_text('#include "component_stub.hpp"\n#include "bank.hpp"\n' + component)
|
||||
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror", "-fsyntax-only",
|
||||
"-I", str(directory), "-I", str(plugin / "src/utils"), str(source)], check=True)
|
||||
source.write_text('#include "component_stub.hpp"\n#include "bank.hpp"\n#include <array>\n' + component + (ROOT / 'tools/tests/bank_component_cases.cpp').read_text())
|
||||
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined",
|
||||
"-I", str(directory), "-I", str(plugin / "src/utils"), str(source),
|
||||
str(plugin / "src/utils/bank.cpp"), "-o", str(directory / "component")], check=True)
|
||||
result = subprocess.run([str(directory / "component")], cwd=directory, check=True, capture_output=True, text=True)
|
||||
lines = result.stdout.splitlines()
|
||||
for operation in ("load", "write", "remove"):
|
||||
assert sum(f"operation={operation} outcome=storage_failure" in line for line in lines) == 1, lines
|
||||
assert sum(f"operation={operation} outcome=recovered suppressed=1" in line for line in lines) == 1, lines
|
||||
assert "private-secret" not in result.stdout and "Authorization" not in result.stdout
|
||||
print(f"PASS {game}: native storage, acknowledgment, process restart and component checks", flush=True)
|
||||
|
||||
@@ -104,6 +104,29 @@ int main(int argc, char** argv)
|
||||
for (const auto& text : {"", "<html>bad gateway</html>", "{", "null", "{\"ok\":true}"})
|
||||
assert(!parse_acknowledgement(text).valid);
|
||||
assert(!parse_acknowledgement(std::string(0x5000,' ')+valid.dump()).valid);
|
||||
// Every validation failure exposes a fixed reason and clears response data.
|
||||
const auto invalid = [](const std::string& text, const std::string& reason)
|
||||
{
|
||||
const auto result = parse_acknowledgement(text);
|
||||
assert(!result.valid && result.reason == reason);
|
||||
assert(result.state.empty() && result.code.empty() && result.id.empty());
|
||||
};
|
||||
assert(ack.reason == "ok");
|
||||
invalid("", "empty_body");
|
||||
invalid(std::string(0x5000, 'x'), "body_limit");
|
||||
invalid("secret-token-not-json", "invalid_json");
|
||||
invalid("null", "schema");
|
||||
invalid("{}", "schema");
|
||||
bad = valid; bad["code"] = std::string(65, 'x'); invalid(bad.dump(), "field_limit");
|
||||
bad = valid; bad["state"] = "secret-state"; invalid(bad.dump(), "state");
|
||||
bad = valid; bad["amount"] = -1; invalid(bad.dump(), "amount_range");
|
||||
bad = valid; bad["ok"] = false; invalid(bad.dump(), "ok_state");
|
||||
bad = valid; bad["request_id"] = ""; invalid(bad.dump(), "missing_id");
|
||||
bad = valid; bad["amount"] = 0; invalid(bad.dump(), "zero_transfer");
|
||||
bad = valid; bad["code"] = "private-token\nAuthorization: secret";
|
||||
// Compatibility: valid but unknown API codes remain available to callers;
|
||||
// scripts permit only fixed known codes in diagnostics.
|
||||
assert(parse_acknowledgement(bad.dump()).valid);
|
||||
{ std::ofstream stream(file); stream << "corrupt"; }
|
||||
must_fail([&]{queue.load();});
|
||||
must_fail([&]{queue.store(body("three","cancel"));});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
// Executed with the actual registration adapter and portable engine interfaces.
|
||||
#include <fstream>
|
||||
int main()
|
||||
{
|
||||
bank::component component;
|
||||
component.on_startup(nullptr);
|
||||
const auto call = [](const std::string& name, std::vector<std::string> args = {})
|
||||
{
|
||||
return gsc::function::functions.at(name)(args);
|
||||
};
|
||||
assert(call("bankversion").as<std::string>() == "1.2.0");
|
||||
auto ack = call("bankresponse", {"private-secret-invalid-json"}).as<scripting::array>();
|
||||
assert(!ack["valid"].as<bool>());
|
||||
assert(ack["reason"].as<std::string>() == "invalid_json");
|
||||
assert(ack["code"].as<std::string>().empty());
|
||||
assert(call("bankload", {"server", "endpoint"}).is<scripting::array>());
|
||||
for (int i = 0; i < 2; ++i)
|
||||
assert(!call("bankstore", {"server", "endpoint", "private-secret Authorization"}).as<bool>());
|
||||
const std::string body = R"({"server":"server","request_id":"one","decision":"cancel"})";
|
||||
assert(call("bankstore", {"server", "endpoint", body}).as<bool>());
|
||||
const auto root = bank::spool("server", "endpoint");
|
||||
assert(root.load().size() == 1);
|
||||
// Locate only the test process's temporary outbox.
|
||||
std::filesystem::path file;
|
||||
for (const auto& entry : std::filesystem::recursive_directory_iterator("tws-bank"))
|
||||
if (entry.path().extension() == ".json") file = entry.path();
|
||||
assert(!file.empty());
|
||||
{ std::ofstream stream(file); stream << "private-secret-corrupt-journal"; }
|
||||
for (int i = 0; i < 2; ++i)
|
||||
{
|
||||
assert(call("bankload", {"server", "endpoint"}).get_raw().type == game::SCRIPT_NONE);
|
||||
assert(!call("bankremove", {"server", "endpoint", "one"}).as<bool>());
|
||||
}
|
||||
std::filesystem::remove(file);
|
||||
assert(call("bankload", {"server", "endpoint"}).is<scripting::array>());
|
||||
assert(call("bankremove", {"server", "endpoint", "one"}).as<bool>());
|
||||
}
|
||||
Reference in New Issue
Block a user