create-fullstack-scaffold 0.4.9 → 0.4.10
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/cli/index.js +499 -100
- package/dist/cli/index.js.map +1 -1
- package/package.json +2 -2
- package/template/CLAUDE.md +18 -6
- package/template/drizzle/0004_add_merchants_products.sql +30 -0
- package/template/drizzle/meta/_journal.json +7 -0
- package/template/eslint-rules/module-boundary.js +6 -2
- package/template/eslint-rules/no-cross-module-service-import.js +105 -0
- package/template/eslint-rules/no-disable-type-safe-client.js +5 -0
- package/template/eslint.config.js +9 -0
- package/template/lint-scripts/config/project.config.ts +21 -0
- package/template/lint-scripts/validate-all.ts +48 -12
- package/template/lint-scripts/validators/index.ts +29 -0
- package/template/lint-scripts/validators/module-public-api.validator.ts +103 -0
- package/template/lint-scripts/validators/schema-uniqueness.validator.ts +147 -0
- package/template/modules.config.ts +2 -0
- package/template/package.json +2 -0
- package/template/playwright.config.ts +14 -0
- package/template/src/admin/App.tsx +40 -3
- package/template/src/admin/components/LanguageSwitcher.tsx +22 -0
- package/template/src/admin/components/NotificationDrawer.tsx +108 -38
- package/template/src/admin/components/StatsCard.tsx +3 -1
- package/template/src/admin/components/ThemeToggle.tsx +28 -0
- package/template/src/admin/hooks/useRoles.ts +2 -2
- package/template/src/admin/i18n/index.ts +18 -0
- package/template/src/admin/i18n/locales/en-US.json +381 -0
- package/template/src/admin/i18n/locales/zh-CN.json +381 -0
- package/template/src/admin/i18n/useLanguage.ts +21 -0
- package/template/src/admin/layouts/Header.tsx +37 -16
- package/template/src/admin/layouts/Layout.tsx +7 -3
- package/template/src/admin/layouts/Sidebar.tsx +70 -68
- package/template/src/admin/main.tsx +1 -0
- package/template/src/admin/pages/ContentPage.tsx +68 -56
- package/template/src/admin/pages/DashboardPage.tsx +82 -76
- package/template/src/admin/pages/DisputesPage.tsx +61 -53
- package/template/src/admin/pages/LoginPage.tsx +41 -33
- package/template/src/admin/pages/OrdersPage.tsx +69 -64
- package/template/src/admin/pages/PermissionsPage.tsx +10 -8
- package/template/src/admin/pages/PluginDashboardPage.tsx +3 -1
- package/template/src/admin/pages/PluginManagementPage.tsx +3 -1
- package/template/src/admin/pages/RegisterPage.tsx +18 -16
- package/template/src/admin/pages/RolesPage.tsx +51 -49
- package/template/src/admin/pages/SettingsPage.tsx +22 -22
- package/template/src/admin/pages/SystemLogsPage.tsx +32 -30
- package/template/src/admin/pages/TicketsPage.tsx +67 -59
- package/template/src/admin/pages/UsersPage.tsx +63 -53
- package/template/src/admin/pages/__tests__/RolesPage.test.tsx +2 -2
- package/template/src/admin/stores/themeStore.ts +32 -0
- package/template/src/client/index.css +73 -0
- package/template/src/merchant/App.tsx +2 -0
- package/template/src/merchant/components/MerchantGuard.tsx +2 -6
- package/template/src/merchant/components/__tests__/MerchantGuard.test.tsx +117 -0
- package/template/src/merchant/layouts/Header.tsx +2 -2
- package/template/src/merchant/pages/DashboardPage.tsx +2 -2
- package/template/src/merchant/pages/DisputesPage.tsx +14 -10
- package/template/src/merchant/pages/LoginPage.tsx +49 -0
- package/template/src/merchant/pages/OrdersPage.tsx +17 -10
- package/template/src/merchant/pages/ProductsPage.tsx +31 -8
- package/template/src/merchant/pages/SettingsPage.tsx +3 -7
- package/template/src/merchant/stores/__tests__/merchantStore.test.ts +317 -0
- package/template/src/merchant/stores/merchantStore.ts +24 -27
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/merchants.ts +26 -0
- package/template/src/server/db/schema/products.ts +25 -0
- package/template/src/server/db/test-setup.ts +237 -0
- package/template/src/server/middleware/__tests__/audit-log.test.ts +144 -0
- package/template/src/server/middleware/__tests__/cors.test.ts +84 -0
- package/template/src/server/middleware/__tests__/logger.test.ts +117 -0
- package/template/src/server/middleware/__tests__/rate-limit.test.ts +73 -0
- package/template/src/server/middleware/__tests__/realtime-env.test.ts +56 -0
- package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +150 -0
- package/template/src/server/module-admin/__tests__/admin-routes.test.ts +1 -1
- package/template/src/server/module-admin/module.ts +4 -0
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +3 -3
- package/template/src/server/module-admin/routes/user-management-routes.ts +2 -2
- package/template/src/server/module-auth/__tests__/auth-routes.test.ts +315 -0
- package/template/src/server/module-auth/__tests__/profile-routes.test.ts +83 -0
- package/template/src/server/module-auth/module.ts +2 -0
- package/template/src/server/module-content/module.ts +1 -0
- package/template/src/server/module-content/routes/content-routes.ts +2 -2
- package/template/src/server/module-dispute/module.ts +1 -0
- package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -2
- package/template/src/server/module-merchant/__tests__/merchant-routes.test.ts +218 -0
- package/template/src/server/module-merchant/__tests__/merchant-service.test.ts +183 -0
- package/template/src/server/module-merchant/index.ts +10 -0
- package/template/src/server/module-merchant/module.ts +33 -0
- package/template/src/server/module-merchant/routes/merchant-routes.ts +138 -0
- package/template/src/server/module-merchant/services/merchant-service.ts +292 -0
- package/template/src/server/module-notifications/index.ts +18 -0
- package/template/src/server/module-notifications/module.ts +2 -0
- package/template/src/server/module-order/module.ts +1 -0
- package/template/src/server/module-order/routes/order-routes.ts +2 -2
- package/template/src/server/module-permission/routes/role-routes.ts +3 -3
- package/template/src/server/module-plugin/__tests__/admin-category-service.test.ts +181 -0
- package/template/src/server/module-plugin/__tests__/admin-plugin-service.test.ts +277 -0
- package/template/src/server/module-plugin/__tests__/admin-stats-service.test.ts +170 -0
- package/template/src/server/module-plugin/__tests__/plugin-review-service.test.ts +260 -0
- package/template/src/server/module-plugin/__tests__/plugin-seed-service.test.ts +170 -0
- package/template/src/server/module-plugin/module.ts +3 -0
- package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +142 -0
- package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +152 -0
- package/template/src/server/module-tenant/module.ts +1 -0
- package/template/src/server/module-tenant/services/tenant-service.ts +1 -37
- package/template/src/server/module-ticket/module.ts +1 -0
- package/template/src/server/module-ticket/routes/ticket-routes.ts +2 -2
- package/template/src/server/module-todos/module.ts +3 -0
- package/template/src/server/route-registry.ts +4 -0
- package/template/src/server/utils/__tests__/date.test.ts +130 -0
- package/template/src/server/utils/__tests__/env.test.ts +25 -0
- package/template/src/server/utils/__tests__/generate.test.ts +82 -0
- package/template/src/server/utils/__tests__/id-helpers.test.ts +29 -0
- package/template/src/server/utils/__tests__/json.test.ts +49 -0
- package/template/src/server/utils/__tests__/permission-utils.test.ts +26 -0
- package/template/src/server/utils/__tests__/uuid.test.ts +25 -0
- package/template/src/shared/core/module-manifest.ts +15 -0
- package/template/src/shared/modules/admin/schemas.ts +1 -1
- package/template/src/shared/modules/content/schemas.ts +2 -2
- package/template/src/shared/modules/dispute/schemas.ts +2 -2
- package/template/src/shared/modules/index.ts +181 -1
- package/template/src/shared/modules/merchant/index.ts +12 -0
- package/template/src/shared/modules/merchant/schemas.ts +63 -0
- package/template/src/shared/modules/order/schemas.ts +2 -2
- package/template/src/shared/modules/permission/index.ts +1 -1
- package/template/src/shared/modules/permission/schemas.ts +1 -1
- package/template/src/shared/modules/role/schemas.ts +2 -2
- package/template/src/shared/modules/ticket/schemas.ts +2 -2
- package/template/src/shared/schemas/index.ts +74 -2
|
@@ -272,6 +272,47 @@ export async function setupTestDatabase(): Promise<void> {
|
|
|
272
272
|
FOREIGN KEY (category_id) REFERENCES plugin_categories(id) ON DELETE CASCADE,
|
|
273
273
|
UNIQUE(plugin_id, category_id)
|
|
274
274
|
);
|
|
275
|
+
|
|
276
|
+
CREATE TABLE IF NOT EXISTS tenants (
|
|
277
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
278
|
+
name TEXT NOT NULL,
|
|
279
|
+
slug TEXT NOT NULL UNIQUE,
|
|
280
|
+
status TEXT NOT NULL DEFAULT 'trial',
|
|
281
|
+
plan TEXT NOT NULL DEFAULT 'free',
|
|
282
|
+
max_users INTEGER NOT NULL DEFAULT 5,
|
|
283
|
+
settings TEXT,
|
|
284
|
+
created_at TEXT NOT NULL,
|
|
285
|
+
updated_at TEXT NOT NULL
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
CREATE TABLE IF NOT EXISTS products (
|
|
289
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
290
|
+
name TEXT NOT NULL,
|
|
291
|
+
description TEXT,
|
|
292
|
+
price INTEGER NOT NULL,
|
|
293
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
294
|
+
stock INTEGER NOT NULL DEFAULT 0,
|
|
295
|
+
image_url TEXT,
|
|
296
|
+
merchant_id INTEGER NOT NULL,
|
|
297
|
+
created_at INTEGER DEFAULT (unixepoch() * 1000) NOT NULL,
|
|
298
|
+
updated_at INTEGER DEFAULT (unixepoch() * 1000) NOT NULL
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
CREATE TABLE IF NOT EXISTS merchants (
|
|
302
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
303
|
+
user_id TEXT NOT NULL,
|
|
304
|
+
tenant_id INTEGER NOT NULL,
|
|
305
|
+
business_name TEXT NOT NULL,
|
|
306
|
+
business_type TEXT NOT NULL,
|
|
307
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
308
|
+
description TEXT,
|
|
309
|
+
phone TEXT,
|
|
310
|
+
email TEXT,
|
|
311
|
+
address TEXT,
|
|
312
|
+
password TEXT NOT NULL,
|
|
313
|
+
created_at TEXT NOT NULL,
|
|
314
|
+
updated_at TEXT NOT NULL
|
|
315
|
+
);
|
|
275
316
|
`
|
|
276
317
|
|
|
277
318
|
const statements = migrationSQL.split(';').filter(s => s.trim())
|
|
@@ -699,6 +740,199 @@ async function seedTestData(client: Client): Promise<void> {
|
|
|
699
740
|
// Ignore duplicate errors
|
|
700
741
|
}
|
|
701
742
|
}
|
|
743
|
+
|
|
744
|
+
// Seed tenants for testing
|
|
745
|
+
const tenants = [
|
|
746
|
+
{
|
|
747
|
+
id: 1,
|
|
748
|
+
name: 'Demo Corp',
|
|
749
|
+
slug: 'demo',
|
|
750
|
+
status: 'active',
|
|
751
|
+
plan: 'pro',
|
|
752
|
+
max_users: 50,
|
|
753
|
+
settings: JSON.stringify({ theme: 'dark', language: 'en' }),
|
|
754
|
+
created_at: new Date(Date.now() - 86400000).toISOString(),
|
|
755
|
+
updated_at: new Date(Date.now() - 86400000).toISOString(),
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
id: 2,
|
|
759
|
+
name: 'Test Inc',
|
|
760
|
+
slug: 'test',
|
|
761
|
+
status: 'trial',
|
|
762
|
+
plan: 'free',
|
|
763
|
+
max_users: 5,
|
|
764
|
+
settings: null,
|
|
765
|
+
created_at: new Date(Date.now() - 172800000).toISOString(),
|
|
766
|
+
updated_at: new Date(Date.now() - 172800000).toISOString(),
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
id: 3,
|
|
770
|
+
name: 'Suspended Corp',
|
|
771
|
+
slug: 'suspended',
|
|
772
|
+
status: 'suspended',
|
|
773
|
+
plan: 'starter',
|
|
774
|
+
max_users: 10,
|
|
775
|
+
settings: JSON.stringify({ theme: 'light', language: 'zh' }),
|
|
776
|
+
created_at: new Date(Date.now() - 259200000).toISOString(),
|
|
777
|
+
updated_at: new Date(Date.now() - 259200000).toISOString(),
|
|
778
|
+
},
|
|
779
|
+
]
|
|
780
|
+
|
|
781
|
+
for (const tenant of tenants) {
|
|
782
|
+
try {
|
|
783
|
+
await client.execute({
|
|
784
|
+
sql: `INSERT OR IGNORE INTO tenants (id, name, slug, status, plan, max_users, settings, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
785
|
+
args: [
|
|
786
|
+
tenant.id,
|
|
787
|
+
tenant.name,
|
|
788
|
+
tenant.slug,
|
|
789
|
+
tenant.status,
|
|
790
|
+
tenant.plan,
|
|
791
|
+
tenant.max_users,
|
|
792
|
+
tenant.settings,
|
|
793
|
+
tenant.created_at,
|
|
794
|
+
tenant.updated_at,
|
|
795
|
+
],
|
|
796
|
+
})
|
|
797
|
+
} catch {
|
|
798
|
+
// Ignore duplicate errors
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Seed products for testing
|
|
803
|
+
const products = [
|
|
804
|
+
{
|
|
805
|
+
id: 'prod-1',
|
|
806
|
+
name: '高级无线耳机',
|
|
807
|
+
description: '降噪蓝牙耳机,支持30小时续航',
|
|
808
|
+
price: 29900, // 299.00元
|
|
809
|
+
status: 'active',
|
|
810
|
+
stock: 100,
|
|
811
|
+
image_url: 'https://example.com/headphones.jpg',
|
|
812
|
+
merchant_id: 1, // merchant-1.id
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
id: 'prod-2',
|
|
816
|
+
name: '智能手表 Pro',
|
|
817
|
+
description: '健康监测、运动追踪,IP68防水',
|
|
818
|
+
price: 19900, // 199.00元
|
|
819
|
+
status: 'active',
|
|
820
|
+
stock: 50,
|
|
821
|
+
image_url: 'https://example.com/smartwatch.jpg',
|
|
822
|
+
merchant_id: 1, // merchant-1.id
|
|
823
|
+
},
|
|
824
|
+
{
|
|
825
|
+
id: 'prod-3',
|
|
826
|
+
name: '机械键盘',
|
|
827
|
+
description: 'RGB背光,Cherry轴体,87键布局',
|
|
828
|
+
price: 89900, // 899.00元
|
|
829
|
+
status: 'active',
|
|
830
|
+
stock: 30,
|
|
831
|
+
image_url: 'https://example.com/keyboard.jpg',
|
|
832
|
+
merchant_id: 2, // merchant-2.id
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
id: 'prod-4',
|
|
836
|
+
name: '4K显示器',
|
|
837
|
+
description: 'IPS面板,144Hz刷新率,HDR支持',
|
|
838
|
+
price: 249900, // 2499.00元
|
|
839
|
+
status: 'inactive',
|
|
840
|
+
stock: 0,
|
|
841
|
+
image_url: 'https://example.com/monitor.jpg',
|
|
842
|
+
merchant_id: 2, // merchant-2.id
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
id: 'prod-5',
|
|
846
|
+
name: '无线鼠标',
|
|
847
|
+
description: '人体工学设计,可编程按键',
|
|
848
|
+
price: 19900, // 199.00元
|
|
849
|
+
status: 'active',
|
|
850
|
+
stock: 200,
|
|
851
|
+
image_url: 'https://example.com/mouse.jpg',
|
|
852
|
+
merchant_id: 1, // merchant-1.id
|
|
853
|
+
},
|
|
854
|
+
]
|
|
855
|
+
|
|
856
|
+
for (const product of products) {
|
|
857
|
+
try {
|
|
858
|
+
await client.execute({
|
|
859
|
+
sql: `INSERT OR IGNORE INTO products (id, name, description, price, status, stock, image_url, merchant_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
860
|
+
args: [
|
|
861
|
+
product.id,
|
|
862
|
+
product.name,
|
|
863
|
+
product.description,
|
|
864
|
+
product.price,
|
|
865
|
+
product.status,
|
|
866
|
+
product.stock,
|
|
867
|
+
product.image_url,
|
|
868
|
+
product.merchant_id,
|
|
869
|
+
Date.now(),
|
|
870
|
+
Date.now(),
|
|
871
|
+
],
|
|
872
|
+
})
|
|
873
|
+
} catch {
|
|
874
|
+
// Ignore duplicate errors
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Seed merchants for testing
|
|
879
|
+
const merchants = [
|
|
880
|
+
{
|
|
881
|
+
id: 1,
|
|
882
|
+
userId: 'merchant-1',
|
|
883
|
+
tenantId: 1,
|
|
884
|
+
businessName: '示例商店',
|
|
885
|
+
businessType: 'retail',
|
|
886
|
+
status: 'active',
|
|
887
|
+
description: '一个示例零售商店',
|
|
888
|
+
phone: '13800138000',
|
|
889
|
+
email: 'merchant@example.com',
|
|
890
|
+
address: '北京市朝阳区',
|
|
891
|
+
password: 'password123',
|
|
892
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
893
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
id: 2,
|
|
897
|
+
userId: 'merchant-2',
|
|
898
|
+
tenantId: 1,
|
|
899
|
+
businessName: '科技商店',
|
|
900
|
+
businessType: 'retail',
|
|
901
|
+
status: 'active',
|
|
902
|
+
description: null,
|
|
903
|
+
phone: null,
|
|
904
|
+
email: null,
|
|
905
|
+
address: null,
|
|
906
|
+
password: 'password123',
|
|
907
|
+
createdAt: '2024-01-02T00:00:00.000Z',
|
|
908
|
+
updatedAt: '2024-01-02T00:00:00.000Z',
|
|
909
|
+
},
|
|
910
|
+
]
|
|
911
|
+
|
|
912
|
+
for (const merchant of merchants) {
|
|
913
|
+
try {
|
|
914
|
+
await client.execute({
|
|
915
|
+
sql: `INSERT OR IGNORE INTO merchants (id, user_id, tenant_id, business_name, business_type, status, description, phone, email, address, password, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
916
|
+
args: [
|
|
917
|
+
merchant.id,
|
|
918
|
+
merchant.userId,
|
|
919
|
+
merchant.tenantId,
|
|
920
|
+
merchant.businessName,
|
|
921
|
+
merchant.businessType,
|
|
922
|
+
merchant.status,
|
|
923
|
+
merchant.description,
|
|
924
|
+
merchant.phone,
|
|
925
|
+
merchant.email,
|
|
926
|
+
merchant.address,
|
|
927
|
+
merchant.password,
|
|
928
|
+
merchant.createdAt,
|
|
929
|
+
merchant.updatedAt,
|
|
930
|
+
],
|
|
931
|
+
})
|
|
932
|
+
} catch {
|
|
933
|
+
// Ignore duplicate errors
|
|
934
|
+
}
|
|
935
|
+
}
|
|
702
936
|
}
|
|
703
937
|
|
|
704
938
|
export async function cleanupTestDatabase(): Promise<void> {
|
|
@@ -734,5 +968,8 @@ export async function cleanupTestDatabase(): Promise<void> {
|
|
|
734
968
|
} catch {
|
|
735
969
|
// Plugin tables may not exist in all test environments
|
|
736
970
|
}
|
|
971
|
+
await client.execute('DELETE FROM tenants')
|
|
972
|
+
await client.execute('DELETE FROM merchants')
|
|
973
|
+
await client.execute('DELETE FROM products')
|
|
737
974
|
}
|
|
738
975
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
|
|
4
|
+
vi.mock('@server/utils/auth', () => ({
|
|
5
|
+
getAuthUser: vi.fn(),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
vi.mock('@server/module-permission/services/audit-log-service', () => ({
|
|
9
|
+
auditLogService: { create: vi.fn().mockResolvedValue({}) },
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
vi.mock('@server/utils/logger', () => ({
|
|
13
|
+
logger: {
|
|
14
|
+
api: () => ({
|
|
15
|
+
info: vi.fn(),
|
|
16
|
+
warn: vi.fn(),
|
|
17
|
+
error: vi.fn(),
|
|
18
|
+
}),
|
|
19
|
+
},
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
vi.mock('@shared/constants', () => ({
|
|
23
|
+
PATH_TO_RESOURCE_TYPE: { users: 'user', orders: 'order', todos: 'todo' },
|
|
24
|
+
ACTION_TYPES: { CREATE: 'create', UPDATE: 'update', DELETE: 'delete' },
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
import { auditLogMiddleware } from '../audit-log'
|
|
28
|
+
import { getAuthUser } from '@server/utils/auth'
|
|
29
|
+
import { auditLogService } from '@server/module-permission/services/audit-log-service'
|
|
30
|
+
|
|
31
|
+
describe('auditLogMiddleware', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.clearAllMocks()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
function createApp() {
|
|
37
|
+
const app = new Hono()
|
|
38
|
+
app.use('*', auditLogMiddleware())
|
|
39
|
+
app.get('/api/users', c => c.json({ ok: true }))
|
|
40
|
+
app.post('/api/users', c => c.json({ ok: true }))
|
|
41
|
+
app.put('/api/users/:id', c => c.json({ ok: true }))
|
|
42
|
+
app.delete('/api/users/:id', c => c.json({ ok: true }))
|
|
43
|
+
app.patch('/api/users/:id', c => c.json({ ok: true }))
|
|
44
|
+
app.get('/health', c => c.json({ status: 'ok' }))
|
|
45
|
+
app.get('/api/permissions/check', c => c.json({ ok: true }))
|
|
46
|
+
return app
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
it('should skip non-API paths', async () => {
|
|
50
|
+
vi.mocked(getAuthUser).mockReturnValue({ id: 'user-1' } as never)
|
|
51
|
+
const app = createApp()
|
|
52
|
+
await app.request('/health')
|
|
53
|
+
expect(auditLogService.create).not.toHaveBeenCalled()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should skip GET requests', async () => {
|
|
57
|
+
vi.mocked(getAuthUser).mockReturnValue({ id: 'user-1' } as never)
|
|
58
|
+
const app = createApp()
|
|
59
|
+
await app.request('/api/users')
|
|
60
|
+
expect(auditLogService.create).not.toHaveBeenCalled()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should skip unauthenticated requests', async () => {
|
|
64
|
+
vi.mocked(getAuthUser).mockReturnValue(undefined as never)
|
|
65
|
+
const app = createApp()
|
|
66
|
+
await app.request('/api/users', { method: 'POST' })
|
|
67
|
+
expect(auditLogService.create).not.toHaveBeenCalled()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should skip /api/permissions/ paths', async () => {
|
|
71
|
+
vi.mocked(getAuthUser).mockReturnValue({ id: 'user-1' } as never)
|
|
72
|
+
const app = createApp()
|
|
73
|
+
await app.request('/api/permissions/check')
|
|
74
|
+
expect(auditLogService.create).not.toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should create audit log for POST with auth', async () => {
|
|
78
|
+
vi.mocked(getAuthUser).mockReturnValue({ id: 'user-1' } as never)
|
|
79
|
+
const app = createApp()
|
|
80
|
+
const res = await app.request('/api/users', { method: 'POST' })
|
|
81
|
+
expect(res.status).toBe(200)
|
|
82
|
+
expect(auditLogService.create).toHaveBeenCalledTimes(1)
|
|
83
|
+
const call = vi.mocked(auditLogService.create).mock.calls[0][0]
|
|
84
|
+
expect(call.userId).toBe('user-1')
|
|
85
|
+
expect(call.action).toBe('create')
|
|
86
|
+
expect(call.resourceType).toBe('user')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should create audit log for PUT with update action', async () => {
|
|
90
|
+
vi.mocked(getAuthUser).mockReturnValue({ id: 'user-1' } as never)
|
|
91
|
+
const app = createApp()
|
|
92
|
+
await app.request('/api/users/123', { method: 'PUT' })
|
|
93
|
+
const call = vi.mocked(auditLogService.create).mock.calls[0][0]
|
|
94
|
+
expect(call.action).toBe('update')
|
|
95
|
+
expect(call.resourceId).toBe('123')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('should create audit log for DELETE with delete action', async () => {
|
|
99
|
+
vi.mocked(getAuthUser).mockReturnValue({ id: 'user-1' } as never)
|
|
100
|
+
const app = createApp()
|
|
101
|
+
await app.request('/api/users/456', { method: 'DELETE' })
|
|
102
|
+
const call = vi.mocked(auditLogService.create).mock.calls[0][0]
|
|
103
|
+
expect(call.action).toBe('delete')
|
|
104
|
+
expect(call.resourceId).toBe('456')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('should create audit log for PATCH with update action', async () => {
|
|
108
|
+
vi.mocked(getAuthUser).mockReturnValue({ id: 'user-1' } as never)
|
|
109
|
+
const app = createApp()
|
|
110
|
+
await app.request('/api/users/789', { method: 'PATCH' })
|
|
111
|
+
const call = vi.mocked(auditLogService.create).mock.calls[0][0]
|
|
112
|
+
expect(call.action).toBe('update')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should not throw when auditLogService.create fails', async () => {
|
|
116
|
+
vi.mocked(getAuthUser).mockReturnValue({ id: 'user-1' } as never)
|
|
117
|
+
vi.mocked(auditLogService.create).mockRejectedValueOnce(new Error('DB error'))
|
|
118
|
+
const app = createApp()
|
|
119
|
+
const res = await app.request('/api/users', { method: 'POST' })
|
|
120
|
+
expect(res.status).toBe(200)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('should capture IP address from headers', async () => {
|
|
124
|
+
vi.mocked(getAuthUser).mockReturnValue({ id: 'user-1' } as never)
|
|
125
|
+
const app = createApp()
|
|
126
|
+
await app.request('/api/users', {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'X-Forwarded-For': '1.2.3.4' },
|
|
129
|
+
})
|
|
130
|
+
const call = vi.mocked(auditLogService.create).mock.calls[0][0]
|
|
131
|
+
expect(call.ipAddress).toBe('1.2.3.4')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('should capture user agent from headers', async () => {
|
|
135
|
+
vi.mocked(getAuthUser).mockReturnValue({ id: 'user-1' } as never)
|
|
136
|
+
const app = createApp()
|
|
137
|
+
await app.request('/api/users', {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'User-Agent': 'TestAgent/1.0' },
|
|
140
|
+
})
|
|
141
|
+
const call = vi.mocked(auditLogService.create).mock.calls[0][0]
|
|
142
|
+
expect(call.userAgent).toBe('TestAgent/1.0')
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
import { corsMiddleware, createCorsMiddleware } from '../cors'
|
|
4
|
+
|
|
5
|
+
describe('corsMiddleware', () => {
|
|
6
|
+
function createApp(options?: Parameters<typeof corsMiddleware>[0]) {
|
|
7
|
+
const app = new Hono()
|
|
8
|
+
app.use('*', corsMiddleware(options))
|
|
9
|
+
app.get('/test', c => c.json({ ok: true }))
|
|
10
|
+
app.options('/test', c => c.json({ ok: true }))
|
|
11
|
+
return app
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
it('should set Access-Control-Allow-Origin header', async () => {
|
|
15
|
+
const app = createApp()
|
|
16
|
+
const res = await app.request('/test', {
|
|
17
|
+
headers: { Origin: 'http://localhost:5173' },
|
|
18
|
+
})
|
|
19
|
+
expect(res.headers.get('Access-Control-Allow-Origin')).toBeTruthy()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should set Access-Control-Allow-Credentials to true', async () => {
|
|
23
|
+
const app = createApp()
|
|
24
|
+
const res = await app.request('/test', {
|
|
25
|
+
headers: { Origin: 'http://localhost:5173' },
|
|
26
|
+
})
|
|
27
|
+
expect(res.headers.get('Access-Control-Allow-Credentials')).toBe('true')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should handle OPTIONS preflight request', async () => {
|
|
31
|
+
const app = createApp()
|
|
32
|
+
const res = await app.request('/test', {
|
|
33
|
+
method: 'OPTIONS',
|
|
34
|
+
headers: { Origin: 'http://localhost:5173' },
|
|
35
|
+
})
|
|
36
|
+
expect(res.status).toBe(204)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('should set allowed methods on preflight', async () => {
|
|
40
|
+
const app = createApp()
|
|
41
|
+
const res = await app.request('/test', {
|
|
42
|
+
method: 'OPTIONS',
|
|
43
|
+
headers: { Origin: 'http://localhost:5173' },
|
|
44
|
+
})
|
|
45
|
+
const allowMethods = res.headers.get('Access-Control-Allow-Methods')
|
|
46
|
+
expect(allowMethods).toBeTruthy()
|
|
47
|
+
expect(allowMethods).toContain('GET')
|
|
48
|
+
expect(allowMethods).toContain('POST')
|
|
49
|
+
expect(allowMethods).toContain('DELETE')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should set allowed headers on preflight', async () => {
|
|
53
|
+
const app = createApp()
|
|
54
|
+
const res = await app.request('/test', {
|
|
55
|
+
method: 'OPTIONS',
|
|
56
|
+
headers: { Origin: 'http://localhost:5173' },
|
|
57
|
+
})
|
|
58
|
+
const allowHeaders = res.headers.get('Access-Control-Allow-Headers')
|
|
59
|
+
expect(allowHeaders).toBeTruthy()
|
|
60
|
+
expect(allowHeaders).toContain('Content-Type')
|
|
61
|
+
expect(allowHeaders).toContain('Authorization')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should respect custom origin', async () => {
|
|
65
|
+
const app = createApp({ origin: 'https://custom.example.com' })
|
|
66
|
+
const res = await app.request('/test', {
|
|
67
|
+
headers: { Origin: 'https://custom.example.com' },
|
|
68
|
+
})
|
|
69
|
+
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://custom.example.com')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should pass through and return response', async () => {
|
|
73
|
+
const app = createApp()
|
|
74
|
+
const res = await app.request('/test')
|
|
75
|
+
expect(res.status).toBe(200)
|
|
76
|
+
const json = (await res.json()) as { ok: boolean }
|
|
77
|
+
expect(json.ok).toBe(true)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('createCorsMiddleware should return same middleware', () => {
|
|
81
|
+
const mw = createCorsMiddleware({ origin: '*' })
|
|
82
|
+
expect(typeof mw).toBe('function')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
|
|
4
|
+
const mockLog = {
|
|
5
|
+
info: vi.fn(),
|
|
6
|
+
warn: vi.fn(),
|
|
7
|
+
error: vi.fn(),
|
|
8
|
+
debug: vi.fn(),
|
|
9
|
+
trace: vi.fn(),
|
|
10
|
+
fatal: vi.fn(),
|
|
11
|
+
child: () => mockLog,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
vi.mock('@server/utils/logger', () => ({
|
|
15
|
+
createModuleLoggerSync: () => mockLog,
|
|
16
|
+
logger: {
|
|
17
|
+
api: () => mockLog,
|
|
18
|
+
app: () => mockLog,
|
|
19
|
+
db: () => mockLog,
|
|
20
|
+
ws: () => mockLog,
|
|
21
|
+
bootstrap: () => mockLog,
|
|
22
|
+
module: () => mockLog,
|
|
23
|
+
},
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
import { loggerMiddleware } from '../logger'
|
|
27
|
+
|
|
28
|
+
describe('loggerMiddleware', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.clearAllMocks()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
function createApp(options?: Parameters<typeof loggerMiddleware>[0]) {
|
|
34
|
+
const app = new Hono()
|
|
35
|
+
app.use('*', loggerMiddleware(options))
|
|
36
|
+
app.get('/test', c => c.json({ ok: true }))
|
|
37
|
+
app.get('/health', c => c.json({ status: 'ok' }))
|
|
38
|
+
return app
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
it('should pass through and return response', async () => {
|
|
42
|
+
const app = createApp()
|
|
43
|
+
const res = await app.request('/test')
|
|
44
|
+
expect(res.status).toBe(200)
|
|
45
|
+
const json = (await res.json()) as { ok: boolean }
|
|
46
|
+
expect(json.ok).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should log request and response for non-excluded paths', async () => {
|
|
50
|
+
const app = createApp()
|
|
51
|
+
await app.request('/test')
|
|
52
|
+
expect(mockLog.info).toHaveBeenCalled()
|
|
53
|
+
const calls = mockLog.info.mock.calls.map(c => c[1])
|
|
54
|
+
expect(calls).toContain('Request started')
|
|
55
|
+
expect(calls).toContain('Request completed')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should skip logging for default excludePaths /health', async () => {
|
|
59
|
+
const app = createApp()
|
|
60
|
+
await app.request('/health')
|
|
61
|
+
expect(mockLog.info).not.toHaveBeenCalled()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should skip request logging when logRequests is false', async () => {
|
|
65
|
+
const app = createApp({ logRequests: false })
|
|
66
|
+
await app.request('/test')
|
|
67
|
+
const calls = mockLog.info.mock.calls.map(c => c[1])
|
|
68
|
+
expect(calls).not.toContain('Request started')
|
|
69
|
+
expect(calls).toContain('Request completed')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should skip response logging when logResponses is false', async () => {
|
|
73
|
+
const app = createApp({ logResponses: false })
|
|
74
|
+
await app.request('/test')
|
|
75
|
+
const calls = mockLog.info.mock.calls.map(c => c[1])
|
|
76
|
+
expect(calls).toContain('Request started')
|
|
77
|
+
expect(calls).not.toContain('Request completed')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should handle custom excludePaths', async () => {
|
|
81
|
+
const app = createApp({ excludePaths: ['/custom', '/health'] })
|
|
82
|
+
app.get('/custom', c => c.json({ ok: true }))
|
|
83
|
+
await app.request('/custom')
|
|
84
|
+
expect(mockLog.info).not.toHaveBeenCalled()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('should re-throw errors after logging', async () => {
|
|
88
|
+
const app = new Hono()
|
|
89
|
+
app.use('*', loggerMiddleware())
|
|
90
|
+
app.get('/boom', () => {
|
|
91
|
+
throw new Error('boom')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const res = await app.request('/boom')
|
|
95
|
+
expect(res.status).toBe(500)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('should skip error logging when logErrors is false', async () => {
|
|
99
|
+
const app = createApp({ logErrors: false })
|
|
100
|
+
app.get('/boom', () => {
|
|
101
|
+
throw new Error('boom')
|
|
102
|
+
})
|
|
103
|
+
app.onError((err, c) => c.json({ error: err.message }, 500))
|
|
104
|
+
|
|
105
|
+
await app.request('/boom')
|
|
106
|
+
expect(mockLog.error).not.toHaveBeenCalled()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('should include duration in response log', async () => {
|
|
110
|
+
const app = createApp()
|
|
111
|
+
await app.request('/test')
|
|
112
|
+
const completedCall = mockLog.info.mock.calls.find(c => c[1] === 'Request completed')
|
|
113
|
+
expect(completedCall).toBeDefined()
|
|
114
|
+
const fields = completedCall![0] as Record<string, unknown>
|
|
115
|
+
expect(fields.duration).toBeDefined()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
import {
|
|
4
|
+
rateLimitMiddleware,
|
|
5
|
+
strictRateLimitMiddleware,
|
|
6
|
+
mediumRateLimitMiddleware,
|
|
7
|
+
standardRateLimitMiddleware,
|
|
8
|
+
} from '../rate-limit'
|
|
9
|
+
|
|
10
|
+
describe('rateLimitMiddleware', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.clearAllMocks()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
function createApp(options?: Parameters<typeof rateLimitMiddleware>[0]) {
|
|
16
|
+
const app = new Hono()
|
|
17
|
+
app.use('*', rateLimitMiddleware(options))
|
|
18
|
+
app.get('/test', c => c.json({ ok: true }))
|
|
19
|
+
return app
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
it('should pass through in test environment', async () => {
|
|
23
|
+
const app = createApp()
|
|
24
|
+
const res = await app.request('/test')
|
|
25
|
+
expect(res.status).toBe(200)
|
|
26
|
+
const json = (await res.json()) as { ok: boolean }
|
|
27
|
+
expect(json.ok).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should return response with custom options', async () => {
|
|
31
|
+
const app = createApp({ windowMs: 1000, max: 5, message: 'Too many' })
|
|
32
|
+
const res = await app.request('/test')
|
|
33
|
+
expect(res.status).toBe(200)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should export preset middlewares', () => {
|
|
37
|
+
expect(typeof strictRateLimitMiddleware).toBe('function')
|
|
38
|
+
expect(typeof mediumRateLimitMiddleware).toBe('function')
|
|
39
|
+
expect(typeof standardRateLimitMiddleware).toBe('function')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('strictRateLimitMiddleware should pass through in test env', async () => {
|
|
43
|
+
const app = new Hono()
|
|
44
|
+
app.use('*', strictRateLimitMiddleware)
|
|
45
|
+
app.get('/test', c => c.json({ ok: true }))
|
|
46
|
+
const res = await app.request('/test')
|
|
47
|
+
expect(res.status).toBe(200)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('mediumRateLimitMiddleware should pass through in test env', async () => {
|
|
51
|
+
const app = new Hono()
|
|
52
|
+
app.use('*', mediumRateLimitMiddleware)
|
|
53
|
+
app.get('/test', c => c.json({ ok: true }))
|
|
54
|
+
const res = await app.request('/test')
|
|
55
|
+
expect(res.status).toBe(200)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('standardRateLimitMiddleware should pass through in test env', async () => {
|
|
59
|
+
const app = new Hono()
|
|
60
|
+
app.use('*', standardRateLimitMiddleware)
|
|
61
|
+
app.get('/test', c => c.json({ ok: true }))
|
|
62
|
+
const res = await app.request('/test')
|
|
63
|
+
expect(res.status).toBe(200)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should pass through multiple requests in test env', async () => {
|
|
67
|
+
const app = createApp({ windowMs: 1000, max: 1 })
|
|
68
|
+
for (let i = 0; i < 10; i++) {
|
|
69
|
+
const res = await app.request('/test')
|
|
70
|
+
expect(res.status).toBe(200)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
})
|