Volver al índice

src/main/java/ar/com/companeros/ToolSafetyTests.java

package ar.com.companeros;

import ar.com.companeros.tools.PrimitiveExecutor;
import ar.com.companeros.tools.ToolExecution;
import ar.com.companeros.tools.ToolManifest;
import ar.com.companeros.tools.ToolPermission;
import ar.com.companeros.tools.ToolRegistry;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.io.IOException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraftforge.gametest.GameTestHolder;
import net.minecraftforge.gametest.PrefixGameTestTemplate;

/** Pruebas de permisos y ejecución DSL; no sustituyen las pruebas físicas del actor. */
@GameTestHolder(Companeros.ID)
@PrefixGameTestTemplate(false)
public final class ToolSafetyTests {
    private static final UUID AUTHOR = UUID.fromString("00000000-0000-0000-0000-000000000123");
    private static final UUID ACTOR = UUID.fromString("00000000-0000-0000-0000-000000000456");

    @GameTest(template = "empty")
    public void rejectsOperatingSystemAndUnexpectedArguments(GameTestHelper helper) {
        ToolRegistry registry = registry();
        expectRejected(helper, () -> registry.registerCertified(manifest("shell_tool", "shell", new JsonObject(), ToolPermission.WORLD_READ, 20, 10)),
            "No admite primitivas de sistema operativo");
        JsonObject args = new JsonObject(); args.addProperty("command", "stop");
        expectRejected(helper, () -> registry.registerCertified(manifest("command_tool", "inspect", args, ToolPermission.WORLD_READ, 20, 10)),
            "Una primitiva no admite comandos escondidos como argumentos");
        helper.succeed();
    }

    @GameTest(template = "empty")
    public void proposalsStayPendingAndAuthorIsTrusted(GameTestHelper helper) {
        ToolRegistry registry = registry();
        ToolManifest proposed = manifest("pending_test", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 10);
        try { registry.propose(AUTHOR, proposed.toJson().toString()); }
        catch (IOException exception) { throw new IllegalStateException(exception); }
        helper.assertTrue(registry.pendingProposals().get(0).manifest().author().equals(AUTHOR), "El autor proviene del contexto confiable");
        expectRejected(helper, () -> registry.start(proposed.id(), proposed.version(), context(Set.of(ToolPermission.WORLD_READ), new JsonObject()), completedExecutor(new AtomicInteger())),
            "Una propuesta pendiente no se ejecuta");
        JsonObject forged = proposed.toJson(); forged.addProperty("author", ACTOR.toString());
        expectRejected(helper, () -> ToolManifest.parse(forged.toString(), AUTHOR), "El JSON no puede falsificar el autor");
        helper.succeed();
    }

    @GameTest(template = "empty")
    public void missingPermissionStartsNoPrimitive(GameTestHelper helper) {
        ToolRegistry registry = registry();
        ToolManifest tool = manifest("permission_test", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 10);
        registry.registerCertified(tool);
        AtomicInteger started = new AtomicInteger();
        expectRejected(helper, () -> registry.start(tool.id(), 1, context(Set.of(), new JsonObject()), completedExecutor(started)),
            "Comprueba los permisos antes de tocar el mundo");
        helper.assertTrue(started.get() == 0, "No inicia acciones sin permisos");
        helper.succeed();
    }

    @GameTest(template = "empty")
    public void runtimeResolvesTypedInputsAndCountsResultOnce(GameTestHelper helper) {
        ToolRegistry registry = registry();
        JsonObject args = position(0, 0, 0);
        JsonObject reference = new JsonObject(); reference.addProperty("input", "destination_x"); args.add("x", reference);
        ToolManifest tool = manifest("input_test", "move_to", args, ToolPermission.MOVE, 20, 10);
        registry.registerCertified(tool);
        JsonObject inputs = new JsonObject(); inputs.addProperty("destination_x", 12.5);
        AtomicInteger calls = new AtomicInteger();
        PrimitiveExecutor executor = (actor, invocation, primitive, resolved) -> {
            helper.assertTrue(actor.equals(ACTOR) && resolved.get("x").getAsDouble() == 12.5, "Conserva actor y tipo del argumento");
            calls.incrementAndGet();
            return completedTask();
        };
        ToolExecution execution = registry.start(tool.id(), 1, context(Set.of(ToolPermission.MOVE), inputs), executor);
        helper.assertTrue(execution.tick(0) == ToolExecution.Status.SUCCEEDED, "Ejecuta una herramienta certificada");
        execution.tick(0); execution.tick(1); execution.cancel();
        helper.assertTrue(calls.get() == 1 && registry.statistics(tool.id(), 1).successes() == 1, "No duplica acciones ni estadísticas");
        helper.succeed();
    }

