@gencow/core 0.1.8 → 0.1.9
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/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/reactive.d.ts +3 -1
- package/dist/scheduler.d.ts +29 -10
- package/dist/scheduler.js +5 -19
- package/dist/scoped-db.d.ts +34 -0
- package/dist/scoped-db.js +364 -0
- package/dist/storage.d.ts +21 -3
- package/dist/storage.js +112 -8
- package/dist/table.d.ts +67 -0
- package/dist/table.js +98 -0
- package/package.json +1 -1
- package/src/__tests__/auth.test.ts +114 -0
- package/src/__tests__/httpaction.test.ts +122 -0
- package/src/__tests__/scheduler-exec.test.ts +246 -0
- package/src/__tests__/scheduler.test.ts +169 -0
- package/src/__tests__/scoped-db.test.ts +442 -0
- package/src/__tests__/storage.test.ts +208 -0
- package/src/__tests__/table.test.ts +324 -0
- package/src/__tests__/validator.test.ts +284 -0
- package/src/index.ts +6 -0
- package/src/reactive.ts +3 -1
- package/src/scheduler.ts +17 -3
- package/src/scoped-db.ts +416 -0
- package/src/storage.ts +157 -12
- package/src/table.ts +165 -0
package/dist/storage.js
CHANGED
|
@@ -4,53 +4,137 @@ import * as crypto from "crypto";
|
|
|
4
4
|
// ─── Constants ──────────────────────────────────────────
|
|
5
5
|
/** 파일 업로드 최대 크기: 50MB (하드코딩 — 사용자가 오버라이드 불가) */
|
|
6
6
|
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
7
|
+
/** 기본 스토리지 쿼터: 1GB */
|
|
8
|
+
const DEFAULT_STORAGE_QUOTA = 1024 * 1024 * 1024;
|
|
7
9
|
function formatBytes(bytes) {
|
|
8
10
|
if (bytes < 1024)
|
|
9
11
|
return `${bytes}B`;
|
|
10
12
|
if (bytes < 1024 * 1024)
|
|
11
13
|
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
12
|
-
|
|
14
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
15
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
16
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
|
|
13
17
|
}
|
|
14
18
|
// ─── Implementation ─────────────────────────────────────
|
|
15
19
|
const metaStore = new Map();
|
|
20
|
+
/**
|
|
21
|
+
* files 테이블 자동 생성 (이미 존재하면 무시)
|
|
22
|
+
* admin.ts의 CREATE_FILES_TABLE_SQL과 동일한 스키마 사용
|
|
23
|
+
*/
|
|
24
|
+
let filesTableEnsured = false;
|
|
25
|
+
async function ensureFilesTable(rawSql) {
|
|
26
|
+
if (filesTableEnsured)
|
|
27
|
+
return;
|
|
28
|
+
await rawSql(`
|
|
29
|
+
DO $$
|
|
30
|
+
BEGIN
|
|
31
|
+
IF NOT EXISTS (
|
|
32
|
+
SELECT 1 FROM information_schema.tables
|
|
33
|
+
WHERE table_schema = current_schema()
|
|
34
|
+
AND table_name = 'files'
|
|
35
|
+
) THEN
|
|
36
|
+
CREATE TABLE files (
|
|
37
|
+
id SERIAL PRIMARY KEY,
|
|
38
|
+
storage_id TEXT NOT NULL,
|
|
39
|
+
name TEXT NOT NULL,
|
|
40
|
+
size TEXT NOT NULL,
|
|
41
|
+
type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
|
42
|
+
uploaded_by TEXT DEFAULT 'api',
|
|
43
|
+
created_at TIMESTAMP DEFAULT NOW()
|
|
44
|
+
);
|
|
45
|
+
END IF;
|
|
46
|
+
END$$;
|
|
47
|
+
`);
|
|
48
|
+
filesTableEnsured = true;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 스토리지 쿼터 검증 — 현재 총 사용량 + 새 파일 크기가 쿼터를 초과하는지 확인
|
|
52
|
+
*/
|
|
53
|
+
async function checkStorageQuota(rawSql, newFileSize, quota) {
|
|
54
|
+
if (quota <= 0)
|
|
55
|
+
return; // 무제한
|
|
56
|
+
const rows = await rawSql(`SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM files`);
|
|
57
|
+
const currentUsage = Number(rows[0]?.total || "0");
|
|
58
|
+
const projectedUsage = currentUsage + newFileSize;
|
|
59
|
+
if (projectedUsage > quota) {
|
|
60
|
+
throw new Error(`Storage quota exceeded: ${formatBytes(currentUsage)} used + ${formatBytes(newFileSize)} new = ${formatBytes(projectedUsage)}, ` +
|
|
61
|
+
`quota is ${formatBytes(quota)}. Delete files or increase quota.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* DB에 파일 메타데이터 기록 — store() 호출 시 자동 수행
|
|
66
|
+
*
|
|
67
|
+
* 대시보드 업로드(admin.ts)는 uploaded_by='dashboard'로 별도 INSERT하므로,
|
|
68
|
+
* 여기서는 skipDbRecord 옵션으로 중복 방지 가능.
|
|
69
|
+
*/
|
|
70
|
+
async function recordFileToDb(rawSql, storageId, name, size, type, uploadedBy = "api") {
|
|
71
|
+
await ensureFilesTable(rawSql);
|
|
72
|
+
await rawSql(`INSERT INTO files (storage_id, name, size, type, uploaded_by, created_at)
|
|
73
|
+
VALUES ($1, $2, $3, $4, $5, NOW())`, [storageId, name, String(size), type, uploadedBy]);
|
|
74
|
+
}
|
|
16
75
|
/**
|
|
17
76
|
* Create a storage instance — Convex storage 패턴 재현
|
|
18
77
|
*
|
|
78
|
+
* @param dir - 파일 저장 디렉토리
|
|
79
|
+
* @param options - DB 연동 + 쿼터 옵션
|
|
80
|
+
*
|
|
19
81
|
* @example
|
|
20
|
-
* const storage = createStorage('./uploads');
|
|
82
|
+
* const storage = createStorage('./uploads', { rawSql, storageQuota: 1024 * 1024 * 1024 });
|
|
21
83
|
* const id = await storage.store(file);
|
|
22
84
|
* const url = storage.getUrl(id);
|
|
23
85
|
*/
|
|
24
|
-
export function createStorage(dir = "./uploads") {
|
|
86
|
+
export function createStorage(dir = "./uploads", options) {
|
|
87
|
+
const rawSql = options?.rawSql;
|
|
88
|
+
const quota = options?.storageQuota ?? DEFAULT_STORAGE_QUOTA;
|
|
25
89
|
// Ensure directory exists
|
|
26
90
|
fs.mkdir(dir, { recursive: true }).catch(() => { });
|
|
27
91
|
return {
|
|
28
92
|
async store(file, filename) {
|
|
29
|
-
// 크기 제한 검증
|
|
93
|
+
// 크기 제한 검증 (단일 파일)
|
|
30
94
|
if (file.size > MAX_FILE_SIZE) {
|
|
31
95
|
throw new Error(`File too large: ${formatBytes(file.size)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`);
|
|
32
96
|
}
|
|
97
|
+
// 스토리지 쿼터 검증 (앱 전체 용량)
|
|
98
|
+
if (rawSql) {
|
|
99
|
+
await checkStorageQuota(rawSql, file.size, quota);
|
|
100
|
+
}
|
|
33
101
|
const id = crypto.randomUUID();
|
|
34
102
|
const name = filename || (file instanceof File ? file.name : `${id}.bin`);
|
|
35
103
|
const filePath = path.join(dir, id);
|
|
36
104
|
const arrayBuffer = await file.arrayBuffer();
|
|
37
105
|
await fs.writeFile(filePath, Buffer.from(arrayBuffer));
|
|
106
|
+
const type = file.type || "application/octet-stream";
|
|
38
107
|
metaStore.set(id, {
|
|
39
108
|
id,
|
|
40
109
|
name,
|
|
41
110
|
size: file.size,
|
|
42
|
-
type
|
|
111
|
+
type,
|
|
43
112
|
path: filePath,
|
|
44
113
|
});
|
|
114
|
+
// DB 자동 기록 (rawSql 있을 때만)
|
|
115
|
+
if (rawSql) {
|
|
116
|
+
try {
|
|
117
|
+
await recordFileToDb(rawSql, id, name, file.size, type, "api");
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
// DB 기록 실패해도 파일 저장은 성공 — 로그만 남김
|
|
121
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
122
|
+
console.warn(`[storage] DB record failed for ${id}: ${msg}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
45
125
|
return id;
|
|
46
126
|
},
|
|
47
127
|
async storeBuffer(buffer, filename, type = "application/octet-stream") {
|
|
48
|
-
|
|
49
|
-
const filePath = path.join(dir, id);
|
|
50
|
-
// 크기 제한 검증
|
|
128
|
+
// 크기 제한 검증 (단일 파일)
|
|
51
129
|
if (buffer.length > MAX_FILE_SIZE) {
|
|
52
130
|
throw new Error(`File too large: ${formatBytes(buffer.length)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`);
|
|
53
131
|
}
|
|
132
|
+
// 스토리지 쿼터 검증 (앱 전체 용량)
|
|
133
|
+
if (rawSql) {
|
|
134
|
+
await checkStorageQuota(rawSql, buffer.length, quota);
|
|
135
|
+
}
|
|
136
|
+
const id = crypto.randomUUID();
|
|
137
|
+
const filePath = path.join(dir, id);
|
|
54
138
|
await fs.writeFile(filePath, buffer);
|
|
55
139
|
metaStore.set(id, {
|
|
56
140
|
id,
|
|
@@ -59,6 +143,16 @@ export function createStorage(dir = "./uploads") {
|
|
|
59
143
|
type,
|
|
60
144
|
path: filePath,
|
|
61
145
|
});
|
|
146
|
+
// DB 자동 기록
|
|
147
|
+
if (rawSql) {
|
|
148
|
+
try {
|
|
149
|
+
await recordFileToDb(rawSql, id, filename, buffer.length, type, "api");
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
153
|
+
console.warn(`[storage] DB record failed for ${id}: ${msg}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
62
156
|
return id;
|
|
63
157
|
},
|
|
64
158
|
getUrl(storageId) {
|
|
@@ -73,11 +167,21 @@ export function createStorage(dir = "./uploads") {
|
|
|
73
167
|
await fs.unlink(meta.path).catch(() => { });
|
|
74
168
|
metaStore.delete(storageId);
|
|
75
169
|
}
|
|
170
|
+
// DB에서도 삭제 (rawSql 있을 때만)
|
|
171
|
+
if (rawSql) {
|
|
172
|
+
try {
|
|
173
|
+
await rawSql(`DELETE FROM files WHERE storage_id = $1`, [storageId]);
|
|
174
|
+
}
|
|
175
|
+
catch { /* 삭제 실패 무시 — 파일은 이미 제거됨 */ }
|
|
176
|
+
}
|
|
76
177
|
},
|
|
77
178
|
};
|
|
78
179
|
}
|
|
79
180
|
/**
|
|
80
181
|
* Hono routes for serving stored files
|
|
182
|
+
*
|
|
183
|
+
* 인증 없이 public URL로 서빙 — Convex getUrl() 패턴과 동일.
|
|
184
|
+
* 접근 제어는 URL을 반환하는 query/mutation 레벨에서 개발자가 구현.
|
|
81
185
|
*/
|
|
82
186
|
export function storageRoutes(storage, rawSql, storageDir) {
|
|
83
187
|
return async (c) => {
|
package/dist/table.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/core/src/table.ts
|
|
3
|
+
*
|
|
4
|
+
* gencowTable() — Drizzle pgTable wrapper with schema-level access control.
|
|
5
|
+
* Inspired by KeystoneJS's declarative filter pattern.
|
|
6
|
+
*
|
|
7
|
+
* Run tests: bun test packages/core/src/__tests__/table.test.ts
|
|
8
|
+
*/
|
|
9
|
+
import { pgTable } from "drizzle-orm/pg-core";
|
|
10
|
+
import type { GencowCtx } from "./reactive";
|
|
11
|
+
import type { SQL } from "drizzle-orm";
|
|
12
|
+
/**
|
|
13
|
+
* Access control filter function.
|
|
14
|
+
* Returns a Drizzle SQL condition, `true` (allow all), or `false` (deny all).
|
|
15
|
+
* Can be async (e.g., for team membership lookups).
|
|
16
|
+
*/
|
|
17
|
+
export type AccessFilter = (ctx: GencowCtx) => SQL | boolean | Promise<SQL | boolean>;
|
|
18
|
+
/** Field-level access control: per-field read permission. */
|
|
19
|
+
export interface FieldAccessRule {
|
|
20
|
+
read: (ctx: GencowCtx) => boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Options for gencowTable — access control metadata. */
|
|
23
|
+
export interface GencowTableOptions {
|
|
24
|
+
/** Row-level filter — auto-injected into all queries on this table */
|
|
25
|
+
filter: AccessFilter;
|
|
26
|
+
/** Field-level access — unauthorized fields are nullified in results */
|
|
27
|
+
fieldAccess?: Record<string, FieldAccessRule>;
|
|
28
|
+
}
|
|
29
|
+
/** Stored metadata for a gencowTable. */
|
|
30
|
+
export interface TableAccessMeta {
|
|
31
|
+
filter: AccessFilter;
|
|
32
|
+
fieldAccess?: Record<string, FieldAccessRule>;
|
|
33
|
+
tableName: string;
|
|
34
|
+
}
|
|
35
|
+
declare global {
|
|
36
|
+
var __gencow_tableAccessRegistry: Map<any, TableAccessMeta>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Drizzle pgTable wrapper with schema-level access control.
|
|
40
|
+
*
|
|
41
|
+
* Creates a standard Drizzle pgTable (100% compatible) and registers
|
|
42
|
+
* filter + fieldAccess metadata that ctx.db Proxy uses to auto-inject
|
|
43
|
+
* WHERE clauses.
|
|
44
|
+
*
|
|
45
|
+
* @param name - PostgreSQL table name
|
|
46
|
+
* @param columns - Drizzle column definitions (same as pgTable)
|
|
47
|
+
* @param options - Access control (filter required, fieldAccess optional)
|
|
48
|
+
*/
|
|
49
|
+
export declare function gencowTable<T extends Record<string, any>>(name: string, columns: T, options: GencowTableOptions): ReturnType<typeof pgTable<string, T>>;
|
|
50
|
+
/**
|
|
51
|
+
* Convenience helper for userId-based isolation.
|
|
52
|
+
*
|
|
53
|
+
* Usage:
|
|
54
|
+
* export const tasks = gencowTable("tasks", { ... }, ownerFilter("userId"));
|
|
55
|
+
* export const files = gencowTable("files", { ... }, ownerFilter("ownerId"));
|
|
56
|
+
*
|
|
57
|
+
* @param columnName - Name of the user ID column (default: "userId")
|
|
58
|
+
*/
|
|
59
|
+
export declare function ownerFilter(columnName?: string): GencowTableOptions;
|
|
60
|
+
/** Get access control metadata for a table. Returns undefined for plain pgTable. */
|
|
61
|
+
export declare function getTableAccessMeta(table: any): TableAccessMeta | undefined;
|
|
62
|
+
/** Check if a table has gencowTable metadata registered. */
|
|
63
|
+
export declare function isGencowTable(table: any): boolean;
|
|
64
|
+
/** Get all registered gencowTables (for audit/boot-time checks). */
|
|
65
|
+
export declare function getAllGencowTables(): Map<any, TableAccessMeta>;
|
|
66
|
+
/** Reset registry (for testing only). */
|
|
67
|
+
export declare function _resetTableRegistry(): void;
|
package/dist/table.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/core/src/table.ts
|
|
3
|
+
*
|
|
4
|
+
* gencowTable() — Drizzle pgTable wrapper with schema-level access control.
|
|
5
|
+
* Inspired by KeystoneJS's declarative filter pattern.
|
|
6
|
+
*
|
|
7
|
+
* Run tests: bun test packages/core/src/__tests__/table.test.ts
|
|
8
|
+
*/
|
|
9
|
+
import { pgTable } from "drizzle-orm/pg-core";
|
|
10
|
+
import { eq } from "drizzle-orm";
|
|
11
|
+
function isOwnerFilter(opts) {
|
|
12
|
+
return "_ownerColumn" in opts;
|
|
13
|
+
}
|
|
14
|
+
if (!globalThis.__gencow_tableAccessRegistry) {
|
|
15
|
+
globalThis.__gencow_tableAccessRegistry = new Map();
|
|
16
|
+
}
|
|
17
|
+
const tableAccessRegistry = globalThis.__gencow_tableAccessRegistry;
|
|
18
|
+
// ─── gencowTable() ──────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* Drizzle pgTable wrapper with schema-level access control.
|
|
21
|
+
*
|
|
22
|
+
* Creates a standard Drizzle pgTable (100% compatible) and registers
|
|
23
|
+
* filter + fieldAccess metadata that ctx.db Proxy uses to auto-inject
|
|
24
|
+
* WHERE clauses.
|
|
25
|
+
*
|
|
26
|
+
* @param name - PostgreSQL table name
|
|
27
|
+
* @param columns - Drizzle column definitions (same as pgTable)
|
|
28
|
+
* @param options - Access control (filter required, fieldAccess optional)
|
|
29
|
+
*/
|
|
30
|
+
export function gencowTable(name, columns, options) {
|
|
31
|
+
if (!options || (typeof options.filter !== "function" && !isOwnerFilter(options))) {
|
|
32
|
+
throw new Error(`[gencow] gencowTable("${name}") requires a filter option. ` +
|
|
33
|
+
`Use ownerFilter("userId") for simple user isolation, ` +
|
|
34
|
+
`or { filter: () => true } for public tables.`);
|
|
35
|
+
}
|
|
36
|
+
// Create standard Drizzle pgTable — fully compatible
|
|
37
|
+
const table = pgTable(name, columns);
|
|
38
|
+
// Resolve ownerFilter to a concrete filter bound to this table
|
|
39
|
+
let filter;
|
|
40
|
+
if (isOwnerFilter(options)) {
|
|
41
|
+
const columnName = options._ownerColumn;
|
|
42
|
+
const col = table[columnName];
|
|
43
|
+
if (!col) {
|
|
44
|
+
throw new Error(`[gencow] ownerFilter("${columnName}"): column "${columnName}" not found on table "${name}". ` +
|
|
45
|
+
`Available columns: ${Object.keys(table).filter(k => !k.startsWith("_") && !k.startsWith("$")).join(", ")}`);
|
|
46
|
+
}
|
|
47
|
+
filter = (ctx) => {
|
|
48
|
+
const user = ctx.auth.requireAuth();
|
|
49
|
+
return eq(col, user.id);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
filter = options.filter;
|
|
54
|
+
}
|
|
55
|
+
// Store access control metadata keyed by the table object reference
|
|
56
|
+
tableAccessRegistry.set(table, {
|
|
57
|
+
filter,
|
|
58
|
+
fieldAccess: options.fieldAccess,
|
|
59
|
+
tableName: name,
|
|
60
|
+
});
|
|
61
|
+
return table;
|
|
62
|
+
}
|
|
63
|
+
// ─── ownerFilter() helper ───────────────────────────────
|
|
64
|
+
/**
|
|
65
|
+
* Convenience helper for userId-based isolation.
|
|
66
|
+
*
|
|
67
|
+
* Usage:
|
|
68
|
+
* export const tasks = gencowTable("tasks", { ... }, ownerFilter("userId"));
|
|
69
|
+
* export const files = gencowTable("files", { ... }, ownerFilter("ownerId"));
|
|
70
|
+
*
|
|
71
|
+
* @param columnName - Name of the user ID column (default: "userId")
|
|
72
|
+
*/
|
|
73
|
+
export function ownerFilter(columnName = "userId") {
|
|
74
|
+
// Return a marker object — gencowTable() resolves this to a concrete filter
|
|
75
|
+
// by binding to the actual table column at registration time.
|
|
76
|
+
return {
|
|
77
|
+
_ownerColumn: columnName,
|
|
78
|
+
// Placeholder filter — replaced by gencowTable()
|
|
79
|
+
filter: () => { throw new Error("[gencow] ownerFilter placeholder should not be called directly"); },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// ─── Lookup ─────────────────────────────────────────────
|
|
83
|
+
/** Get access control metadata for a table. Returns undefined for plain pgTable. */
|
|
84
|
+
export function getTableAccessMeta(table) {
|
|
85
|
+
return tableAccessRegistry.get(table);
|
|
86
|
+
}
|
|
87
|
+
/** Check if a table has gencowTable metadata registered. */
|
|
88
|
+
export function isGencowTable(table) {
|
|
89
|
+
return tableAccessRegistry.has(table);
|
|
90
|
+
}
|
|
91
|
+
/** Get all registered gencowTables (for audit/boot-time checks). */
|
|
92
|
+
export function getAllGencowTables() {
|
|
93
|
+
return new Map(tableAccessRegistry);
|
|
94
|
+
}
|
|
95
|
+
/** Reset registry (for testing only). */
|
|
96
|
+
export function _resetTableRegistry() {
|
|
97
|
+
tableAccessRegistry.clear();
|
|
98
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/core/src/__tests__/auth.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests for auth module — AuthCtx, defineAuth, auth-config.
|
|
5
|
+
*
|
|
6
|
+
* Run: bun test packages/core/src/__tests__/auth.test.ts
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from "bun:test";
|
|
10
|
+
import { defineAuth } from "../auth-config";
|
|
11
|
+
import type { GencowAuthConfig } from "../auth-config";
|
|
12
|
+
|
|
13
|
+
// ─── defineAuth() ───────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe("defineAuth()", () => {
|
|
16
|
+
it("빈 설정 객체 반환", () => {
|
|
17
|
+
const config = defineAuth({});
|
|
18
|
+
expect(config).toEqual({});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("emailVerification 설정이 그대로 반환된다", () => {
|
|
22
|
+
const sendFn = async () => {};
|
|
23
|
+
const config = defineAuth({
|
|
24
|
+
emailVerification: {
|
|
25
|
+
sendOnSignUp: true,
|
|
26
|
+
requireEmailVerification: true,
|
|
27
|
+
autoSignInAfterVerification: true,
|
|
28
|
+
sendVerificationEmail: sendFn,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(config.emailVerification?.sendOnSignUp).toBe(true);
|
|
33
|
+
expect(config.emailVerification?.requireEmailVerification).toBe(true);
|
|
34
|
+
expect(config.emailVerification?.autoSignInAfterVerification).toBe(true);
|
|
35
|
+
expect(config.emailVerification?.sendVerificationEmail).toBe(sendFn);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("부분 설정도 허용된다", () => {
|
|
39
|
+
const config = defineAuth({
|
|
40
|
+
emailVerification: {
|
|
41
|
+
sendVerificationEmail: async () => {},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(config.emailVerification?.sendOnSignUp).toBeUndefined();
|
|
46
|
+
expect(config.emailVerification?.sendVerificationEmail).toBeDefined();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ─── AuthCtx interface 패턴 ─────────────────────────────
|
|
51
|
+
|
|
52
|
+
describe("AuthCtx 패턴 (mock)", () => {
|
|
53
|
+
// AuthCtx는 런타임에서 생성되므로, 인터페이스 수준에서 패턴 검증
|
|
54
|
+
|
|
55
|
+
it("getUserIdentity — 비로그인 시 null", () => {
|
|
56
|
+
const authCtx = {
|
|
57
|
+
getUserIdentity: () => null,
|
|
58
|
+
requireAuth: () => { throw new Error("Authentication required"); },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
expect(authCtx.getUserIdentity()).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("getUserIdentity — 로그인 시 유저 반환", () => {
|
|
65
|
+
const user = { id: "u1", email: "test@test.com", name: "Test" };
|
|
66
|
+
const authCtx = {
|
|
67
|
+
getUserIdentity: () => user,
|
|
68
|
+
requireAuth: () => user,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
expect(authCtx.getUserIdentity()).toEqual(user);
|
|
72
|
+
expect(authCtx.getUserIdentity()!.id).toBe("u1");
|
|
73
|
+
expect(authCtx.getUserIdentity()!.email).toBe("test@test.com");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("requireAuth — 비로그인 시 에러 throw", () => {
|
|
77
|
+
const authCtx = {
|
|
78
|
+
getUserIdentity: () => null,
|
|
79
|
+
requireAuth: () => { throw new Error("Authentication required"); },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
expect(() => authCtx.requireAuth()).toThrow("Authentication required");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("requireAuth — 로그인 시 유저 반환 (throw 안 함)", () => {
|
|
86
|
+
const user = { id: "u2", email: "auth@test.com" };
|
|
87
|
+
const authCtx = {
|
|
88
|
+
getUserIdentity: () => user,
|
|
89
|
+
requireAuth: () => user,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
expect(() => authCtx.requireAuth()).not.toThrow();
|
|
93
|
+
expect(authCtx.requireAuth()).toEqual(user);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ─── Secure by Default 검증 ─────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe("Secure by Default — 기본 인증 필수", () => {
|
|
100
|
+
// query/mutation의 isPublic 기본값은 reactive.test.ts에서 검증됨.
|
|
101
|
+
// 여기서는 auth 엔드포인트 레벨 패턴만 확인.
|
|
102
|
+
|
|
103
|
+
it("공개(public) 쿼리는 auth 없이 실행 가능해야 함", () => {
|
|
104
|
+
// 이 테스트는 query({ public: true })의 isPublic 플래그 확인
|
|
105
|
+
// reactive.test.ts의 "Secure by Default" 섹션과 연계
|
|
106
|
+
const mockQueryDef = { isPublic: true, handler: async () => [] };
|
|
107
|
+
expect(mockQueryDef.isPublic).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("비공개 쿼리는 기본적으로 auth 필수", () => {
|
|
111
|
+
const mockQueryDef = { isPublic: false, handler: async () => [] };
|
|
112
|
+
expect(mockQueryDef.isPublic).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/core/src/__tests__/httpaction.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests for httpAction() — custom HTTP endpoint registration.
|
|
5
|
+
*
|
|
6
|
+
* Run: bun test packages/core/src/__tests__/httpaction.test.ts
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
10
|
+
import { httpAction, getRegisteredHttpActions } from "../reactive";
|
|
11
|
+
|
|
12
|
+
// Clean up httpAction registry between tests
|
|
13
|
+
// Note: httpAction uses globalThis.__gencow_httpActionRegistry (push-only array)
|
|
14
|
+
// We clear it in beforeEach to isolate tests.
|
|
15
|
+
|
|
16
|
+
describe("httpAction()", () => {
|
|
17
|
+
const initialLength = getRegisteredHttpActions().length;
|
|
18
|
+
|
|
19
|
+
it("httpAction 등록 시 레지스트리에 추가된다", () => {
|
|
20
|
+
const before = getRegisteredHttpActions().length;
|
|
21
|
+
|
|
22
|
+
httpAction({
|
|
23
|
+
method: "GET",
|
|
24
|
+
path: "/test/health",
|
|
25
|
+
handler: async () => ({ body: { status: "ok" } }),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const after = getRegisteredHttpActions().length;
|
|
29
|
+
expect(after).toBe(before + 1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("method, path가 정확히 설정된다", () => {
|
|
33
|
+
httpAction({
|
|
34
|
+
method: "POST",
|
|
35
|
+
path: "/webhook/stripe",
|
|
36
|
+
handler: async () => ({ body: { received: true } }),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const actions = getRegisteredHttpActions();
|
|
40
|
+
const last = actions[actions.length - 1];
|
|
41
|
+
expect(last.method).toBe("POST");
|
|
42
|
+
expect(last.path).toBe("/webhook/stripe");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("public 기본값은 false이다", () => {
|
|
46
|
+
httpAction({
|
|
47
|
+
method: "GET",
|
|
48
|
+
path: "/test/private",
|
|
49
|
+
handler: async () => ({ body: {} }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const actions = getRegisteredHttpActions();
|
|
53
|
+
const last = actions[actions.length - 1];
|
|
54
|
+
expect(last.isPublic).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("public: true 설정 시 isPublic === true", () => {
|
|
58
|
+
httpAction({
|
|
59
|
+
method: "GET",
|
|
60
|
+
path: "/test/public-endpoint",
|
|
61
|
+
public: true,
|
|
62
|
+
handler: async () => ({ body: {} }),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const actions = getRegisteredHttpActions();
|
|
66
|
+
const last = actions[actions.length - 1];
|
|
67
|
+
expect(last.isPublic).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("handler가 HttpActionDef에 포함된다", () => {
|
|
71
|
+
const handler = async () => ({ body: { ok: true } });
|
|
72
|
+
httpAction({
|
|
73
|
+
method: "PUT",
|
|
74
|
+
path: "/test/with-handler",
|
|
75
|
+
handler,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const actions = getRegisteredHttpActions();
|
|
79
|
+
const last = actions[actions.length - 1];
|
|
80
|
+
expect(last.handler).toBe(handler);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("모든 HTTP 메서드 지원 (GET, POST, PUT, DELETE, PATCH)", () => {
|
|
84
|
+
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
|
|
85
|
+
const before = getRegisteredHttpActions().length;
|
|
86
|
+
|
|
87
|
+
for (const method of methods) {
|
|
88
|
+
httpAction({
|
|
89
|
+
method,
|
|
90
|
+
path: `/test/method-${method.toLowerCase()}`,
|
|
91
|
+
handler: async () => ({ body: {} }),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const after = getRegisteredHttpActions().length;
|
|
96
|
+
expect(after).toBe(before + 5);
|
|
97
|
+
|
|
98
|
+
const actions = getRegisteredHttpActions();
|
|
99
|
+
const registered = actions.slice(-5).map(a => a.method);
|
|
100
|
+
expect(registered).toEqual(["GET", "POST", "PUT", "DELETE", "PATCH"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("getRegisteredHttpActions()는 복사본을 반환한다 (원본 보호)", () => {
|
|
104
|
+
const a = getRegisteredHttpActions();
|
|
105
|
+
const b = getRegisteredHttpActions();
|
|
106
|
+
expect(a).not.toBe(b);
|
|
107
|
+
expect(a).toEqual(b);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("Hono 경로 패턴 (/api/:id) 지원", () => {
|
|
111
|
+
httpAction({
|
|
112
|
+
method: "GET",
|
|
113
|
+
path: "/api/apps/:id/status",
|
|
114
|
+
public: true,
|
|
115
|
+
handler: async () => ({ body: {} }),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const actions = getRegisteredHttpActions();
|
|
119
|
+
const last = actions[actions.length - 1];
|
|
120
|
+
expect(last.path).toBe("/api/apps/:id/status");
|
|
121
|
+
});
|
|
122
|
+
});
|