Initial commit
This commit is contained in:
35
tests/drop-contract.test.mjs
Normal file
35
tests/drop-contract.test.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { loadModule } from "./test-helpers.mjs";
|
||||
|
||||
const contract = await loadModule("shared/utils/drop-contract.ts");
|
||||
const types = await loadModule("shared/types/drop.ts");
|
||||
const valid = {
|
||||
version: 2,
|
||||
chunkSize: types.CHUNK_SIZE,
|
||||
chunkCount: 1,
|
||||
noncePrefix: "AQIDBAUGBwg",
|
||||
expiresIn: "24h",
|
||||
maxReads: 1,
|
||||
};
|
||||
|
||||
test("reservation validation accepts protocol geometry and rejects unsafe policies", () => {
|
||||
assert.deepEqual(contract.validateReservation(valid), valid);
|
||||
for (const bad of [
|
||||
{ ...valid, chunkSize: 1 },
|
||||
{ ...valid, chunkCount: 0 },
|
||||
{ ...valid, chunkCount: 1.5 },
|
||||
{ ...valid, expiresIn: "forever" },
|
||||
{ ...valid, maxReads: 2 },
|
||||
{ ...valid, unknown: true },
|
||||
{ ...valid, noncePrefix: "AA" },
|
||||
])
|
||||
assert.throws(() => contract.validateReservation(bad));
|
||||
});
|
||||
|
||||
test("manifest body must contain plaintext as well as the GCM tag", () => {
|
||||
assert.throws(() => contract.validateManifestBodyLength(types.GCM_TAG_BYTES));
|
||||
assert.doesNotThrow(() =>
|
||||
contract.validateManifestBodyLength(types.GCM_TAG_BYTES + 1),
|
||||
);
|
||||
});
|
||||
126
tests/drop-crypto.test.mjs
Normal file
126
tests/drop-crypto.test.mjs
Normal file
@@ -0,0 +1,126 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { loadModule } from "./test-helpers.mjs";
|
||||
|
||||
const crypto = await loadModule("app/utils/crypto.ts");
|
||||
const id = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
||||
const key = crypto.generateKey();
|
||||
const wrongKey = crypto.generateKey();
|
||||
const nonce = crypto.generateNoncePrefix();
|
||||
|
||||
test("AES-GCM manifest and chunk round trips preserve plaintext", async () => {
|
||||
const plain = new TextEncoder().encode("hello chunk");
|
||||
const cipher = await crypto.encryptChunk(
|
||||
plain,
|
||||
key,
|
||||
id,
|
||||
nonce,
|
||||
0,
|
||||
8 * 1024 * 1024,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[
|
||||
...(await crypto.decryptChunk(
|
||||
cipher,
|
||||
key,
|
||||
id,
|
||||
nonce,
|
||||
0,
|
||||
plain.byteLength,
|
||||
8 * 1024 * 1024,
|
||||
1,
|
||||
)),
|
||||
],
|
||||
[...plain],
|
||||
);
|
||||
const manifest = {
|
||||
version: 2,
|
||||
name: "hello.txt",
|
||||
type: "text/plain",
|
||||
size: plain.byteLength,
|
||||
lastModified: 1,
|
||||
chunkSize: 8 * 1024 * 1024,
|
||||
chunkCount: 1,
|
||||
};
|
||||
const encrypted = await crypto.encryptManifest(manifest, key, id, nonce);
|
||||
assert.deepEqual(
|
||||
await crypto.decryptManifest(
|
||||
encrypted,
|
||||
key,
|
||||
id,
|
||||
nonce,
|
||||
manifest.chunkSize,
|
||||
manifest.chunkCount,
|
||||
),
|
||||
manifest,
|
||||
);
|
||||
});
|
||||
|
||||
test("empty chunks are authenticated and round-trip", async () => {
|
||||
const cipher = await crypto.encryptChunk(
|
||||
new Uint8Array(),
|
||||
key,
|
||||
id,
|
||||
nonce,
|
||||
0,
|
||||
8 * 1024 * 1024,
|
||||
1,
|
||||
);
|
||||
assert.equal(cipher.byteLength, 16);
|
||||
assert.equal(
|
||||
(
|
||||
await crypto.decryptChunk(
|
||||
cipher,
|
||||
key,
|
||||
id,
|
||||
nonce,
|
||||
0,
|
||||
0,
|
||||
8 * 1024 * 1024,
|
||||
1,
|
||||
)
|
||||
).byteLength,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("tampering and wrong key or AAD fail integrity", async () => {
|
||||
const cipher = await crypto.encryptChunk(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
key,
|
||||
id,
|
||||
nonce,
|
||||
0,
|
||||
8 * 1024 * 1024,
|
||||
1,
|
||||
);
|
||||
const tampered = new Uint8Array(cipher);
|
||||
tampered[0] ^= 1;
|
||||
await assert.rejects(
|
||||
crypto.decryptChunk(tampered, key, id, nonce, 0, 3, 8 * 1024 * 1024, 1),
|
||||
/integrity/,
|
||||
);
|
||||
await assert.rejects(
|
||||
crypto.decryptChunk(cipher, wrongKey, id, nonce, 0, 3, 8 * 1024 * 1024, 1),
|
||||
/integrity/,
|
||||
);
|
||||
await assert.rejects(
|
||||
crypto.decryptChunk(cipher, key, id, nonce, 1, 3, 8 * 1024 * 1024, 1),
|
||||
/integrity/,
|
||||
);
|
||||
});
|
||||
|
||||
test("tag-only manifests are rejected before decryption", async () => {
|
||||
await assert.rejects(
|
||||
crypto.decryptManifest(
|
||||
new Uint8Array(16),
|
||||
key,
|
||||
id,
|
||||
nonce,
|
||||
8 * 1024 * 1024,
|
||||
1,
|
||||
),
|
||||
/empty/,
|
||||
);
|
||||
});
|
||||
153
tests/drop-store.test.mjs
Normal file
153
tests/drop-store.test.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm, stat, utimes, access } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { loadModule } from "./test-helpers.mjs";
|
||||
|
||||
const { DropStore, DropStoreError, pathsFor } = await loadModule(
|
||||
"server/utils/drop-store.ts",
|
||||
);
|
||||
const { CHUNK_SIZE } = await loadModule("shared/types/drop.ts");
|
||||
const noncePrefix = "AQIDBAUGBwg";
|
||||
let roots = [];
|
||||
async function fresh(clock = { now: () => Date.now() }) {
|
||||
const root = await mkdtemp(join(tmpdir(), "quickdrop-test-"));
|
||||
roots.push(root);
|
||||
return { store: new DropStore({ root, clock }), root };
|
||||
}
|
||||
async function reserve(store, maxReads = 1, expiresIn = "24h") {
|
||||
return store.reserve({
|
||||
version: 2,
|
||||
chunkSize: CHUNK_SIZE,
|
||||
chunkCount: 1,
|
||||
noncePrefix,
|
||||
expiresIn,
|
||||
maxReads,
|
||||
});
|
||||
}
|
||||
async function ready(store, maxReads = 1, expiresIn = "24h") {
|
||||
const result = await reserve(store, maxReads, expiresIn);
|
||||
await store.putChunk(result.id, 0, new Uint8Array(16));
|
||||
await store.finalize(result.id, new Uint8Array(17));
|
||||
return result;
|
||||
}
|
||||
async function exists(path) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
test.after(async () => {
|
||||
await Promise.all(
|
||||
roots.map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
test("finalize starts expiry and rejects missing chunks", async () => {
|
||||
let now = 1000;
|
||||
const { store } = await fresh({ now: () => now });
|
||||
const result = await reserve(store, 1, "1h");
|
||||
await assert.rejects(
|
||||
store.finalize(result.id, new Uint8Array(17)),
|
||||
(error) => error instanceof DropStoreError && error.code === "incomplete",
|
||||
);
|
||||
await store.putChunk(result.id, 0, new Uint8Array(16));
|
||||
const metadata = await store.finalize(result.id, new Uint8Array(17));
|
||||
assert.equal(metadata.expiresAt, now + 3600000);
|
||||
now += 3599999;
|
||||
assert.equal(
|
||||
(await store.descriptor(result.id, result.access)).id,
|
||||
result.id,
|
||||
);
|
||||
now++;
|
||||
await assert.rejects(
|
||||
store.descriptor(result.id, result.access),
|
||||
(error) => error instanceof DropStoreError && error.code === "expired",
|
||||
);
|
||||
});
|
||||
|
||||
test("chunk retries are idempotent and conflicting contents are rejected", async () => {
|
||||
const { store } = await fresh();
|
||||
const result = await reserve(store);
|
||||
const body = new Uint8Array(16).fill(7);
|
||||
assert.equal(await store.putChunk(result.id, 0, body), "created");
|
||||
assert.equal(await store.putChunk(result.id, 0, body), "same");
|
||||
await assert.rejects(
|
||||
store.putChunk(result.id, 0, new Uint8Array(16).fill(8)),
|
||||
(error) => error instanceof DropStoreError && error.code === "conflict",
|
||||
);
|
||||
});
|
||||
|
||||
test("maxReads claims are serialized, valid lease reuse does not increment, and completion cleans up", async () => {
|
||||
const { store, root } = await fresh();
|
||||
const result = await ready(store, 1);
|
||||
const claims = await Promise.allSettled([
|
||||
store.claim(result.id, result.access),
|
||||
store.claim(result.id, result.access),
|
||||
]);
|
||||
assert.equal(
|
||||
claims.filter((value) => value.status === "fulfilled").length,
|
||||
1,
|
||||
);
|
||||
assert.equal(claims.filter((value) => value.status === "rejected").length, 1);
|
||||
const first = claims.find((value) => value.status === "fulfilled").value;
|
||||
const metadataBefore = await store.readMetadata(result.id);
|
||||
const reused = await store.claim(result.id, result.access, first.token);
|
||||
assert.equal(reused.reused, true);
|
||||
assert.equal(
|
||||
(await store.readMetadata(result.id)).readsClaimed,
|
||||
metadataBefore.readsClaimed,
|
||||
);
|
||||
const chunk = await store.readChunk(result.id, 0, first.token);
|
||||
assert.equal(chunk.size, 16);
|
||||
await chunk.handle.close();
|
||||
await store.complete(result.id, first.token);
|
||||
assert.equal(await exists(pathsFor(result.id, root).directory), false);
|
||||
});
|
||||
|
||||
test("unlimited drops allow multiple leases and metadata remains parseable", async () => {
|
||||
const { store, root } = await fresh();
|
||||
const result = await ready(store, null);
|
||||
const one = await store.claim(result.id, result.access);
|
||||
const two = await store.claim(result.id, result.access);
|
||||
assert.notEqual(one.token, two.token);
|
||||
assert.equal((await store.readMetadata(result.id)).readsClaimed, 2);
|
||||
JSON.parse(await readFile(pathsFor(result.id, root).metadata, "utf8"));
|
||||
await store.complete(result.id, one.token);
|
||||
assert.equal(await exists(pathsFor(result.id, root).directory), true);
|
||||
});
|
||||
|
||||
test("idle lease cleanup and stale temporary files are safe", async () => {
|
||||
let now = 100000;
|
||||
const { store, root } = await fresh({ now: () => now });
|
||||
const result = await ready(store, 1);
|
||||
await store.claim(result.id, result.access);
|
||||
const paths = pathsFor(result.id, root);
|
||||
const stale = join(paths.directory, ".chunk-stale");
|
||||
await (await import("node:fs/promises")).writeFile(stale, "stale");
|
||||
const old = new Date(now - 2 * 3600000);
|
||||
await utimes(stale, old, old);
|
||||
now += 16 * 60 * 1000;
|
||||
await store.cleanup();
|
||||
assert.equal(await exists(stale), false);
|
||||
assert.equal(await exists(paths.directory), false);
|
||||
});
|
||||
|
||||
test("metadata-less valid directories are removed only after grace period", async () => {
|
||||
let now = 10_000_000;
|
||||
const { store, root } = await fresh({ now: () => now });
|
||||
const result = await reserve(store);
|
||||
const paths = pathsFor(result.id, root);
|
||||
await rm(paths.metadata);
|
||||
const recent = new Date(now);
|
||||
await utimes(paths.directory, recent, recent);
|
||||
await store.cleanup();
|
||||
assert.equal(await exists(paths.directory), true);
|
||||
const old = new Date(now - 2 * 3600000);
|
||||
await utimes(paths.directory, old, old);
|
||||
await store.cleanup();
|
||||
assert.equal(await exists(paths.directory), false);
|
||||
});
|
||||
207
tests/http-smoke.test.mjs
Normal file
207
tests/http-smoke.test.mjs
Normal file
@@ -0,0 +1,207 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { access, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
try {
|
||||
await access(".output/server/index.mjs");
|
||||
} catch {
|
||||
await promisify(execFile)("npm", ["run", "build"], { stdio: "inherit" });
|
||||
}
|
||||
const port = 19000 + Math.floor(Math.random() * 500);
|
||||
const storage = await mkdtemp(join(tmpdir(), "quickdrop-http-"));
|
||||
const child = spawn(process.execPath, [".output/server/index.mjs"], {
|
||||
env: {
|
||||
...process.env,
|
||||
NITRO_HOST: "127.0.0.1",
|
||||
NITRO_PORT: String(port),
|
||||
AUTH_PASSCODE: "smoke-passcode",
|
||||
AUTH_SECRET: "smoke-secret-for-tests",
|
||||
QUICKDROP_STORAGE_DIR: storage,
|
||||
},
|
||||
stdio: "ignore",
|
||||
});
|
||||
async function request(url, init) {
|
||||
return fetch(`http://127.0.0.1:${port}${url}`, init);
|
||||
}
|
||||
async function waitForServer() {
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
try {
|
||||
const response = await request("/api/auth/session");
|
||||
if (response.status === 200) return;
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error("built server did not start");
|
||||
}
|
||||
|
||||
test("built server enforces auth and serves capability-only lease/chunk APIs", async () => {
|
||||
try {
|
||||
await waitForServer();
|
||||
const reservationBody = JSON.stringify({
|
||||
version: 2,
|
||||
chunkSize: 8 * 1024 * 1024,
|
||||
chunkCount: 1,
|
||||
noncePrefix: "AQIDBAUGBwg",
|
||||
expiresIn: "24h",
|
||||
maxReads: 1,
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await request("/api/drops", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: reservationBody,
|
||||
})
|
||||
).status,
|
||||
401,
|
||||
);
|
||||
const login = await request("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
},
|
||||
body: JSON.stringify({ passcode: "smoke-passcode" }),
|
||||
});
|
||||
assert.equal(login.status, 204);
|
||||
const cookie = login.headers.get("set-cookie").split(";", 1)[0];
|
||||
const reserve = await request("/api/drops", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie,
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
},
|
||||
body: reservationBody,
|
||||
});
|
||||
assert.equal(reserve.status, 201);
|
||||
const created = await reserve.json();
|
||||
const chunk = new Uint8Array(16);
|
||||
const put = await request(`/api/drops/${created.id}/chunks/0`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
cookie,
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
},
|
||||
body: chunk,
|
||||
});
|
||||
assert.equal(put.status, 201);
|
||||
const finalize = await request(`/api/drops/${created.id}/finalize`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
cookie,
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
},
|
||||
body: new Uint8Array(17),
|
||||
});
|
||||
assert.equal(finalize.status, 200);
|
||||
const descriptor = await request(`/api/drops/${created.id}`, {
|
||||
headers: { "x-quickdrop-access": created.access },
|
||||
});
|
||||
assert.equal(descriptor.status, 200);
|
||||
const first = await request(`/api/drops/${created.id}/leases`, {
|
||||
method: "POST",
|
||||
headers: { "x-quickdrop-access": created.access },
|
||||
});
|
||||
assert.equal(first.status, 201);
|
||||
const lease = await first.json();
|
||||
const second = await request(`/api/drops/${created.id}/leases`, {
|
||||
method: "POST",
|
||||
headers: { "x-quickdrop-access": created.access },
|
||||
});
|
||||
assert.equal(second.status, 410);
|
||||
const reused = await request(`/api/drops/${created.id}/leases`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-quickdrop-access": created.access,
|
||||
authorization: `Bearer ${lease.token}`,
|
||||
},
|
||||
});
|
||||
assert.equal(reused.status, 200);
|
||||
assert.equal((await reused.json()).token, lease.token);
|
||||
const chunkGet = await request(`/api/drops/${created.id}/chunks/0`, {
|
||||
headers: { authorization: `Bearer ${lease.token}` },
|
||||
});
|
||||
assert.equal(chunkGet.status, 200);
|
||||
assert.equal((await chunkGet.arrayBuffer()).byteLength, 16);
|
||||
const complete = await request(`/api/drops/${created.id}/complete`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${lease.token}` },
|
||||
});
|
||||
assert.equal(complete.status, 204);
|
||||
|
||||
// Completion retries must also be idempotent when the drop remains (unlimited reads).
|
||||
const unlimitedBody = JSON.stringify({
|
||||
...JSON.parse(reservationBody),
|
||||
maxReads: null,
|
||||
});
|
||||
const unlimitedReserve = await request("/api/drops", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie,
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
},
|
||||
body: unlimitedBody,
|
||||
});
|
||||
assert.equal(unlimitedReserve.status, 201);
|
||||
const unlimited = await unlimitedReserve.json();
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/api/drops/${unlimited.id}/chunks/0`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
cookie,
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
},
|
||||
body: chunk,
|
||||
})
|
||||
).status,
|
||||
201,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/api/drops/${unlimited.id}/finalize`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
cookie,
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
},
|
||||
body: new Uint8Array(17),
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
const unlimitedClaim = await request(`/api/drops/${unlimited.id}/leases`, {
|
||||
method: "POST",
|
||||
headers: { "x-quickdrop-access": unlimited.access },
|
||||
});
|
||||
assert.equal(unlimitedClaim.status, 201);
|
||||
const unlimitedLease = await unlimitedClaim.json();
|
||||
const completionInit = {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${unlimitedLease.token}` },
|
||||
};
|
||||
assert.equal(
|
||||
(await request(`/api/drops/${unlimited.id}/complete`, completionInit))
|
||||
.status,
|
||||
204,
|
||||
);
|
||||
assert.equal(
|
||||
(await request(`/api/drops/${unlimited.id}/complete`, completionInit))
|
||||
.status,
|
||||
204,
|
||||
);
|
||||
} finally {
|
||||
child.kill("SIGTERM");
|
||||
await rm(storage, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
16
tests/test-helpers.mjs
Normal file
16
tests/test-helpers.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createServer } from "vite";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export const root = resolve(new URL("..", import.meta.url).pathname);
|
||||
export async function loadModule(path) {
|
||||
const vite = await createServer({
|
||||
root,
|
||||
logLevel: "error",
|
||||
resolve: { alias: { "~": resolve(root, "app") } },
|
||||
});
|
||||
try {
|
||||
return await vite.ssrLoadModule(`/${path}`);
|
||||
} finally {
|
||||
await vite.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user