    @GameTest(template = "empty")
    public void timeoutsCancelActiveAction(GameTestHelper helper) {
        ToolRegistry registry = registry();
        ToolManifest tool = manifest("timeout_test", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 3);
        registry.registerCertified(tool);
        AtomicInteger cancellations = new AtomicInteger();
        PrimitiveExecutor executor = (actor, invocation, primitive, args) -> new PrimitiveExecutor.PrimitiveTask() {
            public PrimitiveExecutor.Result tick(long tick) { return PrimitiveExecutor.Result.running(.25); }
            public void cancel() { cancellations.incrementAndGet(); }
        };
        ToolExecution execution = registry.start(tool.id(), 1, context(Set.of(ToolPermission.WORLD_READ), new JsonObject()), executor);
        helper.assertTrue(execution.tick(0) == ToolExecution.Status.RUNNING, "Puede ejecutar una acción larga localmente");
        helper.assertTrue(execution.tick(3) == ToolExecution.Status.FAILED, "El tiempo del paso está acotado");
        execution.cancel(); execution.tick(4);
        helper.assertTrue(cancellations.get() == 1 && registry.statistics(tool.id(), 1).failures() == 1, "Cancela y registra un solo resultado");
        helper.succeed();
    }

    @GameTest(template = "empty")
    public void certifiedVersionsAreImmutableAndDependenciesPinned(GameTestHelper helper) {
        ToolRegistry registry = registry();
        ToolManifest child = manifest("dependency_child", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 10);
        registry.registerCertified(child);
        JsonObject changed = child.toJson(); changed.addProperty("description", "Otro contenido");
        expectRejected(helper, () -> registry.registerCertified(ToolManifest.parse(changed.toString(), AUTHOR)), "La misma versión no cambia de contenido");
        JsonObject parentJson = composition("dependency_parent", child, child.sha256(), 20, 10);
        ToolManifest parent = ToolManifest.parse(parentJson.toString(), AUTHOR);
        registry.registerCertified(parent);
        AtomicInteger calls = new AtomicInteger();
        ToolExecution execution = registry.start(parent.id(), 1, context(Set.of(ToolPermission.WORLD_READ), new JsonObject()), completedExecutor(calls));
        helper.assertTrue(execution.tick(0) == ToolExecution.Status.SUCCEEDED && calls.get() == 1, "Reutiliza una dependencia certificada");
        expectRejected(helper, () -> registry.registerCertified(ToolManifest.parse(composition("wrong_hash", child, "0".repeat(64), 20, 10).toString(), AUTHOR)),
            "Un hash diferente impide ejecutar una dependencia");
        helper.succeed();
    }

    @GameTest(template = "empty")
    public void nestedCompositionHonorsCallTimeout(GameTestHelper helper) {
        ToolRegistry registry = registry();
        ToolManifest child = manifest("nested_child", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 15);
        registry.registerCertified(child);
        ToolManifest parent = ToolManifest.parse(composition("nested_parent", child, child.sha256(), 30, 2).toString(), AUTHOR);
        registry.registerCertified(parent);
        ToolExecution execution = registry.start(parent.id(), 1, context(Set.of(ToolPermission.WORLD_READ), new JsonObject()),
            (actor, invocation, primitive, args) -> new PrimitiveExecutor.PrimitiveTask() {
                public PrimitiveExecutor.Result tick(long tick) { return PrimitiveExecutor.Result.running(0); }
                public void cancel() { }
            });
        helper.assertTrue(execution.tick(0) == ToolExecution.Status.RUNNING && execution.tick(2) == ToolExecution.Status.FAILED,
            "La composición no permite evadir el límite de su llamada");
        helper.succeed();
    }

    @GameTest(template = "empty")
    public void cancellationStopsSequenceAndPersistsStatistics(GameTestHelper helper) {
        ToolRegistry registry = registry();
        ToolManifest original = manifest("cancel_test", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 10);
        JsonObject source = original.toJson();
        source.getAsJsonArray("steps").add(source.getAsJsonArray("steps").get(0).deepCopy());
        ToolManifest tool = ToolManifest.parse(source.toString(), AUTHOR);
        registry.registerCertified(tool);
        AtomicInteger calls = new AtomicInteger();
        ToolExecution execution = registry.start(tool.id(), 1, context(Set.of(ToolPermission.WORLD_READ), new JsonObject()), completedExecutor(calls));
        helper.assertTrue(execution.tick(0) == ToolExecution.Status.RUNNING && calls.get() == 1, "Inicia como máximo un paso por tick");
        execution.cancel(); execution.tick(1);
        helper.assertTrue(calls.get() == 1 && registry.statistics(tool.id(), 1).cancellations() == 1, "No continúa luego de cancelar");
        try { registry.saveStatistics(); }
        catch (IOException exception) { throw new IllegalStateException(exception); }
        helper.succeed();
    }

