223 lines
8.5 KiB
JavaScript
223 lines
8.5 KiB
JavaScript
/**
|
|
* PR-3: Get Shadow Migration Test
|
|
*
|
|
* Run: node --test test/memory-core/get-shadow.test.mjs
|
|
*
|
|
* Compares legacy memory_get (file system read) vs MemoryCore.get()
|
|
* across test cases: valid files, missing files, path traversal, etc.
|
|
*/
|
|
|
|
import { describe, it, before } from "node:test";
|
|
import assert from "node:assert";
|
|
import { readFileSync, existsSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = join(fileURLToPath(import.meta.url), "..", "..", "..");
|
|
const ROOT = resolve(__dirname);
|
|
|
|
let createMemoryCore;
|
|
let mc;
|
|
|
|
before(async () => {
|
|
const mod = await import(join(ROOT, "src", "memory-core", "memory-core-facade.mjs"));
|
|
createMemoryCore = mod.createMemoryCore;
|
|
mc = createMemoryCore(ROOT);
|
|
});
|
|
|
|
/**
|
|
* Simulate legacy memory_get behavior: read a file from workspace.
|
|
*/
|
|
function legacyGet(relativePath) {
|
|
try {
|
|
const normalized = relativePath.replace(/^\/+/, "");
|
|
if (normalized.includes("..")) {
|
|
return { ok: false, error: { code: "PATH_TRAVERSAL", message: "Path traversal denied" } };
|
|
}
|
|
|
|
const fullPath = join(ROOT, normalized);
|
|
|
|
if (!existsSync(fullPath)) {
|
|
return { ok: false, error: { code: "NOT_FOUND", message: "File not found" } };
|
|
}
|
|
|
|
const content = readFileSync(fullPath, "utf8");
|
|
return { ok: true, content };
|
|
} catch (e) {
|
|
return { ok: false, error: { code: "READ_ERROR", message: e.message } };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compare legacy and facade get results.
|
|
*/
|
|
function compareGetResults(testCase, legacy, facade) {
|
|
const checks = {
|
|
okConsistent: legacy.ok === facade.ok,
|
|
hasContentWhenOk: !legacy.ok || (typeof legacy.content === "string" && typeof facade.content === "string"),
|
|
errorWhenNotOk: legacy.ok || (legacy.error !== undefined && facade.error !== undefined),
|
|
noContentWhenNotOk: legacy.ok || (legacy.content === undefined && facade.content === undefined),
|
|
};
|
|
|
|
// For successful reads, content should match
|
|
if (legacy.ok && facade.ok) {
|
|
// Allow small differences (line ending normalization)
|
|
const legacyTrimmed = (legacy.content || "").replace(/\r\n/g, "\n").trim();
|
|
const facadeTrimmed = (facade.content || "").replace(/\r\n/g, "\n").trim();
|
|
checks.contentMatches = legacyTrimmed === facadeTrimmed;
|
|
} else {
|
|
checks.contentMatches = true; // N/A when not ok
|
|
}
|
|
|
|
const allCompatible = Object.values(checks).every(Boolean);
|
|
|
|
return {
|
|
testCase,
|
|
legacyOk: legacy.ok,
|
|
facadeOk: facade.ok,
|
|
allCompatible,
|
|
checks,
|
|
legacyError: legacy.error || null,
|
|
facadeError: facade.error || null,
|
|
};
|
|
}
|
|
|
|
describe("PR-3: Get Shadow Migration Test", () => {
|
|
|
|
// ─── Test 1 ──────────────────────────────────────────
|
|
it("MEMORY.md — both return ok:true with matching content", async () => {
|
|
const legacy = legacyGet("MEMORY.md");
|
|
const facade = await mc.get("MEMORY.md");
|
|
const cmp = compareGetResults("MEMORY.md", legacy, facade);
|
|
|
|
assert.equal(legacy.ok, true, "Legacy get should succeed");
|
|
assert.equal(facade.ok, true, "Facade get should succeed");
|
|
assert.ok(cmp.checks.contentMatches, "Content should match between legacy and facade");
|
|
});
|
|
|
|
// ─── Test 2 ──────────────────────────────────────────
|
|
it("memory/vault.md — both return ok:true", async () => {
|
|
const legacy = legacyGet("memory/vault.md");
|
|
const facade = await mc.get("memory/vault.md");
|
|
const cmp = compareGetResults("memory/vault.md", legacy, facade);
|
|
|
|
assert.equal(legacy.ok, facade.ok, "ok consistency");
|
|
if (legacy.ok) {
|
|
assert.ok(cmp.checks.contentMatches, "Content should match");
|
|
}
|
|
});
|
|
|
|
// ─── Test 3 ──────────────────────────────────────────
|
|
it("memory/daily/ — reads daily files", async () => {
|
|
const files = ["memory/daily/2026-06-04.md", "memory/daily/2026-06-03.md"];
|
|
|
|
for (const file of files) {
|
|
const legacy = legacyGet(file);
|
|
const facade = await mc.get(file);
|
|
const cmp = compareGetResults(file, legacy, facade);
|
|
|
|
// Both should agree on existence
|
|
assert.equal(legacy.ok, facade.ok,
|
|
`${file}: ok mismatch (legacy=${legacy.ok}, facade=${facade.ok})`);
|
|
if (legacy.ok && facade.ok) {
|
|
assert.ok(cmp.checks.contentMatches, `${file}: content mismatch`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// ─── Test 4 ──────────────────────────────────────────
|
|
it("nonexistent file — both return ok:false", async () => {
|
|
const testFile = "definitely-does-not-exist-99999-xyz.md";
|
|
|
|
const legacy = legacyGet(testFile);
|
|
const facade = await mc.get(testFile);
|
|
const cmp = compareGetResults(testFile, legacy, facade);
|
|
|
|
assert.equal(legacy.ok, false, "Legacy should fail for missing file");
|
|
assert.equal(facade.ok, false, "Facade should fail for missing file");
|
|
assert.ok(cmp.allCompatible, `Compatibility: ${JSON.stringify(cmp.checks)}`);
|
|
});
|
|
|
|
// ─── Test 5 ──────────────────────────────────────────
|
|
it("path traversal blocked by both", async () => {
|
|
const traversalPaths = [
|
|
"../etc/passwd",
|
|
"../../../root/.ssh/id_rsa",
|
|
"..%2F..%2Fetc/passwd",
|
|
];
|
|
|
|
for (const path of traversalPaths) {
|
|
const legacy = legacyGet(path);
|
|
const facade = await mc.get(path);
|
|
|
|
assert.equal(legacy.ok, false, `Legacy should block "${path}"`);
|
|
assert.equal(facade.ok, false, `Facade should block "${path}"`);
|
|
}
|
|
});
|
|
|
|
// ─── Test 6 ──────────────────────────────────────────
|
|
it("empty path — both reject", async () => {
|
|
const legacy = legacyGet("");
|
|
const facade = await mc.get("");
|
|
|
|
// Both should fail for empty path
|
|
assert.equal(legacy.ok, false, "Legacy should fail for empty path");
|
|
assert.equal(facade.ok, false, "Facade should fail for empty path");
|
|
});
|
|
|
|
// ─── Test 7 ──────────────────────────────────────────
|
|
it("core files all accessible via both", async () => {
|
|
const coreFiles = ["SOUL.md", "USER.md", "AGENTS.md", "IDENTITY.md", "TOOLS.md", "HEARTBEAT.md"];
|
|
|
|
for (const file of coreFiles) {
|
|
if (!existsSync(join(ROOT, file))) continue;
|
|
|
|
const legacy = legacyGet(file);
|
|
const facade = await mc.get(file);
|
|
const cmp = compareGetResults(file, legacy, facade);
|
|
|
|
assert.equal(legacy.ok, facade.ok, `${file}: ok mismatch`);
|
|
if (legacy.ok && facade.ok) {
|
|
assert.ok(cmp.checks.contentMatches, `${file}: content mismatch`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// ─── Test 8 ──────────────────────────────────────────
|
|
it("does not modify any real files", async () => {
|
|
// Take snapshots of key files
|
|
const keyFiles = ["MEMORY.md", "SOUL.md", "memory/vault.md"].filter(f => existsSync(join(ROOT, f)));
|
|
const snapshots = {};
|
|
for (const f of keyFiles) {
|
|
snapshots[f] = readFileSync(join(ROOT, f), "utf8");
|
|
}
|
|
|
|
// Run lots of get operations
|
|
await mc.get("MEMORY.md");
|
|
await mc.get("memory/vault.md");
|
|
await mc.get("memory/daily/2026-06-04.md");
|
|
await mc.get("nonexistent");
|
|
await mc.get("../etc/passwd"); // should be blocked
|
|
|
|
// Verify no changes
|
|
for (const f of keyFiles) {
|
|
const current = readFileSync(join(ROOT, f), "utf8");
|
|
assert.equal(current, snapshots[f], `${f} must not be modified`);
|
|
}
|
|
});
|
|
|
|
// ─── Test 9 ──────────────────────────────────────────
|
|
it("all compatibility checks pass for standard files", async () => {
|
|
const testCases = ["MEMORY.md", "memory/vault.md", "nonexistent-file-xxx"];
|
|
|
|
for (const tc of testCases) {
|
|
const legacy = legacyGet(tc);
|
|
const facade = await mc.get(tc);
|
|
const cmp = compareGetResults(tc, legacy, facade);
|
|
|
|
assert.ok(cmp.allCompatible,
|
|
`${tc}: compatibility failure — ${Object.entries(cmp.checks).filter(([,v]) => !v).map(([k]) => k).join(", ")}`);
|
|
}
|
|
});
|
|
});
|