add Lua sandbox with timer system (milestones 1-5 complete)
This commit is contained in:
984
sandbox-test/src/main.cpp
Normal file
984
sandbox-test/src/main.cpp
Normal file
@@ -0,0 +1,984 @@
|
||||
#include "test_harness.h"
|
||||
#include "lua_sandbox.h"
|
||||
#include "permission_gate.h"
|
||||
#include <lua.hpp>
|
||||
#include <iostream>
|
||||
#include "audit_log.h"
|
||||
#include "rate_limiter.h"
|
||||
#include "path_sandbox.h"
|
||||
#include "timer_manager.h"
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
|
||||
// Get path to scripts directory
|
||||
std::string GetScriptsDir() {
|
||||
// Scripts are copied to build directory by CMake
|
||||
return "scripts";
|
||||
}
|
||||
|
||||
// Helper to create test context
|
||||
SandboxContext TestContext() {
|
||||
return SandboxContext{
|
||||
.app_id = "test.app",
|
||||
.app_path = ".",
|
||||
.permissions = {},
|
||||
.is_system_app = false
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to read file contents
|
||||
std::string ReadFile(const std::string& path) {
|
||||
std::ifstream f(path);
|
||||
if (!f) return "";
|
||||
std::stringstream ss;
|
||||
ss << f.rdbuf();
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
// Helper to setup a _test table in real _G for timer tests
|
||||
// This allows test scripts to store state without triggering the proxy's __newindex
|
||||
void SetupTestTable(lua_State* L) {
|
||||
lua_rawgeti(L, LUA_REGISTRYINDEX, LUA_RIDX_GLOBALS);
|
||||
if (lua_getmetatable(L, -1)) {
|
||||
lua_getfield(L, -1, "__index");
|
||||
if (lua_istable(L, -1)) {
|
||||
// Found real _G through proxy's __index
|
||||
lua_newtable(L); // Create _test table
|
||||
lua_setfield(L, -2, "_test");
|
||||
lua_pop(L, 3); // pop real _G, metatable, proxy
|
||||
return;
|
||||
}
|
||||
lua_pop(L, 2); // pop __index, metatable
|
||||
}
|
||||
// No proxy, use directly
|
||||
lua_newtable(L);
|
||||
lua_setfield(L, -2, "_test");
|
||||
lua_pop(L, 1); // pop _G
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// TEST DEFINITIONS
|
||||
//=============================================================================
|
||||
|
||||
bool Test_DangerousGlobalsRemoved(std::string& error_msg) {
|
||||
LuaSandbox sandbox(TestContext());
|
||||
std::string script = ReadFile(GetScriptsDir() + "/test_globals_removed.lua");
|
||||
EXPECT_FALSE(script.empty());
|
||||
EXPECT_TRUE(sandbox.LoadString(script, "test_globals_removed.lua"));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_BytecodeRejected(std::string& error_msg) {
|
||||
LuaSandbox sandbox(TestContext());
|
||||
|
||||
// Lua 5.4 bytecode signature
|
||||
std::string bytecode = "\x1bLua\x54\x00\x19\x93\r\n\x1a\n";
|
||||
|
||||
EXPECT_FALSE(sandbox.LoadString(bytecode, "bytecode_test"));
|
||||
|
||||
// Error should mention binary/bytecode
|
||||
std::string err = sandbox.GetLastError();
|
||||
bool mentions_binary = (err.find("binary") != std::string::npos ||
|
||||
err.find("attempt to load") != std::string::npos ||
|
||||
err.find("text") != std::string::npos);
|
||||
EXPECT_TRUE(mentions_binary);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_MemoryLimitEnforced(std::string& error_msg) {
|
||||
SandboxLimits limits;
|
||||
limits.memory_bytes = 512 * 1024; // 512 KB - very small
|
||||
|
||||
LuaSandbox sandbox(TestContext(), limits);
|
||||
|
||||
std::string script = ReadFile(GetScriptsDir() + "/test_memory_limit.lua");
|
||||
EXPECT_FALSE(script.empty());
|
||||
|
||||
// Should fail due to memory exhaustion
|
||||
EXPECT_FALSE(sandbox.LoadString(script, "test_memory_limit.lua"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_CPULimitEnforced(std::string& error_msg) {
|
||||
SandboxLimits limits;
|
||||
limits.instructions_per_call = 10000; // Very low
|
||||
|
||||
LuaSandbox sandbox(TestContext(), limits);
|
||||
|
||||
std::string script = ReadFile(GetScriptsDir() + "/test_cpu_limit.lua");
|
||||
EXPECT_FALSE(script.empty());
|
||||
|
||||
// Should fail due to instruction limit
|
||||
EXPECT_FALSE(sandbox.LoadString(script, "test_cpu_limit.lua"));
|
||||
|
||||
// Error should mention instructions
|
||||
std::string err = sandbox.GetLastError();
|
||||
EXPECT_CONTAINS(err, "instruction");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_MetatableProtected(std::string& error_msg) {
|
||||
LuaSandbox sandbox(TestContext());
|
||||
std::string script = ReadFile(GetScriptsDir() + "/test_metatable_protected.lua");
|
||||
EXPECT_FALSE(script.empty());
|
||||
if (!sandbox.LoadString(script, "test_metatable_protected.lua")) {
|
||||
error_msg = "Script failed: " + sandbox.GetLastError();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_SafeOperationsWork(std::string& error_msg) {
|
||||
LuaSandbox sandbox(TestContext());
|
||||
std::string script = ReadFile(GetScriptsDir() + "/test_safe_operations.lua");
|
||||
EXPECT_FALSE(script.empty());
|
||||
EXPECT_TRUE(sandbox.LoadString(script, "test_safe_operations.lua"));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_StringDumpRemoved(std::string& error_msg) {
|
||||
LuaSandbox sandbox(TestContext());
|
||||
std::string script = ReadFile(GetScriptsDir() + "/test_string_dump_removed.lua");
|
||||
EXPECT_FALSE(script.empty());
|
||||
EXPECT_TRUE(sandbox.LoadString(script, "test_string_dump_removed.lua"));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_MemoryTracking(std::string& error_msg) {
|
||||
LuaSandbox sandbox(TestContext());
|
||||
|
||||
// Initially should have some baseline memory
|
||||
size_t initial = sandbox.GetMemoryUsed();
|
||||
EXPECT_TRUE(initial > 0);
|
||||
|
||||
// Allocate some data
|
||||
sandbox.LoadString("local t = {}; for i=1,1000 do t[i] = string.rep('x', 100) end", "alloc");
|
||||
|
||||
// Memory should have increased
|
||||
size_t after = sandbox.GetMemoryUsed();
|
||||
EXPECT_TRUE(after > initial);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_InstructionCounting(std::string& error_msg) {
|
||||
SandboxLimits limits;
|
||||
limits.instructions_per_call = 1000000; // 1M instructions
|
||||
|
||||
LuaSandbox sandbox(TestContext(), limits);
|
||||
|
||||
// Run some code
|
||||
sandbox.LoadString("for i=1,10000 do local x = i * 2 end", "counting");
|
||||
|
||||
// Should have used some instructions
|
||||
EXPECT_TRUE(sandbox.GetInstructionsUsed() > 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_MultipleLoads(std::string& error_msg) {
|
||||
LuaSandbox sandbox(TestContext());
|
||||
|
||||
// Should be able to load multiple scripts
|
||||
EXPECT_TRUE(sandbox.LoadString("local a = 1", "script1"));
|
||||
EXPECT_TRUE(sandbox.LoadString("local b = 2", "script2"));
|
||||
EXPECT_TRUE(sandbox.LoadString("local c = 3", "script3"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_ErrorRecovery(std::string& error_msg) {
|
||||
LuaSandbox sandbox(TestContext());
|
||||
|
||||
// Script with error
|
||||
EXPECT_FALSE(sandbox.LoadString("error('test error')", "error_script"));
|
||||
|
||||
// Should still be able to run more code after error
|
||||
EXPECT_TRUE(sandbox.LoadString("local x = 1", "after_error"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// PERMISSION SYSTEM TESTS (Milestone 2)
|
||||
//=============================================================================
|
||||
|
||||
bool Test_NormalPermissionAutoGranted(std::string& error_msg) {
|
||||
SandboxContext ctx = TestContext();
|
||||
ctx.permissions = {"internet", "vibrate"}; // Declare normal permissions
|
||||
|
||||
mosis::PermissionGate gate(ctx);
|
||||
|
||||
// Normal permissions should be auto-granted when declared
|
||||
EXPECT_TRUE(gate.HasPermission("internet"));
|
||||
EXPECT_TRUE(gate.HasPermission("vibrate"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_DangerousPermissionRequiresGrant(std::string& error_msg) {
|
||||
SandboxContext ctx = TestContext();
|
||||
ctx.permissions = {"camera"}; // Declare dangerous permission
|
||||
|
||||
mosis::PermissionGate gate(ctx);
|
||||
|
||||
// Not granted yet (regular app)
|
||||
EXPECT_FALSE(gate.HasPermission("camera"));
|
||||
|
||||
// Grant at runtime
|
||||
gate.GrantPermission("camera");
|
||||
|
||||
// Now should have it
|
||||
EXPECT_TRUE(gate.HasPermission("camera"));
|
||||
|
||||
// Revoke
|
||||
gate.RevokePermission("camera");
|
||||
EXPECT_FALSE(gate.HasPermission("camera"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_SignaturePermissionSystemOnly(std::string& error_msg) {
|
||||
// Non-system app
|
||||
SandboxContext ctx = TestContext();
|
||||
ctx.permissions = {"system.settings"};
|
||||
ctx.is_system_app = false;
|
||||
|
||||
mosis::PermissionGate gate(ctx);
|
||||
EXPECT_FALSE(gate.HasPermission("system.settings"));
|
||||
|
||||
// System app
|
||||
SandboxContext sys_ctx = TestContext();
|
||||
sys_ctx.permissions = {"system.settings"};
|
||||
sys_ctx.is_system_app = true;
|
||||
|
||||
mosis::PermissionGate sys_gate(sys_ctx);
|
||||
EXPECT_TRUE(sys_gate.HasPermission("system.settings"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_UserGestureTracking(std::string& error_msg) {
|
||||
SandboxContext ctx = TestContext();
|
||||
mosis::PermissionGate gate(ctx);
|
||||
|
||||
// No recent gesture
|
||||
EXPECT_FALSE(gate.HasRecentUserGesture(5000));
|
||||
|
||||
// Record gesture
|
||||
gate.RecordUserGesture();
|
||||
|
||||
// Should have recent gesture
|
||||
EXPECT_TRUE(gate.HasRecentUserGesture(5000));
|
||||
|
||||
// Wait for gesture to expire (use short window)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
EXPECT_FALSE(gate.HasRecentUserGesture(50)); // 50ms window, we waited 100ms
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_UndeclaredPermissionDenied(std::string& error_msg) {
|
||||
SandboxContext ctx = TestContext();
|
||||
ctx.permissions = {}; // No permissions declared
|
||||
|
||||
mosis::PermissionGate gate(ctx);
|
||||
|
||||
// Even normal permissions need to be declared
|
||||
EXPECT_FALSE(gate.HasPermission("internet"));
|
||||
|
||||
// Dangerous permissions also denied
|
||||
EXPECT_FALSE(gate.HasPermission("camera"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_SystemAppGetsDangerousAuto(std::string& error_msg) {
|
||||
// System apps get dangerous permissions automatically (no runtime grant needed)
|
||||
SandboxContext ctx = TestContext();
|
||||
ctx.permissions = {"camera", "microphone"};
|
||||
ctx.is_system_app = true;
|
||||
|
||||
mosis::PermissionGate gate(ctx);
|
||||
|
||||
// System app should have dangerous perms without explicit grant
|
||||
EXPECT_TRUE(gate.HasPermission("camera"));
|
||||
EXPECT_TRUE(gate.HasPermission("microphone"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_PermissionCategoryCheck(std::string& error_msg) {
|
||||
// Check that permission categories are correct
|
||||
EXPECT_TRUE(mosis::PermissionGate::GetCategory("internet") == mosis::PermissionCategory::Normal);
|
||||
EXPECT_TRUE(mosis::PermissionGate::GetCategory("camera") == mosis::PermissionCategory::Dangerous);
|
||||
EXPECT_TRUE(mosis::PermissionGate::GetCategory("system.settings") == mosis::PermissionCategory::Signature);
|
||||
|
||||
// Unknown permissions default to Dangerous
|
||||
EXPECT_TRUE(mosis::PermissionGate::GetCategory("unknown.perm") == mosis::PermissionCategory::Dangerous);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// AUDIT LOG TESTS (Milestone 3)
|
||||
//=============================================================================
|
||||
|
||||
bool Test_AuditLogBasic(std::string& error_msg) {
|
||||
mosis::AuditLog log(1000);
|
||||
|
||||
log.Log(mosis::AuditEvent::AppStart, "test.app", "App started");
|
||||
log.Log(mosis::AuditEvent::PermissionCheck, "test.app", "camera", true);
|
||||
log.Log(mosis::AuditEvent::PermissionDenied, "test.app", "microphone", false);
|
||||
|
||||
auto entries = log.GetEntries(10);
|
||||
EXPECT_TRUE(entries.size() == 3);
|
||||
|
||||
auto app_entries = log.GetEntriesForApp("test.app", 10);
|
||||
EXPECT_TRUE(app_entries.size() == 3);
|
||||
|
||||
// Check event filtering
|
||||
auto denied_entries = log.GetEntriesByEvent(mosis::AuditEvent::PermissionDenied, 10);
|
||||
EXPECT_TRUE(denied_entries.size() == 1);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_AuditLogRingBuffer(std::string& error_msg) {
|
||||
mosis::AuditLog log(100); // Small buffer
|
||||
|
||||
// Log more than capacity
|
||||
for (int i = 0; i < 200; i++) {
|
||||
log.Log(mosis::AuditEvent::Custom, "test.app", std::to_string(i));
|
||||
}
|
||||
|
||||
// Should only have latest 100 stored
|
||||
auto entries = log.GetEntries(200);
|
||||
EXPECT_TRUE(entries.size() == 100);
|
||||
|
||||
// Total logged should be 200
|
||||
EXPECT_TRUE(log.GetTotalEntries() == 200);
|
||||
|
||||
// Most recent should be "199"
|
||||
EXPECT_TRUE(entries[0].details == "199");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_AuditLogThreadSafe(std::string& error_msg) {
|
||||
mosis::AuditLog log(10000);
|
||||
|
||||
// Spawn multiple threads logging concurrently
|
||||
std::vector<std::thread> threads;
|
||||
for (int t = 0; t < 4; t++) {
|
||||
threads.emplace_back([&log, t]() {
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
log.Log(mosis::AuditEvent::Custom, "app" + std::to_string(t), std::to_string(i));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (auto& thread : threads) {
|
||||
thread.join();
|
||||
}
|
||||
|
||||
// Should have logged 4000 entries
|
||||
EXPECT_TRUE(log.GetTotalEntries() == 4000);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// RATE LIMITER TESTS (Milestone 3)
|
||||
//=============================================================================
|
||||
|
||||
bool Test_RateLimiterBasic(std::string& error_msg) {
|
||||
mosis::RateLimiter limiter;
|
||||
|
||||
// Should succeed initially (has tokens)
|
||||
EXPECT_TRUE(limiter.Check("test.app", "network.request"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_RateLimiterExhaustion(std::string& error_msg) {
|
||||
mosis::RateLimiter limiter;
|
||||
limiter.SetLimit("test.op", {0.0, 5.0}); // 5 tokens, no refill
|
||||
|
||||
// Use all tokens
|
||||
for (int i = 0; i < 5; i++) {
|
||||
EXPECT_TRUE(limiter.Check("test.app", "test.op"));
|
||||
}
|
||||
|
||||
// Should be denied now
|
||||
EXPECT_FALSE(limiter.Check("test.app", "test.op"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_RateLimiterRefill(std::string& error_msg) {
|
||||
mosis::RateLimiter limiter;
|
||||
limiter.SetLimit("test.op", {1000.0, 1.0}); // 1000/sec, max 1 token
|
||||
|
||||
// Use the token
|
||||
EXPECT_TRUE(limiter.Check("test.app", "test.op"));
|
||||
EXPECT_FALSE(limiter.Check("test.app", "test.op"));
|
||||
|
||||
// Wait a bit for refill (2ms = ~2 tokens at 1000/sec, but max is 1)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
|
||||
// Should have token again
|
||||
EXPECT_TRUE(limiter.Check("test.app", "test.op"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_RateLimiterAppIsolation(std::string& error_msg) {
|
||||
mosis::RateLimiter limiter;
|
||||
limiter.SetLimit("test.op", {0.0, 1.0}); // 1 token, no refill
|
||||
|
||||
// App 1 uses its token
|
||||
EXPECT_TRUE(limiter.Check("app1", "test.op"));
|
||||
EXPECT_FALSE(limiter.Check("app1", "test.op"));
|
||||
|
||||
// App 2 should still have its token
|
||||
EXPECT_TRUE(limiter.Check("app2", "test.op"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_RateLimiterReset(std::string& error_msg) {
|
||||
mosis::RateLimiter limiter;
|
||||
limiter.SetLimit("test.op", {0.0, 2.0}); // 2 tokens, no refill
|
||||
|
||||
// Use all tokens
|
||||
EXPECT_TRUE(limiter.Check("test.app", "test.op"));
|
||||
EXPECT_TRUE(limiter.Check("test.app", "test.op"));
|
||||
EXPECT_FALSE(limiter.Check("test.app", "test.op"));
|
||||
|
||||
// Reset the app
|
||||
limiter.ResetApp("test.app");
|
||||
|
||||
// Should have tokens again
|
||||
EXPECT_TRUE(limiter.Check("test.app", "test.op"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_RateLimiterNoConfig(std::string& error_msg) {
|
||||
mosis::RateLimiter limiter;
|
||||
|
||||
// Operation with no config should always succeed
|
||||
for (int i = 0; i < 100; i++) {
|
||||
EXPECT_TRUE(limiter.Check("test.app", "unconfigured.operation"));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// PATH SANDBOX TESTS (Milestone 4)
|
||||
//=============================================================================
|
||||
|
||||
bool Test_PathRejectsTraversal(std::string& error_msg) {
|
||||
mosis::PathSandbox sandbox("D:/test/app");
|
||||
|
||||
EXPECT_TRUE(mosis::PathSandbox::ContainsTraversal("../etc/passwd"));
|
||||
EXPECT_TRUE(mosis::PathSandbox::ContainsTraversal("foo/../../../bar"));
|
||||
EXPECT_TRUE(mosis::PathSandbox::ContainsTraversal("..\\windows\\system32"));
|
||||
EXPECT_TRUE(mosis::PathSandbox::ContainsTraversal("data/.."));
|
||||
EXPECT_TRUE(mosis::PathSandbox::ContainsTraversal(".."));
|
||||
|
||||
// Should not match ".." in filenames
|
||||
EXPECT_FALSE(mosis::PathSandbox::ContainsTraversal("file..txt"));
|
||||
EXPECT_FALSE(mosis::PathSandbox::ContainsTraversal("test...name"));
|
||||
|
||||
std::string canonical;
|
||||
EXPECT_FALSE(sandbox.ValidatePath("../etc/passwd", canonical));
|
||||
EXPECT_FALSE(sandbox.ValidatePath("data/../../../etc/passwd", canonical));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_PathRejectsAbsolute(std::string& error_msg) {
|
||||
EXPECT_TRUE(mosis::PathSandbox::IsAbsolutePath("/etc/passwd"));
|
||||
EXPECT_TRUE(mosis::PathSandbox::IsAbsolutePath("C:\\Windows\\System32"));
|
||||
EXPECT_TRUE(mosis::PathSandbox::IsAbsolutePath("D:/test/file.txt"));
|
||||
EXPECT_TRUE(mosis::PathSandbox::IsAbsolutePath("\\\\server\\share"));
|
||||
EXPECT_TRUE(mosis::PathSandbox::IsAbsolutePath("//server/share"));
|
||||
|
||||
EXPECT_FALSE(mosis::PathSandbox::IsAbsolutePath("scripts/utils.lua"));
|
||||
EXPECT_FALSE(mosis::PathSandbox::IsAbsolutePath("./data/file.txt"));
|
||||
EXPECT_FALSE(mosis::PathSandbox::IsAbsolutePath("data/config.json"));
|
||||
|
||||
mosis::PathSandbox sandbox("D:/test/app");
|
||||
std::string canonical;
|
||||
EXPECT_FALSE(sandbox.ValidatePath("/etc/passwd", canonical));
|
||||
EXPECT_FALSE(sandbox.ValidatePath("C:\\Windows\\System32\\file.dll", canonical));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_PathAcceptsValid(std::string& error_msg) {
|
||||
mosis::PathSandbox sandbox(GetScriptsDir());
|
||||
|
||||
std::string canonical;
|
||||
EXPECT_TRUE(sandbox.ValidatePath("test_globals_removed.lua", canonical));
|
||||
EXPECT_TRUE(sandbox.ValidatePath("./test_memory_limit.lua", canonical));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_ModuleNameValidation(std::string& error_msg) {
|
||||
// Valid names
|
||||
EXPECT_TRUE(mosis::PathSandbox::IsValidModuleName("utils"));
|
||||
EXPECT_TRUE(mosis::PathSandbox::IsValidModuleName("my_module"));
|
||||
EXPECT_TRUE(mosis::PathSandbox::IsValidModuleName("ui.button"));
|
||||
EXPECT_TRUE(mosis::PathSandbox::IsValidModuleName("a.b.c"));
|
||||
EXPECT_TRUE(mosis::PathSandbox::IsValidModuleName("Module123"));
|
||||
|
||||
// Invalid names
|
||||
EXPECT_FALSE(mosis::PathSandbox::IsValidModuleName(""));
|
||||
EXPECT_FALSE(mosis::PathSandbox::IsValidModuleName(".utils"));
|
||||
EXPECT_FALSE(mosis::PathSandbox::IsValidModuleName("utils."));
|
||||
EXPECT_FALSE(mosis::PathSandbox::IsValidModuleName("ui..button"));
|
||||
EXPECT_FALSE(mosis::PathSandbox::IsValidModuleName("../evil"));
|
||||
EXPECT_FALSE(mosis::PathSandbox::IsValidModuleName("/etc/passwd"));
|
||||
EXPECT_FALSE(mosis::PathSandbox::IsValidModuleName("foo;bar"));
|
||||
EXPECT_FALSE(mosis::PathSandbox::IsValidModuleName("foo/bar"));
|
||||
EXPECT_FALSE(mosis::PathSandbox::IsValidModuleName("foo\\bar"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_ModuleToPath(std::string& error_msg) {
|
||||
EXPECT_TRUE(mosis::PathSandbox::ModuleToPath("utils") == "scripts/utils.lua");
|
||||
EXPECT_TRUE(mosis::PathSandbox::ModuleToPath("ui.button") == "scripts/ui/button.lua");
|
||||
EXPECT_TRUE(mosis::PathSandbox::ModuleToPath("a.b.c") == "scripts/a/b/c.lua");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_SafeRequireLoads(std::string& error_msg) {
|
||||
// Create sandbox with scripts directory as app path
|
||||
// The test_module.lua is in scripts/scripts/ so after ModuleToPath
|
||||
// it becomes scripts/scripts/test_module.lua
|
||||
// Safe require is auto-registered by LuaSandbox when app_path is set
|
||||
SandboxContext ctx = TestContext();
|
||||
ctx.app_path = GetScriptsDir(); // "scripts"
|
||||
|
||||
LuaSandbox sandbox(ctx);
|
||||
|
||||
// Should be able to require a test module
|
||||
std::string script =
|
||||
"local m = require('test_module')\n"
|
||||
"if m.value ~= 42 then\n"
|
||||
" error('module value mismatch')\n"
|
||||
"end\n"
|
||||
"return true\n";
|
||||
|
||||
if (!sandbox.LoadString(script, "require_test")) {
|
||||
error_msg = "Failed to load module: " + sandbox.GetLastError();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_SafeRequireCaches(std::string& error_msg) {
|
||||
// Safe require is auto-registered by LuaSandbox when app_path is set
|
||||
SandboxContext ctx = TestContext();
|
||||
ctx.app_path = GetScriptsDir();
|
||||
|
||||
LuaSandbox sandbox(ctx);
|
||||
|
||||
std::string script =
|
||||
"local m1 = require('test_module')\n"
|
||||
"local m2 = require('test_module')\n"
|
||||
"if m1 ~= m2 then\n"
|
||||
" error('modules should be same (cached)')\n"
|
||||
"end\n"
|
||||
"return true\n";
|
||||
|
||||
if (!sandbox.LoadString(script, "cache_test")) {
|
||||
error_msg = "Cache test failed: " + sandbox.GetLastError();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_SafeRequireRejectsInvalid(std::string& error_msg) {
|
||||
// Safe require is auto-registered by LuaSandbox when app_path is set
|
||||
SandboxContext ctx = TestContext();
|
||||
ctx.app_path = GetScriptsDir();
|
||||
|
||||
LuaSandbox sandbox(ctx);
|
||||
|
||||
// Should reject path traversal in module name
|
||||
EXPECT_FALSE(sandbox.LoadString("require('../evil')", "evil_require"));
|
||||
|
||||
// Should reject absolute paths
|
||||
EXPECT_FALSE(sandbox.LoadString("require('/etc/passwd')", "abs_require"));
|
||||
|
||||
// Should reject special characters
|
||||
EXPECT_FALSE(sandbox.LoadString("require('foo;bar')", "special_require"));
|
||||
|
||||
// Should reject empty
|
||||
EXPECT_FALSE(sandbox.LoadString("require('')", "empty_require"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// TIMER MANAGER TESTS (Milestone 5)
|
||||
//=============================================================================
|
||||
|
||||
bool Test_SetTimeoutFires(std::string& error_msg) {
|
||||
SandboxContext ctx = TestContext();
|
||||
LuaSandbox sandbox(ctx);
|
||||
// Manager must be declared AFTER sandbox so it's destroyed BEFORE sandbox
|
||||
mosis::TimerManager manager;
|
||||
|
||||
// Setup _test table for storing state (bypasses proxy __newindex)
|
||||
SetupTestTable(sandbox.GetState());
|
||||
|
||||
// Register timer API
|
||||
mosis::RegisterTimerAPI(sandbox.GetState(), &manager, ctx.app_id);
|
||||
|
||||
// Set a timeout that modifies _test table
|
||||
std::string script =
|
||||
"_test.fired = false\n"
|
||||
"setTimeout(function() _test.fired = true end, 50)\n";
|
||||
|
||||
if (!sandbox.LoadString(script, "timeout_test")) {
|
||||
error_msg = "Failed to set timeout: " + sandbox.GetLastError();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Process timers after delay
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
manager.ProcessTimers();
|
||||
|
||||
// Check if callback fired
|
||||
if (!sandbox.LoadString("assert(_test.fired == true, 'callback did not fire')", "check")) {
|
||||
error_msg = "Timeout callback did not fire: " + sandbox.GetLastError();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_SetIntervalFires(std::string& error_msg) {
|
||||
SandboxContext ctx = TestContext();
|
||||
LuaSandbox sandbox(ctx);
|
||||
// Manager must be declared AFTER sandbox so it's destroyed BEFORE sandbox
|
||||
mosis::TimerManager manager;
|
||||
|
||||
// Setup _test table for storing state
|
||||
SetupTestTable(sandbox.GetState());
|
||||
|
||||
mosis::RegisterTimerAPI(sandbox.GetState(), &manager, ctx.app_id);
|
||||
|
||||
std::string script =
|
||||
"_test.count = 0\n"
|
||||
"setInterval(function() _test.count = _test.count + 1 end, 30)\n";
|
||||
|
||||
if (!sandbox.LoadString(script, "interval_test")) {
|
||||
error_msg = "Failed to set interval: " + sandbox.GetLastError();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Process multiple times
|
||||
for (int i = 0; i < 5; i++) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(40));
|
||||
manager.ProcessTimers();
|
||||
}
|
||||
|
||||
// Should have fired multiple times
|
||||
if (!sandbox.LoadString("assert(_test.count >= 3, 'interval fired only ' .. _test.count .. ' times')", "check")) {
|
||||
error_msg = "Interval did not fire enough times: " + sandbox.GetLastError();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_ClearTimeoutCancels(std::string& error_msg) {
|
||||
SandboxContext ctx = TestContext();
|
||||
LuaSandbox sandbox(ctx);
|
||||
// Manager must be declared AFTER sandbox so it's destroyed BEFORE sandbox
|
||||
mosis::TimerManager manager;
|
||||
|
||||
// Setup _test table for storing state
|
||||
SetupTestTable(sandbox.GetState());
|
||||
|
||||
mosis::RegisterTimerAPI(sandbox.GetState(), &manager, ctx.app_id);
|
||||
|
||||
std::string script =
|
||||
"_test.fired = false\n"
|
||||
"local id = setTimeout(function() _test.fired = true end, 100)\n"
|
||||
"clearTimeout(id)\n";
|
||||
|
||||
if (!sandbox.LoadString(script, "clear_test")) {
|
||||
error_msg = "Failed to clear timeout: " + sandbox.GetLastError();
|
||||
return false;
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(150));
|
||||
manager.ProcessTimers();
|
||||
|
||||
// Should NOT have fired
|
||||
if (!sandbox.LoadString("assert(_test.fired == false, 'callback should not have fired')", "check")) {
|
||||
error_msg = "Cancelled timeout still fired: " + sandbox.GetLastError();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_ClearIntervalCancels(std::string& error_msg) {
|
||||
SandboxContext ctx = TestContext();
|
||||
LuaSandbox sandbox(ctx);
|
||||
// Manager must be declared AFTER sandbox so it's destroyed BEFORE sandbox
|
||||
mosis::TimerManager manager;
|
||||
|
||||
// Setup _test table for storing state
|
||||
SetupTestTable(sandbox.GetState());
|
||||
|
||||
mosis::RegisterTimerAPI(sandbox.GetState(), &manager, ctx.app_id);
|
||||
|
||||
// Store both count and interval ID in _test table so they persist across LoadString calls
|
||||
std::string script =
|
||||
"_test.count = 0\n"
|
||||
"_test.id = setInterval(function() _test.count = _test.count + 1 end, 30)\n";
|
||||
|
||||
if (!sandbox.LoadString(script, "interval_setup")) {
|
||||
error_msg = "Failed to set interval: " + sandbox.GetLastError();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Let it fire once
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(40));
|
||||
manager.ProcessTimers();
|
||||
|
||||
// Now cancel it
|
||||
sandbox.LoadString("clearInterval(_test.id)", "cancel");
|
||||
|
||||
// Wait and process more
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
manager.ProcessTimers();
|
||||
|
||||
// Should have fired only once (or maybe twice due to timing)
|
||||
if (!sandbox.LoadString("assert(_test.count <= 2, 'interval fired too many times: ' .. _test.count)", "check")) {
|
||||
error_msg = "Interval kept firing after cancel: " + sandbox.GetLastError();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_TimerLimitEnforced(std::string& error_msg) {
|
||||
SandboxContext ctx = TestContext();
|
||||
LuaSandbox sandbox(ctx);
|
||||
// Manager must be declared AFTER sandbox so it's destroyed BEFORE sandbox
|
||||
mosis::TimerManager manager;
|
||||
|
||||
mosis::RegisterTimerAPI(sandbox.GetState(), &manager, ctx.app_id);
|
||||
|
||||
// Try to create more than MAX_TIMERS_PER_APP (100) timers
|
||||
std::string script =
|
||||
"created = 0\n"
|
||||
"for i = 1, 150 do\n"
|
||||
" local ok, err = pcall(function()\n"
|
||||
" setTimeout(function() end, 1000000)\n"
|
||||
" end)\n"
|
||||
" if ok then created = created + 1 end\n"
|
||||
"end\n";
|
||||
|
||||
sandbox.LoadString(script, "limit_test");
|
||||
|
||||
// Should be capped at MAX_TIMERS_PER_APP
|
||||
size_t count = manager.GetTimerCount(ctx.app_id);
|
||||
if (count > 100) {
|
||||
error_msg = "Timer limit not enforced: " + std::to_string(count) + " timers created";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_ClearAppTimersCleanup(std::string& error_msg) {
|
||||
SandboxContext ctx = TestContext();
|
||||
LuaSandbox sandbox(ctx);
|
||||
// Manager must be declared AFTER sandbox so it's destroyed BEFORE sandbox
|
||||
mosis::TimerManager manager;
|
||||
|
||||
mosis::RegisterTimerAPI(sandbox.GetState(), &manager, ctx.app_id);
|
||||
|
||||
std::string script =
|
||||
"for i = 1, 10 do\n"
|
||||
" setTimeout(function() end, 1000000)\n"
|
||||
"end\n";
|
||||
|
||||
sandbox.LoadString(script, "cleanup_test");
|
||||
|
||||
size_t before = manager.GetTimerCount(ctx.app_id);
|
||||
EXPECT_TRUE(before == 10);
|
||||
|
||||
// Clear all timers for app (simulating app stop)
|
||||
manager.ClearAppTimers(ctx.app_id);
|
||||
|
||||
size_t after = manager.GetTimerCount(ctx.app_id);
|
||||
EXPECT_TRUE(after == 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Test_MinIntervalEnforced(std::string& error_msg) {
|
||||
SandboxContext ctx = TestContext();
|
||||
LuaSandbox sandbox(ctx);
|
||||
// Manager must be declared AFTER sandbox so it's destroyed BEFORE sandbox
|
||||
mosis::TimerManager manager;
|
||||
|
||||
// Setup _test table for storing state
|
||||
SetupTestTable(sandbox.GetState());
|
||||
|
||||
mosis::RegisterTimerAPI(sandbox.GetState(), &manager, ctx.app_id);
|
||||
|
||||
// Try to set interval less than minimum (10ms)
|
||||
std::string script =
|
||||
"_test.count = 0\n"
|
||||
"setInterval(function() _test.count = _test.count + 1 end, 1)\n"; // 1ms, should be clamped to 10ms
|
||||
|
||||
sandbox.LoadString(script, "min_interval_test");
|
||||
|
||||
// With 1ms interval, in 50ms we'd get 50 callbacks
|
||||
// With 10ms minimum, we should get ~5
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(55));
|
||||
for (int i = 0; i < 10; i++) {
|
||||
manager.ProcessTimers();
|
||||
}
|
||||
|
||||
if (!sandbox.LoadString("assert(_test.count <= 10, 'interval fired too often: ' .. _test.count)", "check")) {
|
||||
error_msg = "Minimum interval not enforced: " + sandbox.GetLastError();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// MAIN
|
||||
//=============================================================================
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
std::string filter;
|
||||
std::string output_file = "test_results.json";
|
||||
|
||||
// Parse args
|
||||
for (int i = 1; i < argc; i++) {
|
||||
std::string arg = argv[i];
|
||||
if (arg == "--test" && i + 1 < argc) {
|
||||
filter = argv[++i];
|
||||
} else if (arg == "--output" && i + 1 < argc) {
|
||||
output_file = argv[++i];
|
||||
} else if (arg == "--help") {
|
||||
std::cout << "Usage: sandbox-test [options]\n";
|
||||
std::cout << "Options:\n";
|
||||
std::cout << " --test <name> Run only tests containing <name>\n";
|
||||
std::cout << " --output <file> Write JSON report to <file>\n";
|
||||
std::cout << " --help Show this help\n";
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
std::cout << "========================================\n";
|
||||
std::cout << " LUA SANDBOX SECURITY TESTS\n";
|
||||
std::cout << "========================================\n\n";
|
||||
|
||||
// Check scripts directory exists
|
||||
if (!std::filesystem::exists(GetScriptsDir())) {
|
||||
std::cerr << "ERROR: Scripts directory not found: " << GetScriptsDir() << "\n";
|
||||
std::cerr << "Make sure to run from the build directory.\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Register tests
|
||||
TestHarness harness;
|
||||
|
||||
// Milestone 1: Core Sandbox
|
||||
harness.AddTest("DangerousGlobalsRemoved", Test_DangerousGlobalsRemoved);
|
||||
harness.AddTest("BytecodeRejected", Test_BytecodeRejected);
|
||||
harness.AddTest("MemoryLimitEnforced", Test_MemoryLimitEnforced);
|
||||
harness.AddTest("CPULimitEnforced", Test_CPULimitEnforced);
|
||||
harness.AddTest("MetatableProtected", Test_MetatableProtected);
|
||||
harness.AddTest("SafeOperationsWork", Test_SafeOperationsWork);
|
||||
harness.AddTest("StringDumpRemoved", Test_StringDumpRemoved);
|
||||
harness.AddTest("MemoryTracking", Test_MemoryTracking);
|
||||
harness.AddTest("InstructionCounting", Test_InstructionCounting);
|
||||
harness.AddTest("MultipleLoads", Test_MultipleLoads);
|
||||
harness.AddTest("ErrorRecovery", Test_ErrorRecovery);
|
||||
|
||||
// Milestone 2: Permission System
|
||||
harness.AddTest("NormalPermissionAutoGranted", Test_NormalPermissionAutoGranted);
|
||||
harness.AddTest("DangerousPermissionRequiresGrant", Test_DangerousPermissionRequiresGrant);
|
||||
harness.AddTest("SignaturePermissionSystemOnly", Test_SignaturePermissionSystemOnly);
|
||||
harness.AddTest("UserGestureTracking", Test_UserGestureTracking);
|
||||
harness.AddTest("UndeclaredPermissionDenied", Test_UndeclaredPermissionDenied);
|
||||
harness.AddTest("SystemAppGetsDangerousAuto", Test_SystemAppGetsDangerousAuto);
|
||||
harness.AddTest("PermissionCategoryCheck", Test_PermissionCategoryCheck);
|
||||
|
||||
// Milestone 3: Audit Logging & Rate Limiting
|
||||
harness.AddTest("AuditLogBasic", Test_AuditLogBasic);
|
||||
harness.AddTest("AuditLogRingBuffer", Test_AuditLogRingBuffer);
|
||||
harness.AddTest("AuditLogThreadSafe", Test_AuditLogThreadSafe);
|
||||
harness.AddTest("RateLimiterBasic", Test_RateLimiterBasic);
|
||||
harness.AddTest("RateLimiterExhaustion", Test_RateLimiterExhaustion);
|
||||
harness.AddTest("RateLimiterRefill", Test_RateLimiterRefill);
|
||||
harness.AddTest("RateLimiterAppIsolation", Test_RateLimiterAppIsolation);
|
||||
harness.AddTest("RateLimiterReset", Test_RateLimiterReset);
|
||||
harness.AddTest("RateLimiterNoConfig", Test_RateLimiterNoConfig);
|
||||
|
||||
// Milestone 4: Safe Path & Require
|
||||
harness.AddTest("PathRejectsTraversal", Test_PathRejectsTraversal);
|
||||
harness.AddTest("PathRejectsAbsolute", Test_PathRejectsAbsolute);
|
||||
harness.AddTest("PathAcceptsValid", Test_PathAcceptsValid);
|
||||
harness.AddTest("ModuleNameValidation", Test_ModuleNameValidation);
|
||||
harness.AddTest("ModuleToPath", Test_ModuleToPath);
|
||||
harness.AddTest("SafeRequireLoads", Test_SafeRequireLoads);
|
||||
harness.AddTest("SafeRequireCaches", Test_SafeRequireCaches);
|
||||
harness.AddTest("SafeRequireRejectsInvalid", Test_SafeRequireRejectsInvalid);
|
||||
|
||||
// Milestone 5: Timer & Callback System
|
||||
harness.AddTest("SetTimeoutFires", Test_SetTimeoutFires);
|
||||
harness.AddTest("SetIntervalFires", Test_SetIntervalFires);
|
||||
harness.AddTest("ClearTimeoutCancels", Test_ClearTimeoutCancels);
|
||||
harness.AddTest("ClearIntervalCancels", Test_ClearIntervalCancels);
|
||||
harness.AddTest("TimerLimitEnforced", Test_TimerLimitEnforced);
|
||||
harness.AddTest("ClearAppTimersCleanup", Test_ClearAppTimersCleanup);
|
||||
harness.AddTest("MinIntervalEnforced", Test_MinIntervalEnforced);
|
||||
|
||||
// Run tests
|
||||
auto results = harness.Run(filter);
|
||||
|
||||
// Output
|
||||
harness.PrintResults(results);
|
||||
harness.WriteJsonReport(results, output_file);
|
||||
|
||||
std::cout << "\nJSON report written to: " << output_file << "\n";
|
||||
|
||||
// Return non-zero if any tests failed
|
||||
int failed = 0;
|
||||
for (const auto& r : results) {
|
||||
if (!r.passed) failed++;
|
||||
}
|
||||
|
||||
return failed > 0 ? 1 : 0;
|
||||
}
|
||||
128
sandbox-test/src/test_harness.cpp
Normal file
128
sandbox-test/src/test_harness.cpp
Normal file
@@ -0,0 +1,128 @@
|
||||
#include "test_harness.h"
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <ctime>
|
||||
|
||||
void TestHarness::AddTest(const std::string& name, std::function<bool(std::string&)> func) {
|
||||
m_tests.push_back({name, func});
|
||||
}
|
||||
|
||||
std::vector<TestResult> TestHarness::Run(const std::string& filter) {
|
||||
std::vector<TestResult> results;
|
||||
|
||||
for (const auto& test : m_tests) {
|
||||
// Filter check
|
||||
if (!filter.empty() && test.name.find(filter) == std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
TestResult result;
|
||||
result.name = test.name;
|
||||
|
||||
std::cout << "Running: " << test.name << "... " << std::flush;
|
||||
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
|
||||
try {
|
||||
std::string error;
|
||||
result.passed = test.func(error);
|
||||
result.error_message = error;
|
||||
} catch (const std::exception& e) {
|
||||
result.passed = false;
|
||||
result.error_message = std::string("Exception: ") + e.what();
|
||||
} catch (...) {
|
||||
result.passed = false;
|
||||
result.error_message = "Unknown exception";
|
||||
}
|
||||
|
||||
auto end = std::chrono::steady_clock::now();
|
||||
result.duration_ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
|
||||
|
||||
if (result.passed) {
|
||||
std::cout << "PASSED (" << result.duration_ms << "ms)\n";
|
||||
} else {
|
||||
std::cout << "FAILED\n";
|
||||
std::cout << " Error: " << result.error_message << "\n";
|
||||
}
|
||||
|
||||
results.push_back(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
void TestHarness::WriteJsonReport(const std::vector<TestResult>& results, const std::string& path) {
|
||||
nlohmann::json report;
|
||||
|
||||
// Get timestamp
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto time = std::chrono::system_clock::to_time_t(now);
|
||||
std::stringstream ss;
|
||||
ss << std::put_time(std::gmtime(&time), "%Y-%m-%dT%H:%M:%SZ");
|
||||
|
||||
report["name"] = "Lua Sandbox Security Tests";
|
||||
report["timestamp"] = ss.str();
|
||||
|
||||
int passed = 0, failed = 0;
|
||||
for (const auto& r : results) {
|
||||
if (r.passed) passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
report["summary"]["passed"] = passed;
|
||||
report["summary"]["failed"] = failed;
|
||||
report["summary"]["total"] = static_cast<int>(results.size());
|
||||
|
||||
nlohmann::json tests = nlohmann::json::array();
|
||||
for (const auto& r : results) {
|
||||
nlohmann::json t;
|
||||
t["name"] = r.name;
|
||||
t["status"] = r.passed ? "passed" : "failed";
|
||||
t["duration_ms"] = r.duration_ms;
|
||||
if (!r.passed && !r.error_message.empty()) {
|
||||
t["error"] = r.error_message;
|
||||
}
|
||||
tests.push_back(t);
|
||||
}
|
||||
report["tests"] = tests;
|
||||
|
||||
std::ofstream f(path);
|
||||
f << report.dump(2);
|
||||
}
|
||||
|
||||
void TestHarness::PrintResults(const std::vector<TestResult>& results) {
|
||||
std::cout << "\n";
|
||||
std::cout << "========================================\n";
|
||||
std::cout << " TEST RESULTS\n";
|
||||
std::cout << "========================================\n\n";
|
||||
|
||||
int passed = 0, failed = 0;
|
||||
for (const auto& r : results) {
|
||||
if (r.passed) passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
std::cout << "Total: " << results.size() << "\n";
|
||||
std::cout << "Passed: " << passed << "\n";
|
||||
std::cout << "Failed: " << failed << "\n\n";
|
||||
|
||||
if (failed > 0) {
|
||||
std::cout << "FAILED TESTS:\n";
|
||||
for (const auto& r : results) {
|
||||
if (!r.passed) {
|
||||
std::cout << " - " << r.name << "\n";
|
||||
std::cout << " " << r.error_message << "\n";
|
||||
}
|
||||
}
|
||||
std::cout << "\n";
|
||||
}
|
||||
|
||||
if (failed == 0) {
|
||||
std::cout << "ALL TESTS PASSED!\n";
|
||||
} else {
|
||||
std::cout << "SOME TESTS FAILED!\n";
|
||||
}
|
||||
|
||||
std::cout << "========================================\n";
|
||||
}
|
||||
85
sandbox-test/src/test_harness.h
Normal file
85
sandbox-test/src/test_harness.h
Normal file
@@ -0,0 +1,85 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
|
||||
// Simple test result
|
||||
struct TestResult {
|
||||
std::string name;
|
||||
bool passed;
|
||||
std::string error_message;
|
||||
int64_t duration_ms;
|
||||
};
|
||||
|
||||
// Test case definition
|
||||
struct TestCase {
|
||||
std::string name;
|
||||
std::function<bool(std::string&)> func; // Returns true if passed, error in string
|
||||
};
|
||||
|
||||
// Test runner
|
||||
class TestHarness {
|
||||
public:
|
||||
void AddTest(const std::string& name, std::function<bool(std::string&)> func);
|
||||
|
||||
// Run all tests or filter by name
|
||||
std::vector<TestResult> Run(const std::string& filter = "");
|
||||
|
||||
// Output results as JSON
|
||||
void WriteJsonReport(const std::vector<TestResult>& results, const std::string& path);
|
||||
|
||||
// Print results to console
|
||||
void PrintResults(const std::vector<TestResult>& results);
|
||||
|
||||
private:
|
||||
std::vector<TestCase> m_tests;
|
||||
};
|
||||
|
||||
// Assertion macros
|
||||
#define EXPECT_TRUE(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
error_msg = std::string(__FILE__) + ":" + std::to_string(__LINE__) + \
|
||||
": EXPECT_TRUE(" #cond ") failed"; \
|
||||
return false; \
|
||||
} \
|
||||
} while(0)
|
||||
|
||||
#define EXPECT_FALSE(cond) \
|
||||
do { \
|
||||
if (cond) { \
|
||||
error_msg = std::string(__FILE__) + ":" + std::to_string(__LINE__) + \
|
||||
": EXPECT_FALSE(" #cond ") failed"; \
|
||||
return false; \
|
||||
} \
|
||||
} while(0)
|
||||
|
||||
#define EXPECT_EQ(a, b) \
|
||||
do { \
|
||||
if ((a) != (b)) { \
|
||||
error_msg = std::string(__FILE__) + ":" + std::to_string(__LINE__) + \
|
||||
": EXPECT_EQ failed: " + std::to_string(a) + " != " + std::to_string(b); \
|
||||
return false; \
|
||||
} \
|
||||
} while(0)
|
||||
|
||||
#define EXPECT_NE(a, b) \
|
||||
do { \
|
||||
if ((a) == (b)) { \
|
||||
error_msg = std::string(__FILE__) + ":" + std::to_string(__LINE__) + \
|
||||
": EXPECT_NE failed: values are equal"; \
|
||||
return false; \
|
||||
} \
|
||||
} while(0)
|
||||
|
||||
#define EXPECT_CONTAINS(haystack, needle) \
|
||||
do { \
|
||||
if ((haystack).find(needle) == std::string::npos) { \
|
||||
error_msg = std::string(__FILE__) + ":" + std::to_string(__LINE__) + \
|
||||
": EXPECT_CONTAINS failed: '" + (haystack) + "' does not contain '" + (needle) + "'"; \
|
||||
return false; \
|
||||
} \
|
||||
} while(0)
|
||||
Reference in New Issue
Block a user