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:
2026-10-13 10:02:36 -04:00
parent ab989395f7
commit 463b4bcc51
19 changed files with 804 additions and 78 deletions
+84 -9
View File
@@ -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);
+23 -3
View File
@@ -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)
+23
View File
@@ -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"));});
+38
View File
@@ -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>());
}