+package eu.svjatoslav.alyverkko.assistant;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Drives the real {@code Tools/memory delete} against a temporary
+ * profiles root: plain deletion, unknown/invalid names, the
+ * chat-in-use and stale-ACTIVE guards, and encrypted vault deletion
+ * (the mounted case needs real gocryptfs and is skipped without it).
+ */
+class ProfileDeleteTest {
+
+ private static final String PASSPHRASE = "correct horse battery";
+
+ @TempDir
+ Path profilesRoot;
+
+ @AfterEach
+ void lockEverything() throws Exception {
+ // A mounted vault would block @TempDir cleanup.
+ runScript("Tools/vault", null, "lock");
+ }
+
+ @Test
+ void deletesPlainProfile() throws Exception {
+ final Path profile = Files.createDirectories(
+ profilesRoot.resolve("Alpha/Conversations"));
+ Files.writeString(profile.resolve("prompt.txt"), "You are A.\n");
+ Files.writeString(
+ profilesRoot.resolve("Alpha/Conversations/t.jsonl"),
+ "{}\n");
+
+ final Result result = delete("Alpha");
+ assertEquals(0, result.exit(), result.output());
+ assertFalse(Files.exists(profilesRoot.resolve("Alpha")));
+ assertTrue(result.output().contains("deleted profile 'Alpha'"));
+ }
+
+ @Test
+ void refusesUnknownAndInvalidNames() throws Exception {
+ assertEquals(2, delete("NoSuch").exit());
+ assertEquals(2, delete("a/b").exit());
+ assertEquals(2, delete("").exit());
+ }
+
+ @Test
+ void refusesWhileChatRunsOnTheProfile() throws Exception {
+ Files.createDirectories(profilesRoot.resolve("Alpha"));
+ Files.writeString(profilesRoot.resolve("Alpha/prompt.txt"), "x\n");
+ // A live PID in .lock + ACTIVE naming the profile = a chat on
+ // it is running.
+ Files.writeString(profilesRoot.resolve(".lock"),
+ String.valueOf(ProcessHandle.current().pid()));
+ Files.writeString(profilesRoot.resolve("ACTIVE"), "Alpha");
+
+ final Result result = delete("Alpha");
+ assertEquals(2, result.exit(), result.output());
+ assertTrue(result.output().contains("chat on 'Alpha'"),
+ result.output());
+ assertTrue(Files.exists(profilesRoot.resolve("Alpha")),
+ "refusal must not delete");
+ }
+
+ @Test
+ void staleActiveIsClearedOnDelete() throws Exception {
+ Files.createDirectories(profilesRoot.resolve("Alpha"));
+ Files.writeString(profilesRoot.resolve("Alpha/prompt.txt"), "x\n");
+ // No live lock: ACTIVE is stale, deletion proceeds and drops it.
+ Files.writeString(profilesRoot.resolve("ACTIVE"), "Alpha");
+
+ assertEquals(0, delete("Alpha").exit());
+ assertFalse(Files.exists(profilesRoot.resolve("ACTIVE")));
+ }
+
+ @Test
+ void deletesLockedVaultWithoutGocryptfs() throws Exception {
+ // A locked vault is ciphertext only: deletion needs no mount
+ // handling, so no gocryptfs either.
+ Files.createDirectories(profilesRoot.resolve("Secret.vault"));
+ Files.writeString(
+ profilesRoot.resolve("Secret.vault/gocryptfs.conf"),
+ "{}\n");
+
+ final Result result = delete("Secret");
+ assertEquals(0, result.exit(), result.output());
+ assertFalse(Files.exists(profilesRoot.resolve("Secret.vault")));
+ }
+
+ @Test
+ void deletesMountedVaultViaRealGocryptfs() throws Exception {
+ assumeTrue(onPath("gocryptfs") && onPath("fusermount"),
+ "gocryptfs/fusermount not installed");
+ assertEquals(0, runScript("Tools/vault", PASSPHRASE,
+ "init", "Secret").exit());
+ Files.writeString(profilesRoot.resolve("Secret/prompt.txt"),
+ "top secret\n");
+
+ final Result result = delete("Secret");
+ assertEquals(0, result.exit(), result.output());
+ assertFalse(Files.exists(profilesRoot.resolve("Secret.vault")));
+ assertFalse(mountpoint(profilesRoot.resolve("Secret")),
+ "vault unmounted");
+ }
+
+ /** True when dir is an active mountpoint. */
+ private static boolean mountpoint(final Path dir)
+ throws IOException, InterruptedException {
+ return new ProcessBuilder("mountpoint", "-q", dir.toString())
+ .start().waitFor() == 0;
+ }
+
+ /** True when the named binary is on PATH. */
+ private static boolean onPath(final String binary) {
+ for (final String dir
+ : System.getenv("PATH").split(java.io.File.pathSeparator)) {
+ if (Files.isExecutable(Path.of(dir, binary))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Script result: exit code and merged stdout/stderr. */
+ private record Result(int exit, String output) {
+ }
+
+ private Result delete(final String name)
+ throws IOException, InterruptedException {
+ return runScript("Tools/memory", null, "delete", name);
+ }
+
+ /**
+ * Runs a Tools script with the optional passphrase on stdin and
+ * ALYVERKKO_PROFILES_ROOT pointing at the temp root.
+ */
+ private Result runScript(final String script, final String stdin,
+ final String... args)
+ throws IOException, InterruptedException {
+ final String[] command = new String[args.length + 1];
+ command[0] = script;
+ System.arraycopy(args, 0, command, 1, args.length);
+ final ProcessBuilder builder = new ProcessBuilder(command);
+ builder.environment().put("ALYVERKKO_PROFILES_ROOT",
+ profilesRoot.toAbsolutePath().toString());
+ builder.redirectErrorStream(true);
+ final Process process = builder.start();
+ final OutputStream in = process.getOutputStream();
+ try {
+ if (stdin != null) {
+ in.write((stdin + "\n").getBytes(StandardCharsets.UTF_8));
+ }
+ in.close();
+ } catch (final IOException ignored) {
+ // dead pipe: the script exited before reading stdin
+ }
+ final String output = new String(
+ process.getInputStream().readAllBytes(),
+ StandardCharsets.UTF_8);
+ return new Result(process.waitFor(), output);
+ }
+}