@goplusvn/core 0.1.57 → 0.1.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLATFORM.md +127 -0
- package/package.json +21 -2
- package/src/branch-scope/__tests__/branch-scope.test.ts +288 -0
- package/src/branch-scope/context.ts +66 -0
- package/src/branch-scope/guard.ts +100 -0
- package/src/branch-scope/index.ts +42 -0
- package/src/branch-scope/scope.ts +149 -0
- package/src/branch-scope/types.ts +57 -0
- package/src/storage/__tests__/keys.test.ts +62 -0
- package/src/storage/__tests__/no-aws-in-barrel.test.ts +45 -0
- package/src/storage/__tests__/storage-service.test.ts +312 -0
- package/src/storage/file-proxy.ts +147 -0
- package/src/storage/index.ts +51 -0
- package/src/storage/keys.ts +105 -0
- package/src/storage/s3/index.ts +160 -0
- package/src/storage/storage-service.ts +280 -0
- package/src/storage/types.ts +111 -0
- package/src/storage/upload-handler.ts +100 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NO_BRANCH_ACCESS,
|
|
3
|
+
type BranchScope,
|
|
4
|
+
type BranchScopeConfig,
|
|
5
|
+
} from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* LỚP 1 — lọc tường minh. Page/route/service gọi `getBranchScope(session)` rồi
|
|
9
|
+
* nhét `scopedBranchWhere(scope)` vào `where`. Lớp 2 (branch-guard extension)
|
|
10
|
+
* là lưới an toàn cho chỗ quên, KHÔNG phải thay thế lớp này: guard chỉ chạm các
|
|
11
|
+
* model được khai và chỉ trong request đi qua apiHandler.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
15
|
+
let configured: BranchScopeConfig<any> | null = null;
|
|
16
|
+
|
|
17
|
+
export function configureBranchScope<TSession>(
|
|
18
|
+
config: BranchScopeConfig<TSession>,
|
|
19
|
+
): void {
|
|
20
|
+
configured = config;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function requireConfigured() {
|
|
24
|
+
if (!configured) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"[branch-scope] chưa configureBranchScope(...) — gọi một lần ở composition root (src/lib/branch-scope.ts) rồi import phạm vi qua đúng file đó.",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return configured;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function canViewAllBranches<TSession>(session: TSession): boolean {
|
|
33
|
+
return requireConfigured().canViewAll(session);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Phạm vi chi nhánh của user. Chưa được gán chi nhánh nào → sentinel, tức thấy
|
|
38
|
+
* 0 dòng — KHÔNG phải "thấy tất": mặc định an toàn là không thấy gì.
|
|
39
|
+
*/
|
|
40
|
+
export async function getBranchScope<TSession>(
|
|
41
|
+
session: TSession,
|
|
42
|
+
): Promise<BranchScope> {
|
|
43
|
+
const config = requireConfigured();
|
|
44
|
+
if (config.canViewAll(session)) return { canViewAll: true };
|
|
45
|
+
|
|
46
|
+
const branchIds = await readAllowedBranchIds(config, session);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
canViewAll: false,
|
|
50
|
+
allowedBranchIds: branchIds.length > 0 ? branchIds : [NO_BRANCH_ACCESS],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readAllowedBranchIds<TSession>(
|
|
55
|
+
config: BranchScopeConfig<TSession>,
|
|
56
|
+
session: TSession,
|
|
57
|
+
): Promise<string[]> {
|
|
58
|
+
if (config.getAllowedBranchIds) {
|
|
59
|
+
const ids = await config.getAllowedBranchIds(session);
|
|
60
|
+
return (ids ?? []).filter((id): id is string => Boolean(id));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const userId = config.getUserId(session);
|
|
64
|
+
if (!userId) return [];
|
|
65
|
+
|
|
66
|
+
if (!config.db) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
"[branch-scope] configureBranchScope cần `db` (hoặc `getAllowedBranchIds`) để biết user thuộc chi nhánh nào.",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const rows = await config.db.userBranch.findMany({
|
|
72
|
+
where: { userId },
|
|
73
|
+
select: { branchId: true },
|
|
74
|
+
});
|
|
75
|
+
return rows
|
|
76
|
+
.map((row) => (row as { branchId?: string | null }).branchId)
|
|
77
|
+
.filter((id): id is string => Boolean(id));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Guard trang chi tiết: user có được xem bản ghi thuộc chi nhánh này không?
|
|
82
|
+
* Bản ghi không gắn chi nhánh (null — dữ liệu cũ hoặc dùng chung) thì ai vào
|
|
83
|
+
* được trang đều xem được.
|
|
84
|
+
*/
|
|
85
|
+
export function canAccessBranch(
|
|
86
|
+
scope: BranchScope,
|
|
87
|
+
branchId: string | null | undefined,
|
|
88
|
+
): boolean {
|
|
89
|
+
if (scope.canViewAll) return true;
|
|
90
|
+
if (!branchId) return true;
|
|
91
|
+
return scope.allowedBranchIds!.includes(branchId);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Fragment `where` cho model có cột branchId: thuộc CN được phép HOẶC chưa gắn
|
|
96
|
+
* CN. `{}` khi xem được tất — nhét thẳng vào where là xong, không cần rẽ nhánh.
|
|
97
|
+
*
|
|
98
|
+
* `field` cho model scope qua quan hệ, ví dụ kho: `scopedBranchWhere(scope,
|
|
99
|
+
* "warehouse")` → `{ OR: [{ warehouse: { branchId: { in } } }, { warehouse: {
|
|
100
|
+
* branchId: null } }] }`.
|
|
101
|
+
*/
|
|
102
|
+
export function scopedBranchWhere(
|
|
103
|
+
scope: BranchScope,
|
|
104
|
+
relation?: string,
|
|
105
|
+
): Record<string, unknown> {
|
|
106
|
+
if (scope.canViewAll) return {};
|
|
107
|
+
const ids = scope.allowedBranchIds!;
|
|
108
|
+
const inClause = { branchId: { in: ids } };
|
|
109
|
+
const nullClause = { branchId: null };
|
|
110
|
+
return relation
|
|
111
|
+
? { OR: [{ [relation]: inClause }, { [relation]: nullClause }] }
|
|
112
|
+
: { OR: [inClause, nullClause] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Kẹp bộ lọc chi nhánh client gửi lên vào trong phạm vi được phép. `undefined`
|
|
117
|
+
* = client không lọc gì (phạm vi đã do allowedBranchIds lo). Chọn toàn CN ngoài
|
|
118
|
+
* phạm vi → sentinel: thấy 0 dòng, chứ KHÔNG rơi về "không lọc".
|
|
119
|
+
*/
|
|
120
|
+
export function clampBranchFilter(
|
|
121
|
+
requested: string[] | string | null | undefined,
|
|
122
|
+
scope: BranchScope,
|
|
123
|
+
): string[] | undefined {
|
|
124
|
+
const ids = normalizeIds(requested);
|
|
125
|
+
if (ids.length === 0) return undefined;
|
|
126
|
+
if (scope.canViewAll) return ids;
|
|
127
|
+
const valid = ids.filter((id) => scope.allowedBranchIds!.includes(id));
|
|
128
|
+
return valid.length > 0 ? valid : [NO_BRANCH_ACCESS];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Kẹp danh sách id client chọn vào danh sách được phép — dùng cho thực thể đi
|
|
133
|
+
* theo chi nhánh (kho, quầy, điểm bán). Ngoài danh sách → sentinel.
|
|
134
|
+
*/
|
|
135
|
+
export function clampIdFilter(
|
|
136
|
+
requested: string[] | string | null | undefined,
|
|
137
|
+
allowedIds: string[],
|
|
138
|
+
): string[] {
|
|
139
|
+
const valid = normalizeIds(requested).filter((id) => allowedIds.includes(id));
|
|
140
|
+
return valid.length > 0 ? valid : [NO_BRANCH_ACCESS];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function normalizeIds(
|
|
144
|
+
requested: string[] | string | null | undefined,
|
|
145
|
+
): string[] {
|
|
146
|
+
return (Array.isArray(requested) ? requested : [requested]).filter(
|
|
147
|
+
(id): id is string => Boolean(id && id.trim()),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phạm vi dữ liệu theo chi nhánh — hợp đồng chung.
|
|
3
|
+
*
|
|
4
|
+
* Bài toán: app nhiều chi nhánh thì "danh sách phiếu thu" của kế toán CN A phải
|
|
5
|
+
* KHÁC của CN B, còn giám đốc thì thấy tất. Lọc bằng tay ở từng route là cách
|
|
6
|
+
* chắc chắn sẽ rò: chỉ cần một route mới quên `where branchId`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sentinel cho "user chưa được gán chi nhánh nào" và "client lọc toàn chi nhánh
|
|
11
|
+
* ngoài phạm vi". Là một chuỗi KHÔNG BAO GIỜ khớp branchId thật, nên
|
|
12
|
+
* `branchId: { in: [NO_BRANCH_ACCESS] }` trả 0 dòng. Không dùng mảng rỗng: Prisma
|
|
13
|
+
* hiểu `in: []` là "không có gì khớp" ở vài chỗ nhưng `undefined`/bỏ mệnh đề ở
|
|
14
|
+
* chỗ khác — nhầm một lần là lộ toàn bộ dữ liệu.
|
|
15
|
+
*/
|
|
16
|
+
export const NO_BRANCH_ACCESS = "__NO_ACCESS__";
|
|
17
|
+
|
|
18
|
+
export interface BranchScope {
|
|
19
|
+
canViewAll: boolean;
|
|
20
|
+
/** undefined khi canViewAll — ngược lại LUÔN ≥1 phần tử (sentinel nếu user chưa gán CN). */
|
|
21
|
+
allowedBranchIds?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Chỉ cần đúng phần delegate `user_branches` mà scope dùng tới. */
|
|
25
|
+
export interface BranchScopeDb {
|
|
26
|
+
userBranch: {
|
|
27
|
+
findMany: (args: {
|
|
28
|
+
where: { userId: string };
|
|
29
|
+
select: { branchId: true };
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
}) => Promise<any[]>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface BranchScopeConfig<TSession = unknown> {
|
|
36
|
+
/**
|
|
37
|
+
* Nguồn mặc định của phạm vi: bảng `user_branches`. Bỏ trống được NẾU đã khai
|
|
38
|
+
* `getAllowedBranchIds`.
|
|
39
|
+
*/
|
|
40
|
+
db?: BranchScopeDb;
|
|
41
|
+
/**
|
|
42
|
+
* Session đã mang sẵn danh sách chi nhánh (app nhồi vào lúc đăng nhập) thì
|
|
43
|
+
* khai ở đây — khỏi phải truy vấn user_branches mỗi lần cần phạm vi. Trả mảng
|
|
44
|
+
* rỗng/null = user chưa được gán chi nhánh nào (→ sentinel, thấy 0 dòng).
|
|
45
|
+
*/
|
|
46
|
+
getAllowedBranchIds?: (
|
|
47
|
+
session: TSession,
|
|
48
|
+
) => string[] | null | undefined | Promise<string[] | null | undefined>;
|
|
49
|
+
/** Lấy id user từ session của app. */
|
|
50
|
+
getUserId: (session: TSession) => string | null | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* "Xem mọi chi nhánh" — app tự quyết: vai trò quản trị, hoặc quyền
|
|
53
|
+
* `view-all-branches` trên resource nào đó. Trả true thì scope bỏ qua luôn
|
|
54
|
+
* truy vấn user_branches.
|
|
55
|
+
*/
|
|
56
|
+
canViewAll: (session: TSession) => boolean;
|
|
57
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
InvalidStorageKeyError,
|
|
5
|
+
assertSafeKey,
|
|
6
|
+
contentTypeFor,
|
|
7
|
+
fileExtension,
|
|
8
|
+
isSafeKey,
|
|
9
|
+
safeFileName,
|
|
10
|
+
} from "../keys";
|
|
11
|
+
|
|
12
|
+
describe("assertSafeKey", () => {
|
|
13
|
+
// Key đến từ URL `/api/files/<key>` — người dùng điều khiển hoàn toàn.
|
|
14
|
+
it.each([
|
|
15
|
+
"../../etc/passwd",
|
|
16
|
+
"uploads/../../secret.pdf",
|
|
17
|
+
"/etc/passwd",
|
|
18
|
+
"uploads\\win.pdf",
|
|
19
|
+
"uploads/a\0b.pdf",
|
|
20
|
+
"",
|
|
21
|
+
])("từ chối %j", (key) => {
|
|
22
|
+
expect(() => assertSafeKey(key)).toThrow(InvalidStorageKeyError);
|
|
23
|
+
expect(isSafeKey(key)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("cho qua key thường", () => {
|
|
27
|
+
expect(assertSafeKey("uploads/20260801/hoa_don_123.pdf")).toBe(
|
|
28
|
+
"uploads/20260801/hoa_don_123.pdf",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("safeFileName", () => {
|
|
34
|
+
it("giữ dấu tiếng Việt", () => {
|
|
35
|
+
expect(safeFileName("Hợp đồng số 12.pdf")).toBe("Hợp_đồng_số_12.pdf");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("bỏ đường dẫn đính kèm trong tên", () => {
|
|
39
|
+
expect(safeFileName("../../evil.png")).toBe("evil.png");
|
|
40
|
+
expect(safeFileName("C:\\Users\\a\\b.png")).toBe("b.png");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("hạ chữ thường phần mở rộng", () => {
|
|
44
|
+
expect(safeFileName("BAO_CAO.XLSX")).toBe("BAO_CAO.xlsx");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("tên rỗng vẫn ra tên dùng được", () => {
|
|
48
|
+
expect(safeFileName("")).toBe("file");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("fileExtension / contentTypeFor", () => {
|
|
53
|
+
it("lấy đuôi ở phần tên cuối, không phải ở thư mục", () => {
|
|
54
|
+
expect(fileExtension("a.pdf/b")).toBe("");
|
|
55
|
+
expect(fileExtension("uploads/2026/bao-cao.XLSX")).toBe("xlsx");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("đuôi lạ trả undefined để nơi gọi tự quyết", () => {
|
|
59
|
+
expect(contentTypeFor("a.exe")).toBeUndefined();
|
|
60
|
+
expect(contentTypeFor("a.pdf")).toBe("application/pdf");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Ratchet: `@goerp/core/storage` phải chạy được khi app KHÔNG cài aws-sdk.
|
|
8
|
+
*
|
|
9
|
+
* Bẫy đã trả giá một lần ở proxy-gate: bundler phân giải TĨNH cả
|
|
10
|
+
* `await import()`, nên chỉ cần một dòng import gói không cài trong nhánh chết
|
|
11
|
+
* là app gãy build bằng "Module not found". Driver S3 phải ở `storage/s3/`
|
|
12
|
+
* (subpath riêng), tuyệt đối không rò vào barrel.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Đọc theo cwd: môi trường test là jsdom nên import.meta.url là URL http,
|
|
16
|
+
// fileURLToPath sẽ ném "URL must be of scheme file".
|
|
17
|
+
const STORAGE_DIR = resolve(process.cwd(), "src/storage");
|
|
18
|
+
|
|
19
|
+
function filesInBarrel(dir: string, acc: string[] = []): string[] {
|
|
20
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
21
|
+
// `s3/` là nơi ĐƯỢC PHÉP dùng aws-sdk; __tests__ không vào bundle app.
|
|
22
|
+
if (entry.isDirectory()) {
|
|
23
|
+
if (entry.name === "s3" || entry.name === "__tests__") continue;
|
|
24
|
+
filesInBarrel(join(dir, entry.name), acc);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
|
28
|
+
acc.push(join(dir, entry.name));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return acc;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("barrel storage không kéo theo aws-sdk", () => {
|
|
35
|
+
it("không file nào ngoài storage/s3 nhắc tới @aws-sdk/@smithy", () => {
|
|
36
|
+
const offenders = filesInBarrel(STORAGE_DIR).filter((file) => {
|
|
37
|
+
const source = readFileSync(file, "utf8")
|
|
38
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
39
|
+
.replace(/^\s*\/\/.*$/gm, "");
|
|
40
|
+
return /["'](@aws-sdk|@smithy)\//.test(source);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(offenders).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { createFileProxyHandler } from "../file-proxy";
|
|
8
|
+
import {
|
|
9
|
+
clearStorageCache,
|
|
10
|
+
configureStorage,
|
|
11
|
+
deleteFile,
|
|
12
|
+
getFile,
|
|
13
|
+
getPresignedUrl,
|
|
14
|
+
getPublicUrl,
|
|
15
|
+
isRemoteStorageEnabled,
|
|
16
|
+
listObjects,
|
|
17
|
+
putFile,
|
|
18
|
+
} from "../storage-service";
|
|
19
|
+
import { createUploadHandler } from "../upload-handler";
|
|
20
|
+
import { StorageNotFoundError, type StorageDriver } from "../types";
|
|
21
|
+
|
|
22
|
+
/** DB giả: trả đúng các dòng system_configs mà engine hỏi. */
|
|
23
|
+
function fakeDb(rows: Record<string, string> = {}) {
|
|
24
|
+
return {
|
|
25
|
+
systemConfig: {
|
|
26
|
+
findMany: vi.fn(async () =>
|
|
27
|
+
Object.entries(rows).map(([key, value]) => ({ key, value })),
|
|
28
|
+
),
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let dir: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
dir = await mkdtemp(join(tmpdir(), "goerp-storage-"));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await rm(dir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("chế độ local (mặc định)", () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
configureStorage({ db: fakeDb(), localDir: dir });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("không có driver thì không hỏi DB làm gì", async () => {
|
|
49
|
+
const db = fakeDb({ STORAGE_TYPE: "s3" });
|
|
50
|
+
configureStorage({ db, localDir: dir });
|
|
51
|
+
|
|
52
|
+
expect(await isRemoteStorageEnabled()).toBe(false);
|
|
53
|
+
expect(db.systemConfig.findMany).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("ghi rồi đọc lại", async () => {
|
|
57
|
+
await putFile("uploads/2026/a.txt", Buffer.from("xin chào"), "text/plain");
|
|
58
|
+
expect((await getFile("uploads/2026/a.txt")).toString()).toBe("xin chào");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("thiếu key → StorageNotFoundError (nơi gọi dịch thành 404)", async () => {
|
|
62
|
+
await expect(getFile("uploads/khong-co.pdf")).rejects.toBeInstanceOf(
|
|
63
|
+
StorageNotFoundError,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("xoá là idempotent", async () => {
|
|
68
|
+
await putFile("a.txt", Buffer.from("x"), "text/plain");
|
|
69
|
+
await deleteFile("a.txt");
|
|
70
|
+
await expect(deleteFile("a.txt")).resolves.toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("liệt kê theo tiền tố", async () => {
|
|
74
|
+
await putFile("uploads/a.txt", Buffer.from("1"), "text/plain");
|
|
75
|
+
await putFile("uploads/sub/b.txt", Buffer.from("22"), "text/plain");
|
|
76
|
+
await putFile("khac/c.txt", Buffer.from("333"), "text/plain");
|
|
77
|
+
|
|
78
|
+
const keys = (await listObjects("uploads/")).map((o) => o.key).sort();
|
|
79
|
+
expect(keys).toEqual(["uploads/a.txt", "uploads/sub/b.txt"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("không ký được URL → null để nơi gọi rơi về proxy", async () => {
|
|
83
|
+
expect(await getPresignedUrl("a.txt")).toBeNull();
|
|
84
|
+
expect(await getPublicUrl("uploads/a.txt")).toBe("/api/files/uploads/a.txt");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("chặn traversal trước khi chạm đĩa", async () => {
|
|
88
|
+
await expect(getFile("../../../etc/passwd")).rejects.toThrow(
|
|
89
|
+
/Key không hợp lệ/,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("chế độ từ xa", () => {
|
|
95
|
+
const driver = (): StorageDriver => ({
|
|
96
|
+
put: vi.fn(async () => undefined),
|
|
97
|
+
get: vi.fn(async () => Buffer.from("từ s3")),
|
|
98
|
+
remove: vi.fn(async () => undefined),
|
|
99
|
+
presign: vi.fn(async () => "https://cdn.example/signed"),
|
|
100
|
+
list: vi.fn(async () => []),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("STORAGE_TYPE=s3 + có driver → dùng driver", async () => {
|
|
104
|
+
const d = driver();
|
|
105
|
+
configureStorage({
|
|
106
|
+
db: fakeDb({
|
|
107
|
+
STORAGE_TYPE: '"s3"', // giá trị trong DB là JSON string
|
|
108
|
+
S3_ENDPOINT: '"http://minio:9000"',
|
|
109
|
+
S3_BUCKET: '"erp"',
|
|
110
|
+
S3_ACCESS_KEY: '"ak"',
|
|
111
|
+
S3_SECRET_KEY: '"sk"',
|
|
112
|
+
}),
|
|
113
|
+
driver: d,
|
|
114
|
+
localDir: dir,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(await isRemoteStorageEnabled()).toBe(true);
|
|
118
|
+
expect((await getFile("a.txt")).toString()).toBe("từ s3");
|
|
119
|
+
expect(await getPresignedUrl("a.txt")).toBe("https://cdn.example/signed");
|
|
120
|
+
expect(await getPublicUrl("a.txt")).toBe("http://minio:9000/erp/a.txt");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("thiếu trường bắt buộc → cảnh báo rồi rơi về local, không sập request", async () => {
|
|
124
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
125
|
+
configureStorage({
|
|
126
|
+
db: fakeDb({ STORAGE_TYPE: "s3", S3_ENDPOINT: "http://minio:9000" }),
|
|
127
|
+
driver: driver(),
|
|
128
|
+
localDir: dir,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(await isRemoteStorageEnabled()).toBe(false);
|
|
132
|
+
await putFile("a.txt", Buffer.from("local"), "text/plain");
|
|
133
|
+
expect((await getFile("a.txt")).toString()).toBe("local");
|
|
134
|
+
expect(warn).toHaveBeenCalled();
|
|
135
|
+
warn.mockRestore();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("requireRemote: mất cấu hình thì THROW chứ không âm thầm ghi đĩa container", async () => {
|
|
139
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
140
|
+
configureStorage({
|
|
141
|
+
db: fakeDb({ STORAGE_TYPE: "local" }),
|
|
142
|
+
driver: driver(),
|
|
143
|
+
localDir: dir,
|
|
144
|
+
requireRemote: true,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await expect(putFile("a.txt", Buffer.from("x"), "text/plain")).rejects.toThrow(
|
|
148
|
+
/requireRemote/,
|
|
149
|
+
);
|
|
150
|
+
await expect(getFile("a.txt")).rejects.toThrow(/requireRemote/);
|
|
151
|
+
await expect(listObjects("")).rejects.toThrow(/requireRemote/);
|
|
152
|
+
warn.mockRestore();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("clearCache() buộc đọc lại cấu hình", async () => {
|
|
156
|
+
const db = fakeDb({
|
|
157
|
+
STORAGE_TYPE: "s3",
|
|
158
|
+
S3_ENDPOINT: "http://minio:9000",
|
|
159
|
+
S3_BUCKET: "erp",
|
|
160
|
+
S3_ACCESS_KEY: "ak",
|
|
161
|
+
S3_SECRET_KEY: "sk",
|
|
162
|
+
});
|
|
163
|
+
configureStorage({ db, driver: driver(), localDir: dir });
|
|
164
|
+
|
|
165
|
+
await isRemoteStorageEnabled();
|
|
166
|
+
await isRemoteStorageEnabled();
|
|
167
|
+
expect(db.systemConfig.findMany).toHaveBeenCalledTimes(1);
|
|
168
|
+
|
|
169
|
+
clearStorageCache();
|
|
170
|
+
await isRemoteStorageEnabled();
|
|
171
|
+
expect(db.systemConfig.findMany).toHaveBeenCalledTimes(2);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("createFileProxyHandler", () => {
|
|
176
|
+
const req = () => new Request("http://x/api/files/a.pdf") as never;
|
|
177
|
+
const ctx = (key: string, session?: unknown) => ({
|
|
178
|
+
params: Promise.resolve({ key: key.split("/") }),
|
|
179
|
+
session,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
beforeEach(async () => {
|
|
183
|
+
configureStorage({ db: fakeDb(), localDir: dir });
|
|
184
|
+
await putFile("uploads/bao cao.pdf", Buffer.from("%PDF"), "application/pdf");
|
|
185
|
+
await putFile("uploads/data.csv", Buffer.from("a,b"), "text/csv");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("trả tập tin, pdf mở inline", async () => {
|
|
189
|
+
const res = await createFileProxyHandler()(req(), ctx("uploads/bao cao.pdf"));
|
|
190
|
+
|
|
191
|
+
expect(res.status).toBe(200);
|
|
192
|
+
expect(res.headers.get("Content-Type")).toBe("application/pdf");
|
|
193
|
+
expect(res.headers.get("Content-Disposition")).toBe(
|
|
194
|
+
'inline; filename="bao%20cao.pdf"',
|
|
195
|
+
);
|
|
196
|
+
expect(res.headers.get("Cache-Control")).toBe("private, max-age=3600");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("loại không xem được thì tải về", async () => {
|
|
200
|
+
const res = await createFileProxyHandler()(req(), ctx("uploads/data.csv"));
|
|
201
|
+
expect(res.headers.get("Content-Disposition")).toMatch(/^attachment;/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("authorize=false → 403 và KHÔNG đọc kho", async () => {
|
|
205
|
+
const res = await createFileProxyHandler({ authorize: () => false })(
|
|
206
|
+
req(),
|
|
207
|
+
ctx("uploads/bao cao.pdf"),
|
|
208
|
+
);
|
|
209
|
+
expect(res.status).toBe(403);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("authorize trả mã riêng → dùng mã đó (404 để giấu sự tồn tại)", async () => {
|
|
213
|
+
const res = await createFileProxyHandler({
|
|
214
|
+
authorize: () => ({ status: 404, message: "Not found" }),
|
|
215
|
+
})(req(), ctx("uploads/bao cao.pdf"));
|
|
216
|
+
expect(res.status).toBe(404);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("seam authorize nhận đúng key và session", async () => {
|
|
220
|
+
const authorize = vi.fn(() => true);
|
|
221
|
+
await createFileProxyHandler({ authorize })(
|
|
222
|
+
req(),
|
|
223
|
+
ctx("uploads/bao cao.pdf", { userId: "u1" }),
|
|
224
|
+
);
|
|
225
|
+
expect(authorize).toHaveBeenCalledWith(
|
|
226
|
+
expect.objectContaining({
|
|
227
|
+
key: "uploads/bao cao.pdf",
|
|
228
|
+
session: { userId: "u1" },
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("key bẩn bị chặn TRƯỚC authorize (seam app hay lấy key đi tra DB)", async () => {
|
|
234
|
+
const authorize = vi.fn(() => true);
|
|
235
|
+
const res = await createFileProxyHandler({ authorize })(
|
|
236
|
+
req(),
|
|
237
|
+
ctx("../../etc/passwd"),
|
|
238
|
+
);
|
|
239
|
+
expect(res.status).toBe(400);
|
|
240
|
+
expect(authorize).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("traversal → 400, thiếu key → 400, không có tập tin → 404", async () => {
|
|
244
|
+
const handler = createFileProxyHandler();
|
|
245
|
+
expect((await handler(req(), ctx("../../etc/passwd"))).status).toBe(400);
|
|
246
|
+
expect((await handler(req(), { params: Promise.resolve({}) })).status).toBe(
|
|
247
|
+
400,
|
|
248
|
+
);
|
|
249
|
+
expect((await handler(req(), ctx("uploads/thieu.pdf"))).status).toBe(404);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("rejectUnknownType chặn loại ngoài bảng (route công khai)", async () => {
|
|
253
|
+
const res = await createFileProxyHandler({
|
|
254
|
+
contentTypes: { png: "image/png" },
|
|
255
|
+
rejectUnknownType: true,
|
|
256
|
+
})(req(), ctx("uploads/bao cao.pdf"));
|
|
257
|
+
expect(res.status).toBe(404);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("createUploadHandler", () => {
|
|
262
|
+
beforeEach(() => {
|
|
263
|
+
configureStorage({ db: fakeDb(), localDir: dir });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Dựng request giả thay vì `new Request(body: FormData)`: môi trường test là
|
|
267
|
+
// jsdom nên File là của jsdom, còn Request.formData() lại do undici phân tích
|
|
268
|
+
// — undici không nhận File lạ và ném AssertionError trước khi vào handler.
|
|
269
|
+
const fakeFile = (name: string, content: string, type: string) => ({
|
|
270
|
+
name,
|
|
271
|
+
type,
|
|
272
|
+
size: Buffer.byteLength(content),
|
|
273
|
+
arrayBuffer: async () => Buffer.from(content),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const post = (file: unknown) =>
|
|
277
|
+
({
|
|
278
|
+
formData: async () => ({ get: (key: string) => (key === "file" ? file : null) }),
|
|
279
|
+
}) as never;
|
|
280
|
+
|
|
281
|
+
it("lưu được và trả URL proxy — KHÔNG phải đường dẫn public", async () => {
|
|
282
|
+
const res = await createUploadHandler()(
|
|
283
|
+
post(fakeFile("Hợp đồng.pdf", "nội dung", "application/pdf")),
|
|
284
|
+
);
|
|
285
|
+
const body = (await res.json()) as { url: string; key: string };
|
|
286
|
+
|
|
287
|
+
expect(res.status).toBe(200);
|
|
288
|
+
expect(body.url).toMatch(
|
|
289
|
+
/^\/api\/files\/uploads\/\d{8}\/Hợp_đồng_\d+\.pdf$/,
|
|
290
|
+
);
|
|
291
|
+
expect((await getFile(body.key)).toString()).toBe("nội dung");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("chặn đuôi không cho phép", async () => {
|
|
295
|
+
const res = await createUploadHandler()(
|
|
296
|
+
post(fakeFile("virus.exe", "MZ", "application/octet-stream")),
|
|
297
|
+
);
|
|
298
|
+
expect(res.status).toBe(415);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("chặn tập tin quá lớn", async () => {
|
|
302
|
+
const res = await createUploadHandler({ maxBytes: 4 })(
|
|
303
|
+
post(fakeFile("a.txt", "quá dài", "text/plain")),
|
|
304
|
+
);
|
|
305
|
+
expect(res.status).toBe(413);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("không có tập tin → 400", async () => {
|
|
309
|
+
const res = await createUploadHandler()(post(null));
|
|
310
|
+
expect(res.status).toBe(400);
|
|
311
|
+
});
|
|
312
|
+
});
|