    @GameTest(template = "empty")
    public void certificationRequiresMatchingTestEvidence(GameTestHelper helper) {
        ToolRegistry registry = registry();
        ToolManifest tool = manifest("review_test", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 10);
        try { registry.propose(AUTHOR, tool.toJson().toString()); }
        catch (IOException exception) { throw new IllegalStateException(exception); }
        expectRejected(helper, () -> registry.certify(tool.sha256(), ACTOR,
            new ToolRegistry.TestEvidence("0".repeat(64), "sandbox", true, Instant.now())), "No acepta evidencia de otro contenido");
        expectRejected(helper, () -> registry.certify(tool.sha256(), ACTOR,
            new ToolRegistry.TestEvidence(tool.sha256(), "sandbox", false, Instant.now())), "No acepta una prueba fallida");
        registry.certify(tool.sha256(), ACTOR, new ToolRegistry.TestEvidence(tool.sha256(), "sandbox", true, Instant.now()));
        helper.assertTrue(registry.pendingProposals().isEmpty() && registry.certifiedTools().size() == 1, "Solo la promoción explícita concede ejecución");
        helper.succeed();
    }

    @GameTest(template = "empty")
    public void fileLoadCannotGrantCertification(GameTestHelper helper) {
        Path directory = testDirectory();
        ToolRegistry first = registry(directory);
        ToolManifest tool = manifest("reload_test", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 10);
        try {
            first.propose(AUTHOR, tool.toJson().toString());
            ToolRegistry loaded = new ToolRegistry(directory);
            ToolRegistry.LoadReport report = loaded.loadProposals();
            helper.assertTrue(report.loaded() == 1 && report.rejected().isEmpty(), "Recupera la propuesta persistente");
            helper.assertTrue(loaded.certifiedTools().isEmpty() && loaded.pendingProposals().size() == 1, "Los archivos no conceden aprobación");
            expectRejected(helper, () -> loaded.start(tool.id(), 1, context(Set.of(ToolPermission.WORLD_READ), new JsonObject()), completedExecutor(new AtomicInteger())),
                "El reinicio no habilita propuestas pendientes");
        } catch (IOException exception) { throw new IllegalStateException(exception); }
        helper.succeed();
    }

    @GameTest(template = "empty")
    public void internalChatAcceptsAllNamesAndCanonicalUuid(GameTestHelper helper) {
        ToolRegistry registry = registry();
        JsonObject args = new JsonObject();
        JsonObject recipientReference = new JsonObject(); recipientReference.addProperty("input", "recipient");
        args.add("recipient", recipientReference); args.addProperty("text", "Encontré hierro");
        ToolManifest tool = manifest("chat_recipients", "message_internal", args, ToolPermission.INTERNAL_CHAT, 20, 10);
        registry.registerCertified(tool);
        AtomicInteger calls = new AtomicInteger();
        for (String recipient : new String[] { "all", "Helio", ACTOR.toString() }) {
            JsonObject inputs = new JsonObject(); inputs.addProperty("recipient", recipient);
            ToolExecution execution = registry.start(tool.id(), 1, context(Set.of(ToolPermission.INTERNAL_CHAT), inputs),
                (actor, invocation, primitive, resolved) -> {
                    helper.assertTrue(resolved.get("recipient").getAsString().equals(recipient), "Conserva el destino como dato de comunicación");
                    calls.incrementAndGet(); return completedTask();
                });
            helper.assertTrue(execution.tick(0) == ToolExecution.Status.SUCCEEDED, "El catálogo acepta destinos soportados por el chat interno");
        }
        helper.assertTrue(calls.get() == 3, "Los tres formatos de destino llegan al ejecutor");
        helper.succeed();
    }

