357 lines
11 KiB
JavaScript
357 lines
11 KiB
JavaScript
const fs = require("fs");
|
|
const path = require("path");
|
|
const vm = require("vm");
|
|
const assert = require("assert");
|
|
|
|
const rootDir = path.resolve(__dirname, "..", "..");
|
|
|
|
const loadAzureStorageHelperModule = (injected = {}) => {
|
|
const filePath = path.join(rootDir, "actions", "azurestorage.js");
|
|
const source = fs.readFileSync(filePath, "utf8");
|
|
|
|
const start = source.indexOf("const buildDownloadBlobQueryPath =");
|
|
const end = source.indexOf("export const createContainerSas =");
|
|
|
|
if (start < 0 || end < 0 || end <= start) {
|
|
throw new Error("Unable to isolate azurestorage helper function block");
|
|
}
|
|
|
|
let helperSource = source.slice(start, end);
|
|
helperSource +=
|
|
"\nmodule.exports = { buildDownloadBlobQueryPath, buildDeleteBlobQueryPath, buildGetBlobListQueryPath, buildHashMetadataPaths };\n";
|
|
|
|
const context = {
|
|
module: { exports: {} },
|
|
exports: {},
|
|
require,
|
|
encodeURIComponent,
|
|
hashAPIPath: (route) => `HASH(${route})`,
|
|
...injected
|
|
};
|
|
|
|
vm.runInNewContext(helperSource, context, { filename: filePath });
|
|
return context.module.exports;
|
|
};
|
|
|
|
const loadRecursiveDeletionHelpers = ({
|
|
blobNames = [],
|
|
deleteBlob = () => Promise.resolve()
|
|
} = {}) => {
|
|
const filePath = path.join(rootDir, "actions", "azurestorage.js");
|
|
const source = fs.readFileSync(filePath, "utf8");
|
|
const start = source.indexOf("const isSafeDeletionPathSegment =");
|
|
const end = source.indexOf("export const uploadFile =");
|
|
|
|
if (start < 0 || end < 0 || end <= start) {
|
|
throw new Error("Unable to isolate recursive deletion helper block");
|
|
}
|
|
|
|
const listedPrefixes = [];
|
|
const deletedBlobNames = [];
|
|
const containerClient = {
|
|
async *listBlobsFlat({ prefix }) {
|
|
listedPrefixes.push(prefix);
|
|
for (const name of blobNames.filter((blobName) =>
|
|
blobName.startsWith(prefix)
|
|
)) {
|
|
yield { name };
|
|
}
|
|
},
|
|
deleteBlob(name) {
|
|
deletedBlobNames.push(name);
|
|
return deleteBlob(name);
|
|
},
|
|
getBlockBlobClient() {
|
|
return { exists: async () => true };
|
|
}
|
|
};
|
|
|
|
let helperSource = source
|
|
.slice(start, end)
|
|
.replace(/export const /g, "const ");
|
|
helperSource +=
|
|
"\nmodule.exports = { deleteBlobCase, deleteBlobRep };\n";
|
|
|
|
const context = {
|
|
module: { exports: {} },
|
|
exports: {},
|
|
console: { log: () => {} },
|
|
DefaultAzureCredential: class {},
|
|
createContainerSas: async () => "sas-token",
|
|
STORAGE_PATH: "https://storage.example.test",
|
|
ContainerClient: class {
|
|
constructor() {
|
|
return containerClient;
|
|
}
|
|
}
|
|
};
|
|
|
|
vm.runInNewContext(helperSource, context, { filename: filePath });
|
|
|
|
return {
|
|
...context.module.exports,
|
|
listedPrefixes,
|
|
deletedBlobNames
|
|
};
|
|
};
|
|
|
|
const tests = [];
|
|
const test = (name, fn) => tests.push({ name, fn });
|
|
|
|
const createDeferred = () => {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise;
|
|
reject = rejectPromise;
|
|
});
|
|
|
|
return { promise, resolve, reject };
|
|
};
|
|
|
|
const flushMicrotasks = async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
};
|
|
|
|
test("azurestorage helper builds download path with default encoding", () => {
|
|
const mod = loadAzureStorageHelperModule();
|
|
|
|
const pathResult = mod.buildDownloadBlobQueryPath({
|
|
containerName: "alpha",
|
|
casefolderID: "A/B",
|
|
blobname: "my doc.pdf"
|
|
});
|
|
|
|
assert.strictEqual(
|
|
pathResult,
|
|
"/api/file/downloadblob?container=alpha&casefolderID=A%2FB&blobname=my%20doc.pdf"
|
|
);
|
|
});
|
|
|
|
test("azurestorage helper preserves raw values when encoding disabled", () => {
|
|
const mod = loadAzureStorageHelperModule();
|
|
|
|
const deletePath = mod.buildDeleteBlobQueryPath({
|
|
containerName: "alpha",
|
|
casefolderID: "A/B",
|
|
blobname: "my doc.pdf",
|
|
encodeCasefolderID: false,
|
|
encodeBlobname: false
|
|
});
|
|
|
|
assert.strictEqual(
|
|
deletePath,
|
|
"/api/file/deleteblob?container=alpha&casefolderID=A/B&blobname=my doc.pdf"
|
|
);
|
|
});
|
|
|
|
test("azurestorage helper builds getbloblist path without field mutation", () => {
|
|
const mod = loadAzureStorageHelperModule();
|
|
|
|
const listPath = mod.buildGetBlobListQueryPath({
|
|
containerName: "alpha",
|
|
casefolderID: "A/B"
|
|
});
|
|
|
|
assert.strictEqual(
|
|
listPath,
|
|
"/api/file/getbloblist?container=alpha&casefolderID=A/B"
|
|
);
|
|
});
|
|
|
|
test("azurestorage helper builds hash metadata map with stable keys", () => {
|
|
const mod = loadAzureStorageHelperModule({
|
|
hashAPIPath: (route) => `signed:${route}`
|
|
});
|
|
|
|
const metadata = mod.buildHashMetadataPaths({
|
|
containerName: "alpha",
|
|
casefolderID: "A/B",
|
|
blobname: "my doc.pdf"
|
|
});
|
|
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(metadata)), {
|
|
hashedfilepath:
|
|
"signed:/api/file/downloadblob?container=alpha&casefolderID=A%2FB&blobname=my%20doc.pdf",
|
|
hasheddeletepath:
|
|
"signed:/api/file/deleteblob?container=alpha&casefolderID=A%2FB&blobname=my%20doc.pdf",
|
|
hashgetblobs:
|
|
"signed:/api/file/getbloblist?container=alpha&casefolderID=A/B"
|
|
});
|
|
});
|
|
|
|
test("case deletion uses a complete folder prefix and excludes lexical siblings", async () => {
|
|
const loaded = loadRecursiveDeletionHelpers({
|
|
blobNames: [
|
|
"TMP-ABC/TMP-ABC_appeal.json",
|
|
"TMP-ABC/files/evidence.pdf",
|
|
"TMP-ABC1/TMP-ABC1_appeal.json",
|
|
"TMP-ABCD/files/sibling.pdf"
|
|
]
|
|
});
|
|
|
|
await loaded.deleteBlobCase("container-1", "TMP-ABC");
|
|
|
|
assert.deepStrictEqual(loaded.listedPrefixes, ["TMP-ABC/"]);
|
|
assert.deepStrictEqual(loaded.deletedBlobNames, [
|
|
"TMP-ABC/TMP-ABC_appeal.json",
|
|
"TMP-ABC/files/evidence.pdf"
|
|
]);
|
|
});
|
|
|
|
test("representation deletion uses a complete folder prefix and preserves parent and siblings", async () => {
|
|
const loaded = loadRecursiveDeletionHelpers({
|
|
blobNames: [
|
|
"CASE-1/CASE-1_case.json",
|
|
"CASE-1/REP-1/REP-1_rep.json",
|
|
"CASE-1/REP-1/files/evidence.pdf",
|
|
"CASE-1/REP-10/REP-10_rep.json",
|
|
"CASE-1/REP-2/REP-2_rep.json"
|
|
]
|
|
});
|
|
|
|
await loaded.deleteBlobRep("container-1", "CASE-1/REP-1");
|
|
|
|
assert.deepStrictEqual(loaded.listedPrefixes, ["CASE-1/REP-1/"]);
|
|
assert.deepStrictEqual(loaded.deletedBlobNames, [
|
|
"CASE-1/REP-1/REP-1_rep.json",
|
|
"CASE-1/REP-1/files/evidence.pdf"
|
|
]);
|
|
});
|
|
|
|
test("recursive deletion helpers reject empty and unsafe resource paths before listing", async () => {
|
|
for (const [helperName, unsafePath] of [
|
|
["deleteBlobCase", ""],
|
|
["deleteBlobCase", "CASE-1/REP-1"],
|
|
["deleteBlobRep", "CASE-1"],
|
|
["deleteBlobRep", "CASE-1/REP-1/FILES"],
|
|
["deleteBlobRep", "CASE-1/../REP-1"]
|
|
]) {
|
|
const loaded = loadRecursiveDeletionHelpers();
|
|
|
|
await assert.rejects(
|
|
() => loaded[helperName]("container-1", unsafePath),
|
|
/Invalid deletion resource path/
|
|
);
|
|
assert.deepStrictEqual(loaded.listedPrefixes, []);
|
|
}
|
|
});
|
|
|
|
for (const deletionCase of [
|
|
{
|
|
helperName: "deleteBlobCase",
|
|
resourcePath: "TMP-ABC",
|
|
blobName: "TMP-ABC/files/evidence.pdf"
|
|
},
|
|
{
|
|
helperName: "deleteBlobRep",
|
|
resourcePath: "CASE-1/REP-1",
|
|
blobName: "CASE-1/REP-1/files/evidence.pdf"
|
|
}
|
|
]) {
|
|
test(`${deletionCase.helperName} remains pending until Azure deletion completes`, async () => {
|
|
const deletion = createDeferred();
|
|
const deletionStarted = createDeferred();
|
|
const loaded = loadRecursiveDeletionHelpers({
|
|
blobNames: [deletionCase.blobName],
|
|
deleteBlob: () => {
|
|
deletionStarted.resolve();
|
|
return deletion.promise;
|
|
}
|
|
});
|
|
let settled = false;
|
|
|
|
const helperPromise = loaded[
|
|
deletionCase.helperName
|
|
]("container-1", deletionCase.resourcePath).then((result) => {
|
|
settled = true;
|
|
return result;
|
|
});
|
|
|
|
await deletionStarted.promise;
|
|
await flushMicrotasks();
|
|
assert.strictEqual(settled, false);
|
|
|
|
deletion.resolve();
|
|
assert.deepStrictEqual(
|
|
JSON.parse(JSON.stringify(await helperPromise)),
|
|
{ deleted: deletionCase.resourcePath }
|
|
);
|
|
});
|
|
|
|
test(`${deletionCase.helperName} propagates Azure deletion rejection`, async () => {
|
|
const deletionError = new Error("controlled Azure deletion failure");
|
|
const rejectedDeletion = Promise.reject(deletionError);
|
|
rejectedDeletion.catch(() => {});
|
|
const loaded = loadRecursiveDeletionHelpers({
|
|
blobNames: [deletionCase.blobName],
|
|
deleteBlob: () => rejectedDeletion
|
|
});
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
loaded[deletionCase.helperName](
|
|
"container-1",
|
|
deletionCase.resourcePath
|
|
),
|
|
/controlled Azure deletion failure/
|
|
);
|
|
});
|
|
}
|
|
|
|
test("recursive deletion does not complete until all selected blob deletions complete", async () => {
|
|
const firstDeletion = createDeferred();
|
|
const secondDeletion = createDeferred();
|
|
const deletionPromises = new Map([
|
|
["TMP-ABC/files/first.pdf", firstDeletion.promise],
|
|
["TMP-ABC/files/second.pdf", secondDeletion.promise]
|
|
]);
|
|
const loaded = loadRecursiveDeletionHelpers({
|
|
blobNames: [...deletionPromises.keys()],
|
|
deleteBlob: (name) => deletionPromises.get(name)
|
|
});
|
|
let settled = false;
|
|
|
|
const helperPromise = loaded
|
|
.deleteBlobCase("container-1", "TMP-ABC")
|
|
.then((result) => {
|
|
settled = true;
|
|
return result;
|
|
});
|
|
|
|
await flushMicrotasks();
|
|
assert.strictEqual(settled, false);
|
|
|
|
firstDeletion.resolve();
|
|
await flushMicrotasks();
|
|
assert.strictEqual(settled, false);
|
|
|
|
secondDeletion.resolve();
|
|
assert.deepStrictEqual(
|
|
JSON.parse(JSON.stringify(await helperPromise)),
|
|
{ deleted: "TMP-ABC" }
|
|
);
|
|
});
|
|
|
|
const run = async () => {
|
|
let passed = 0;
|
|
for (const currentTest of tests) {
|
|
await currentTest.fn();
|
|
passed += 1;
|
|
}
|
|
|
|
console.log(
|
|
`Phase 22 azurestorage-helper tests passed (${passed}/${tests.length}).`
|
|
);
|
|
};
|
|
|
|
module.exports = run;
|
|
|
|
if (require.main === module) {
|
|
run().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
}
|