874 行
27 KiB
JavaScript
874 行
27 KiB
JavaScript
const crypto = require("node:crypto");
|
|
const { Readable } = require("node:stream");
|
|
|
|
const Fastify = require("fastify");
|
|
const multipart = require("@fastify/multipart");
|
|
|
|
const purchaseRequestRoutes = require("../../../src/routes/purchaseRequests");
|
|
const { AppError } = require("../../../src/utils/AppError");
|
|
|
|
const PURCHASE_REQUEST_ID = "11111111-1111-4111-8111-111111111111";
|
|
const ATTACHMENT_ID = "22222222-2222-4222-8222-222222222222";
|
|
const EMPLOYEE_ID = "33333333-3333-4333-8333-333333333333";
|
|
const OTHER_EMPLOYEE_ID = "44444444-4444-4444-8444-444444444444";
|
|
const NOW = new Date("2026-07-17T08:00:00.000Z");
|
|
const VALID_PDF = Buffer.from(
|
|
"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
|
|
"ascii",
|
|
);
|
|
|
|
class TestStorageNotFoundError extends Error {
|
|
constructor() {
|
|
super("not_found");
|
|
this.name = "StorageNotFoundError";
|
|
}
|
|
}
|
|
|
|
function buildMultipartRequest(parts) {
|
|
const boundary = "----codex-purchase-attachment-boundary";
|
|
const segments = [];
|
|
|
|
for (const part of parts) {
|
|
if (part.type === "field") {
|
|
segments.push(
|
|
Buffer.from(
|
|
`--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"\r\n\r\n${part.value}\r\n`,
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
segments.push(
|
|
Buffer.from(
|
|
`--${boundary}\r\nContent-Disposition: form-data; name="${part.fieldname ?? "file"}"; filename="${part.filename}"\r\nContent-Type: ${part.contentType}\r\n\r\n`,
|
|
),
|
|
Buffer.isBuffer(part.content) ? part.content : Buffer.from(part.content),
|
|
Buffer.from("\r\n"),
|
|
);
|
|
}
|
|
|
|
segments.push(Buffer.from(`--${boundary}--\r\n`));
|
|
return {
|
|
payload: Buffer.concat(segments),
|
|
headers: {
|
|
authorization: "Bearer unit-test",
|
|
"content-type": `multipart/form-data; boundary=${boundary}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildPurchaseRequest(overrides = {}) {
|
|
return {
|
|
id: PURCHASE_REQUEST_ID,
|
|
applicant_id: EMPLOYEE_ID,
|
|
submitter_id: null,
|
|
status: "draft",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildAttachment(overrides = {}) {
|
|
return {
|
|
id: ATTACHMENT_ID,
|
|
purchase_request_id: PURCHASE_REQUEST_ID,
|
|
storage_path: `purchase-requests/${PURCHASE_REQUEST_ID}/${ATTACHMENT_ID}.pdf`,
|
|
original_name: "采购报价.pdf",
|
|
mime_type: "application/pdf",
|
|
file_size: VALID_PDF.length,
|
|
governance_status: "approved",
|
|
sensitivity_level: "internal",
|
|
attachment_type: "quote",
|
|
download_policy: "standard",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function buildApp({
|
|
actor = null,
|
|
purchaseRequest = undefined,
|
|
attachment = undefined,
|
|
maxUploadSizeBytes = 1024 * 1024,
|
|
storageOverrides = {},
|
|
auditCreate = null,
|
|
} = {}) {
|
|
const effectiveActor = actor ?? {
|
|
id: EMPLOYEE_ID,
|
|
roleCodes: ["employee"],
|
|
permissionCodes: ["procurement.create", "procurement.view_own"],
|
|
};
|
|
const state = {
|
|
purchaseRequest: purchaseRequest === undefined ? buildPurchaseRequest() : purchaseRequest,
|
|
attachment: attachment === undefined ? buildAttachment() : attachment,
|
|
createdAttachmentData: null,
|
|
};
|
|
|
|
const uploadAuditCreate = vi.fn().mockResolvedValue({ id: "upload-audit" });
|
|
const downloadAuditCreate = auditCreate ?? vi.fn().mockResolvedValue({ id: "download-audit" });
|
|
const attachmentCreate = vi.fn(async ({ data }) => {
|
|
state.createdAttachmentData = data;
|
|
return {
|
|
id: ATTACHMENT_ID,
|
|
original_name: data.original_name,
|
|
mime_type: data.mime_type,
|
|
file_size: data.file_size,
|
|
file_sha256: data.file_sha256,
|
|
governance_status: data.governance_status,
|
|
sensitivity_level: data.sensitivity_level,
|
|
attachment_type: data.attachment_type,
|
|
reviewed_by: null,
|
|
reviewed_at: null,
|
|
review_note: null,
|
|
archived_by: null,
|
|
archived_at: null,
|
|
archive_note: null,
|
|
download_policy: data.download_policy,
|
|
uploaded_by: data.uploaded_by,
|
|
created_at: NOW,
|
|
uploader: {
|
|
id: effectiveActor.id,
|
|
employee_no: "E001",
|
|
name: "测试员工",
|
|
},
|
|
};
|
|
});
|
|
const attachmentDelete = vi.fn(async ({ where }) => {
|
|
if (state.attachment?.id === where.id) {
|
|
state.attachment = null;
|
|
}
|
|
return { id: where.id };
|
|
});
|
|
|
|
const tx = {
|
|
purchaseAttachment: { create: attachmentCreate, delete: attachmentDelete },
|
|
auditLog: { create: uploadAuditCreate },
|
|
};
|
|
const prisma = {
|
|
purchaseRequest: {
|
|
findUnique: vi.fn(async () => state.purchaseRequest),
|
|
},
|
|
purchaseAttachment: {
|
|
findFirst: vi.fn(async ({ where }) => {
|
|
if (!state.attachment) return null;
|
|
return where.id === state.attachment.id &&
|
|
where.purchase_request_id === state.attachment.purchase_request_id
|
|
? state.attachment
|
|
: null;
|
|
}),
|
|
},
|
|
auditLog: { create: downloadAuditCreate },
|
|
$transaction: vi.fn(async (callback) => callback(tx)),
|
|
};
|
|
|
|
const storage = {
|
|
StorageNotFoundError: TestStorageNotFoundError,
|
|
writeBuffer: vi.fn(async ({ key }) => key),
|
|
openReadStream: vi.fn(async () => ({ stream: Readable.from([VALID_PDF]) })),
|
|
...storageOverrides,
|
|
};
|
|
|
|
const app = Fastify();
|
|
app.decorate("prisma", prisma);
|
|
app.decorate("config", {
|
|
appTimezone: "Asia/Shanghai",
|
|
maxUploadSizeBytes,
|
|
});
|
|
app.decorate("hasPermission", (employee, permissionCode) => {
|
|
const permissionCodes = employee?.permissionCodes ?? [];
|
|
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
|
|
});
|
|
app.decorate("authenticate", async (request) => {
|
|
if (typeof request.headers.authorization !== "string") {
|
|
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
|
}
|
|
request.employee = effectiveActor;
|
|
});
|
|
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
|
await app.authenticate(request);
|
|
if (!app.hasPermission(request.employee, permissionCode)) {
|
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
|
}
|
|
});
|
|
app.setErrorHandler((error, request, reply) => {
|
|
if (error instanceof AppError) {
|
|
return reply.status(error.statusCode).send({
|
|
requestId: request.id,
|
|
error: {
|
|
code: error.code,
|
|
message: error.message,
|
|
details: error.details ?? {},
|
|
},
|
|
});
|
|
}
|
|
return reply.status(500).send({
|
|
requestId: request.id,
|
|
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
|
|
});
|
|
});
|
|
|
|
await app.register(multipart, {
|
|
attachFieldsToBody: false,
|
|
limits: { files: 1, fileSize: maxUploadSizeBytes },
|
|
});
|
|
await app.register(purchaseRequestRoutes, {
|
|
prefix: "/api",
|
|
attachmentStorage: storage,
|
|
});
|
|
|
|
return {
|
|
app,
|
|
attachmentCreate,
|
|
attachmentDelete,
|
|
downloadAuditCreate,
|
|
prisma,
|
|
state,
|
|
storage,
|
|
uploadAuditCreate,
|
|
};
|
|
}
|
|
|
|
describe("purchase attachment upload", () => {
|
|
it("consumes the file immediately, accepts trailing governance fields, and persists canonical bytes", async () => {
|
|
const { app, attachmentCreate, state, storage } = await buildApp();
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
|
...buildMultipartRequest([
|
|
{
|
|
type: "file",
|
|
filename: "采购报价.v2.pdf",
|
|
contentType: "application/octet-stream",
|
|
content: VALID_PDF,
|
|
},
|
|
{ type: "field", name: "sensitivityLevel", value: "confidential" },
|
|
]),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(storage.writeBuffer).toHaveBeenCalledTimes(1);
|
|
const writeInput = storage.writeBuffer.mock.calls[0][0];
|
|
expect(writeInput.key).toMatch(
|
|
new RegExp(`^purchase-requests/${PURCHASE_REQUEST_ID}/[0-9a-f-]{36}\\.pdf$`),
|
|
);
|
|
expect(writeInput.key).not.toContain("采购报价");
|
|
expect(writeInput.contentType).toBe("application/pdf");
|
|
expect(writeInput.buffer.equals(VALID_PDF)).toBe(true);
|
|
expect(state.createdAttachmentData).toEqual(
|
|
expect.objectContaining({
|
|
original_name: "采购报价.v2.pdf",
|
|
mime_type: "application/pdf",
|
|
file_size: VALID_PDF.length,
|
|
sensitivity_level: "confidential",
|
|
}),
|
|
);
|
|
expect(state.createdAttachmentData.file_sha256).toBe(
|
|
crypto.createHash("sha256").update(writeInput.buffer).digest("hex"),
|
|
);
|
|
expect(attachmentCreate).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it("accepts governance fields before the file without hanging", async () => {
|
|
const { app, storage } = await buildApp();
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
|
...buildMultipartRequest([
|
|
{ type: "field", name: "attachmentType", value: "acceptance" },
|
|
{
|
|
type: "file",
|
|
filename: "验收.pdf",
|
|
contentType: "application/pdf",
|
|
content: VALID_PDF,
|
|
},
|
|
]),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(storage.writeBuffer).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
["non multipart", null, { file: "multipart_required" }],
|
|
[
|
|
"wrong file field",
|
|
buildMultipartRequest([
|
|
{
|
|
type: "file",
|
|
fieldname: "payload",
|
|
filename: "报价.pdf",
|
|
contentType: "application/pdf",
|
|
content: VALID_PDF,
|
|
},
|
|
]),
|
|
{ file: "invalid_field" },
|
|
],
|
|
[
|
|
"path-shaped filename",
|
|
buildMultipartRequest([
|
|
{
|
|
type: "file",
|
|
filename: "../报价.pdf",
|
|
contentType: "application/pdf",
|
|
content: VALID_PDF,
|
|
},
|
|
]),
|
|
{ file: "dangerous_filename" },
|
|
],
|
|
[
|
|
"active content",
|
|
buildMultipartRequest([
|
|
{
|
|
type: "file",
|
|
filename: "报价.pdf",
|
|
contentType: "application/pdf",
|
|
content: "<!doctype html><script>void 0</script>",
|
|
},
|
|
]),
|
|
null,
|
|
],
|
|
[
|
|
"empty content",
|
|
buildMultipartRequest([
|
|
{
|
|
type: "file",
|
|
filename: "空附件.pdf",
|
|
contentType: "application/pdf",
|
|
content: Buffer.alloc(0),
|
|
},
|
|
]),
|
|
{ file: "empty_file" },
|
|
],
|
|
])("rejects %s before storage or database writes", async (_label, multipartRequest, expectedDetails) => {
|
|
const { app, attachmentCreate, storage, uploadAuditCreate } = await buildApp();
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
|
headers: multipartRequest?.headers ?? { authorization: "Bearer unit-test" },
|
|
payload: multipartRequest?.payload,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error.code).toBe("VALID_001");
|
|
if (expectedDetails) {
|
|
expect(response.json().error.details).toEqual(expectedDetails);
|
|
}
|
|
expect(storage.writeBuffer).not.toHaveBeenCalled();
|
|
expect(attachmentCreate).not.toHaveBeenCalled();
|
|
expect(uploadAuditCreate).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it("maps the production single-file plugin limit to a stable validation error", async () => {
|
|
const { app, attachmentCreate, storage } = await buildApp();
|
|
const request = buildMultipartRequest([
|
|
{
|
|
type: "file",
|
|
filename: "报价一.pdf",
|
|
contentType: "application/pdf",
|
|
content: VALID_PDF,
|
|
},
|
|
{
|
|
type: "file",
|
|
filename: "报价二.pdf",
|
|
contentType: "application/pdf",
|
|
content: VALID_PDF,
|
|
},
|
|
]);
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
|
...request,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error.details).toEqual({ file: "only_single_file_supported" });
|
|
expect(storage.writeBuffer).not.toHaveBeenCalled();
|
|
expect(attachmentCreate).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it("maps the multipart byte limit to a stable validation error", async () => {
|
|
const { app, attachmentCreate, storage } = await buildApp({ maxUploadSizeBytes: 32 });
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
|
...buildMultipartRequest([
|
|
{
|
|
type: "file",
|
|
filename: "超限.pdf",
|
|
contentType: "application/pdf",
|
|
content: VALID_PDF,
|
|
},
|
|
]),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error.details).toEqual({ file: "file_too_large" });
|
|
expect(storage.writeBuffer).not.toHaveBeenCalled();
|
|
expect(attachmentCreate).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
["missing permission", { permissionCodes: ["procurement.view_own"] }, buildPurchaseRequest(), 403],
|
|
[
|
|
"another applicant",
|
|
{ permissionCodes: ["procurement.create", "procurement.view_own"] },
|
|
buildPurchaseRequest({ applicant_id: OTHER_EMPLOYEE_ID }),
|
|
403,
|
|
],
|
|
[
|
|
"non-draft request",
|
|
{ permissionCodes: ["procurement.create", "procurement.view_own"] },
|
|
buildPurchaseRequest({ status: "reviewing" }),
|
|
409,
|
|
],
|
|
])("does not write upload data for %s", async (_label, actorInput, purchaseRequest, statusCode) => {
|
|
const { app, attachmentCreate, storage } = await buildApp({
|
|
actor: {
|
|
id: EMPLOYEE_ID,
|
|
roleCodes: ["employee"],
|
|
...actorInput,
|
|
},
|
|
purchaseRequest,
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
|
...buildMultipartRequest([
|
|
{
|
|
type: "file",
|
|
filename: "报价.pdf",
|
|
contentType: "application/pdf",
|
|
content: VALID_PDF,
|
|
},
|
|
]),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(statusCode);
|
|
expect(storage.writeBuffer).not.toHaveBeenCalled();
|
|
expect(attachmentCreate).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("purchase attachment delete", () => {
|
|
it("requires a reason and records it with the attachment audit", async () => {
|
|
const { app, attachmentDelete, state, uploadAuditCreate } = await buildApp();
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "DELETE",
|
|
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}`,
|
|
headers: { authorization: "Bearer unit-test", "content-type": "application/json" },
|
|
payload: { reason: "重复上传" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toEqual({ outcome: "deleted", attachmentId: ATTACHMENT_ID });
|
|
expect(attachmentDelete).toHaveBeenCalledWith({ where: { id: ATTACHMENT_ID } });
|
|
expect(uploadAuditCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
action: "PURCHASE_ATTACHMENT_DELETED",
|
|
target_id: ATTACHMENT_ID,
|
|
extra: expect.objectContaining({ purchaseRequestId: PURCHASE_REQUEST_ID, reason: "重复上传" }),
|
|
}),
|
|
}),
|
|
);
|
|
expect(state.attachment).toBeNull();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it("rejects a missing reason before attachment lookup or deletion", async () => {
|
|
const { app, attachmentDelete, prisma, uploadAuditCreate } = await buildApp();
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "DELETE",
|
|
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}`,
|
|
headers: { authorization: "Bearer unit-test", "content-type": "application/json" },
|
|
payload: {},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error.code).toBe("VALID_001");
|
|
expect(prisma.purchaseRequest.findUnique).not.toHaveBeenCalled();
|
|
expect(attachmentDelete).not.toHaveBeenCalled();
|
|
expect(uploadAuditCreate).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("purchase attachment authenticated download", () => {
|
|
const downloadUrl = `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}/download`;
|
|
|
|
it("requires current authentication before reading storage", async () => {
|
|
const { app, storage } = await buildApp();
|
|
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: downloadUrl, payload: {} });
|
|
expect(response.statusCode).toBe(401);
|
|
expect(storage.openReadStream).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"missing purchase read permission",
|
|
{
|
|
actor: {
|
|
id: EMPLOYEE_ID,
|
|
roleCodes: ["employee"],
|
|
permissionCodes: ["procurement.create"],
|
|
},
|
|
},
|
|
403,
|
|
],
|
|
[
|
|
"another employee's purchase request",
|
|
{
|
|
purchaseRequest: buildPurchaseRequest({
|
|
applicant_id: OTHER_EMPLOYEE_ID,
|
|
submitter_id: OTHER_EMPLOYEE_ID,
|
|
}),
|
|
},
|
|
403,
|
|
],
|
|
["an attachment outside the purchase request", { attachment: null }, 404],
|
|
])("rejects %s before opening storage", async (_label, options, statusCode) => {
|
|
const { app, storage } = await buildApp(options);
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: downloadUrl,
|
|
headers: { authorization: "Bearer unit-test" },
|
|
payload: {},
|
|
});
|
|
expect(response.statusCode).toBe(statusCode);
|
|
expect(storage.openReadStream).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it("requires a JSON-body reason for sensitive attachments", async () => {
|
|
const { app, storage } = await buildApp({
|
|
attachment: buildAttachment({
|
|
sensitivity_level: "confidential",
|
|
download_policy: "reason_required",
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: downloadUrl,
|
|
headers: { authorization: "Bearer unit-test" },
|
|
payload: {},
|
|
});
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error.details).toEqual({ reason: "required" });
|
|
expect(storage.openReadStream).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it("truncates an overlong download reason before writing audit metadata", async () => {
|
|
const { app, downloadAuditCreate } = await buildApp({
|
|
attachment: buildAttachment({
|
|
sensitivity_level: "confidential",
|
|
download_policy: "reason_required",
|
|
}),
|
|
});
|
|
const reason = "复".repeat(201);
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: downloadUrl,
|
|
headers: {
|
|
authorization: "Bearer unit-test",
|
|
"content-type": "application/json",
|
|
},
|
|
payload: JSON.stringify({ reason }),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(downloadAuditCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
action: "PURCHASE_ATTACHMENT_DOWNLOADED",
|
|
extra: expect.objectContaining({ reason: "复".repeat(200) }),
|
|
}),
|
|
}),
|
|
);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it("blocks quarantined attachments before storage and records a fixed reason", async () => {
|
|
const { app, downloadAuditCreate, storage } = await buildApp({
|
|
attachment: buildAttachment({ governance_status: "quarantined" }),
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: downloadUrl,
|
|
headers: { authorization: "Bearer unit-test" },
|
|
payload: {},
|
|
});
|
|
expect(response.statusCode).toBe(409);
|
|
expect(storage.openReadStream).not.toHaveBeenCalled();
|
|
expect(downloadAuditCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
|
|
extra: expect.objectContaining({ reasonCode: "quarantined" }),
|
|
}),
|
|
}),
|
|
);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it("blocks a recorded object above the hard limit before opening storage", async () => {
|
|
const { app, storage } = await buildApp({
|
|
maxUploadSizeBytes: 64,
|
|
attachment: buildAttachment({ file_size: 65 }),
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: downloadUrl,
|
|
headers: { authorization: "Bearer unit-test" },
|
|
payload: {},
|
|
});
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json().error.details.reason).toBe("size_limit");
|
|
expect(storage.openReadStream).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it("returns a safe not-found response and blocked audit when the stored object is missing", async () => {
|
|
const { app, downloadAuditCreate } = await buildApp({
|
|
storageOverrides: {
|
|
openReadStream: vi.fn(async () => {
|
|
throw new TestStorageNotFoundError();
|
|
}),
|
|
},
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: downloadUrl,
|
|
headers: { authorization: "Bearer unit-test" },
|
|
payload: {},
|
|
});
|
|
expect(response.statusCode).toBe(404);
|
|
expect(response.json().error.message).toBe("附件不存在");
|
|
expect(downloadAuditCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
|
|
extra: expect.objectContaining({ reasonCode: "missing_object" }),
|
|
}),
|
|
}),
|
|
);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it("rejects an actual object larger than the configured limit and destroys its stream", async () => {
|
|
const oversizedStream = {
|
|
destroyed: false,
|
|
destroy: vi.fn(function destroy() {
|
|
this.destroyed = true;
|
|
}),
|
|
async *[Symbol.asyncIterator]() {
|
|
yield Buffer.alloc(65, 0x41);
|
|
},
|
|
};
|
|
const { app, storage } = await buildApp({
|
|
maxUploadSizeBytes: 64,
|
|
attachment: buildAttachment({ file_size: 1 }),
|
|
storageOverrides: {
|
|
openReadStream: vi.fn(async () => ({ stream: oversizedStream })),
|
|
},
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: downloadUrl,
|
|
headers: { authorization: "Bearer unit-test" },
|
|
payload: {},
|
|
});
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json().error.details.reason).toBe("size_limit");
|
|
expect(oversizedStream.destroy).toHaveBeenCalledTimes(1);
|
|
expect(storage.openReadStream).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it("blocks historical active content after byte validation and sends no attachment headers", async () => {
|
|
const { app, downloadAuditCreate } = await buildApp({
|
|
storageOverrides: {
|
|
openReadStream: vi.fn(async () => ({
|
|
stream: Readable.from([Buffer.from("<!doctype html><script>void 0</script>")]),
|
|
})),
|
|
},
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: downloadUrl,
|
|
headers: { authorization: "Bearer unit-test" },
|
|
payload: {},
|
|
});
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.headers["content-disposition"]).toBeUndefined();
|
|
expect(downloadAuditCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
|
|
}),
|
|
}),
|
|
);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it("returns a validated historical attachment with the complete forced-download header contract", async () => {
|
|
const { app, downloadAuditCreate } = await buildApp({
|
|
attachment: buildAttachment({
|
|
mime_type: "application/octet-stream",
|
|
sensitivity_level: "confidential",
|
|
download_policy: "reason_required",
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: downloadUrl,
|
|
headers: {
|
|
authorization: "Bearer unit-test",
|
|
"content-type": "application/json",
|
|
},
|
|
payload: JSON.stringify({ reason: "审批复核" }),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.rawPayload.equals(VALID_PDF)).toBe(true);
|
|
expect(response.headers["content-type"]).toContain("application/octet-stream");
|
|
expect(response.headers["content-disposition"]).toContain("attachment;");
|
|
expect(response.headers["content-disposition"]).toContain("filename*=");
|
|
expect(response.headers["content-disposition"]).not.toContain("inline");
|
|
expect(response.headers["x-content-type-options"]).toBe("nosniff");
|
|
expect(response.headers["cache-control"]).toBe("private, no-store, max-age=0");
|
|
expect(response.headers.pragma).toBe("no-cache");
|
|
expect(response.headers["referrer-policy"]).toBe("no-referrer");
|
|
expect(response.headers["content-security-policy"]).toContain("sandbox");
|
|
expect(response.headers["cross-origin-resource-policy"]).toBe("same-origin");
|
|
expect(downloadAuditCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
action: "PURCHASE_ATTACHMENT_DOWNLOADED",
|
|
extra: expect.objectContaining({ reason: "审批复核" }),
|
|
}),
|
|
}),
|
|
);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it("fails closed before setting attachment headers when success auditing fails", async () => {
|
|
const auditCreate = vi.fn().mockRejectedValue(new Error("database unavailable"));
|
|
const { app } = await buildApp({ auditCreate });
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: downloadUrl,
|
|
headers: { authorization: "Bearer unit-test" },
|
|
payload: {},
|
|
});
|
|
expect(response.statusCode).toBe(500);
|
|
expect(response.headers["content-disposition"]).toBeUndefined();
|
|
expect(response.json().error.details).toEqual({ attachment: "audit_failed" });
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it("retires both legacy signed and query-token routes", async () => {
|
|
const { app, storage } = await buildApp();
|
|
|
|
try {
|
|
const signedResponse = await app.inject({
|
|
method: "POST",
|
|
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}/signed-url`,
|
|
headers: { authorization: "Bearer unit-test" },
|
|
payload: {},
|
|
});
|
|
const fileResponse = await app.inject({
|
|
method: "GET",
|
|
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}/file?token=legacy`,
|
|
});
|
|
|
|
expect(signedResponse.statusCode).toBe(404);
|
|
expect(fileResponse.statusCode).toBe(404);
|
|
expect(storage.openReadStream).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
});
|