    @GameTest(template = "empty")
    public void chatDestinationCannotInjectCommandsOrSelectSender(GameTestHelper helper) {
        ToolRegistry registry = registry();
        JsonObject args = new JsonObject();
        JsonObject reference = new JsonObject(); reference.addProperty("input", "recipient");
        args.add("recipient", reference); args.addProperty("text", "Mensaje");
        ToolManifest tool = manifest("chat_invalid_destination", "message_internal", args, ToolPermission.INTERNAL_CHAT, 20, 10);
        registry.registerCertified(tool);
        AtomicInteger calls = new AtomicInteger();
        for (String recipient : new String[] { "/botmalo", "x".repeat(17), "../secrets" }) {
            JsonObject inputs = new JsonObject(); inputs.addProperty("recipient", recipient);
            expectRejected(helper, () -> registry.start(tool.id(), 1, context(Set.of(ToolPermission.INTERNAL_CHAT), inputs), completedExecutor(calls)),
                "El destino no puede convertirse en comando ni ruta");
        }
        JsonObject forged = new JsonObject(); forged.addProperty("recipient", "all"); forged.addProperty("text", "Mensaje");
        forged.addProperty("sender", AUTHOR.toString());
        expectRejected(helper, () -> registry.registerCertified(manifest("chat_forged_sender", "message_internal", forged, ToolPermission.INTERNAL_CHAT, 20, 10)),
            "El remitente pertenece al contexto confiable y no a los argumentos");
        JsonObject target = new JsonObject(); target.addProperty("target", "Helio");
        expectRejected(helper, () -> registry.registerCertified(manifest("attack_name", "attack", target, ToolPermission.COMBAT, 20, 10)),
            "El ataque conserva su requisito de UUID");
        helper.assertTrue(calls.get() == 0, "Los argumentos inválidos no inician primitivas");
        helper.succeed();
    }

    // Helpers: modelos y contadores solamente; no escriben bloques ni inventarios.
    private static ToolRegistry registry() { return registry(testDirectory()); }
    private static Path testDirectory() { return Path.of("tool-test-output", UUID.randomUUID().toString()); }
    private static ToolRegistry registry(Path path) {
        try { return new ToolRegistry(path); }
        catch (IOException exception) { throw new IllegalStateException(exception); }
    }
    private static ToolRegistry.ExecutionContext context(Set<ToolPermission> permissions, JsonObject inputs) {
        return new ToolRegistry.ExecutionContext(ACTOR, 0, permissions, inputs);
    }
    private static ToolManifest manifest(String id, String primitive, JsonObject args, ToolPermission permission, int maxTicks, int timeout) {
        JsonObject source = new JsonObject(); source.addProperty("schema", 1); source.addProperty("id", id);
        source.addProperty("version", 1); source.addProperty("description", "Prueba de herramienta acotada");
        JsonArray permissions = new JsonArray(); permissions.add(permission.name()); source.add("permissions", permissions);
        source.add("dependencies", new JsonArray());
        JsonObject limits = new JsonObject(); limits.addProperty("maxTicks", maxTicks); limits.addProperty("maxSteps", 16); source.add("limits", limits);
        JsonObject step = new JsonObject(); step.addProperty("primitive", primitive); step.add("args", args.deepCopy()); step.addProperty("timeoutTicks", timeout);
        JsonArray steps = new JsonArray(); steps.add(step); source.add("steps", steps);
        return ToolManifest.parse(source.toString(), AUTHOR);
    }
    private static JsonObject composition(String id, ToolManifest child, String hash, int maxTicks, int timeout) {
        JsonObject source = manifest(id, "inspect", new JsonObject(), ToolPermission.WORLD_READ, maxTicks, timeout).toJson();
        JsonObject dependency = new JsonObject(); dependency.addProperty("id", child.id()); dependency.addProperty("version", child.version()); dependency.addProperty("sha256", hash);
        JsonArray dependencies = new JsonArray(); dependencies.add(dependency); source.add("dependencies", dependencies);
        JsonObject call = new JsonObject(); call.addProperty("tool", child.id()); call.addProperty("version", child.version()); call.addProperty("sha256", hash);
        call.add("args", new JsonObject()); call.addProperty("timeoutTicks", timeout);
        JsonArray steps = new JsonArray(); steps.add(call); source.add("steps", steps);
        return source;
    }
    private static JsonObject position(int x, int y, int z) {
        JsonObject args = new JsonObject(); args.addProperty("x", x); args.addProperty("y", y); args.addProperty("z", z); return args;
    }
    private static PrimitiveExecutor completedExecutor(AtomicInteger started) {
        return (actor, invocation, primitive, arguments) -> { started.incrementAndGet(); return completedTask(); };
    }
    private static PrimitiveExecutor.PrimitiveTask completedTask() {
        return new PrimitiveExecutor.PrimitiveTask() {
            public PrimitiveExecutor.Result tick(long tick) { return PrimitiveExecutor.Result.success(); }
            public void cancel() { }
        };
    }
    private static void expectRejected(GameTestHelper helper, Runnable action, String message) {
        boolean rejected = false;
        try { action.run(); } catch (IllegalArgumentException expected) { rejected = true; }
        helper.assertTrue(rejected, message);
    }
}