@gravito/satellite-membership 0.1.1
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/.dockerignore +8 -0
- package/.env.example +19 -0
- package/ARCHITECTURE.md +14 -0
- package/CHANGELOG.md +14 -0
- package/Dockerfile +25 -0
- package/README.md +112 -0
- package/WHITEPAPER.md +20 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +1121 -0
- package/docs/EXTENDING.md +99 -0
- package/docs/PASSKEYS.md +78 -0
- package/locales/en.json +30 -0
- package/locales/zh-TW.json +30 -0
- package/package.json +35 -0
- package/src/Application/DTOs/MemberDTO.ts +34 -0
- package/src/Application/Mail/ForgotPasswordMail.ts +42 -0
- package/src/Application/Mail/MemberLevelChangedMail.ts +41 -0
- package/src/Application/Mail/WelcomeMail.ts +45 -0
- package/src/Application/Services/PasskeysService.ts +198 -0
- package/src/Application/UseCases/ForgotPassword.ts +34 -0
- package/src/Application/UseCases/LoginMember.ts +64 -0
- package/src/Application/UseCases/RegisterMember.ts +65 -0
- package/src/Application/UseCases/ResetPassword.ts +30 -0
- package/src/Application/UseCases/UpdateMemberLevel.ts +47 -0
- package/src/Application/UseCases/UpdateSettings.ts +81 -0
- package/src/Application/UseCases/VerifyEmail.ts +23 -0
- package/src/Domain/Contracts/IMemberPasskeyRepository.ts +8 -0
- package/src/Domain/Contracts/IMemberRepository.ts +11 -0
- package/src/Domain/Entities/Member.ts +219 -0
- package/src/Domain/Entities/MemberPasskey.ts +97 -0
- package/src/Infrastructure/Auth/SentinelMemberProvider.ts +52 -0
- package/src/Infrastructure/Persistence/AtlasMemberPasskeyRepository.ts +63 -0
- package/src/Infrastructure/Persistence/AtlasMemberRepository.ts +91 -0
- package/src/Infrastructure/Persistence/Migrations/20250101_create_members_table.ts +30 -0
- package/src/Infrastructure/Persistence/Migrations/20250102_create_member_passkeys_table.ts +25 -0
- package/src/Interface/Http/Controllers/PasskeyController.ts +98 -0
- package/src/Interface/Http/Middleware/VerifySingleDevice.ts +51 -0
- package/src/index.ts +234 -0
- package/src/manifest.json +15 -0
- package/tests/PasskeysService.test.ts +113 -0
- package/tests/email-integration.test.ts +161 -0
- package/tests/grand-review.ts +176 -0
- package/tests/integration.test.ts +75 -0
- package/tests/member.test.ts +47 -0
- package/tests/unit.test.ts +7 -0
- package/tests/update-settings.test.ts +101 -0
- package/tsconfig.json +26 -0
- package/views/emails/level_changed.html +35 -0
- package/views/emails/reset_password.html +34 -0
- package/views/emails/welcome.html +31 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import type { PasskeysConfig } from '../src/Application/Services/PasskeysService'
|
|
3
|
+
import { PasskeysService } from '../src/Application/Services/PasskeysService'
|
|
4
|
+
import type { IMemberPasskeyRepository } from '../src/Domain/Contracts/IMemberPasskeyRepository'
|
|
5
|
+
import { Member } from '../src/Domain/Entities/Member'
|
|
6
|
+
import { MemberPasskey } from '../src/Domain/Entities/MemberPasskey'
|
|
7
|
+
|
|
8
|
+
class InMemoryPasskeyRepository implements IMemberPasskeyRepository {
|
|
9
|
+
private store = new Map<string, MemberPasskey>()
|
|
10
|
+
|
|
11
|
+
async save(entity: MemberPasskey): Promise<void> {
|
|
12
|
+
this.store.set(entity.id, entity)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async findById(id: string): Promise<MemberPasskey | null> {
|
|
16
|
+
return this.store.get(id) ?? null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async findAll(): Promise<MemberPasskey[]> {
|
|
20
|
+
return Array.from(this.store.values())
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async delete(id: string): Promise<void> {
|
|
24
|
+
this.store.delete(id)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async exists(id: string): Promise<boolean> {
|
|
28
|
+
return this.store.has(id)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async findByMemberId(memberId: string): Promise<MemberPasskey[]> {
|
|
32
|
+
return Array.from(this.store.values()).filter((entry) => entry.memberId === memberId)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async findByCredentialId(credentialId: string): Promise<MemberPasskey | null> {
|
|
36
|
+
return (
|
|
37
|
+
Array.from(this.store.values()).find((entry) => entry.credentialId === credentialId) ?? null
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async deleteByCredentialId(credentialId: string): Promise<void> {
|
|
42
|
+
const entry = await this.findByCredentialId(credentialId)
|
|
43
|
+
if (entry) {
|
|
44
|
+
this.store.delete(entry.id)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createSession() {
|
|
50
|
+
const store = new Map<string, string>()
|
|
51
|
+
return {
|
|
52
|
+
get(key: string) {
|
|
53
|
+
return store.get(key)
|
|
54
|
+
},
|
|
55
|
+
put(key: string, value: string) {
|
|
56
|
+
store.set(key, value)
|
|
57
|
+
},
|
|
58
|
+
forget(key: string) {
|
|
59
|
+
store.delete(key)
|
|
60
|
+
},
|
|
61
|
+
__store: store,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const config: PasskeysConfig = {
|
|
66
|
+
rpName: 'Test Membership',
|
|
67
|
+
rpID: 'localhost',
|
|
68
|
+
origin: 'http://localhost:3000',
|
|
69
|
+
timeout: 45000,
|
|
70
|
+
userVerification: 'preferred' as const,
|
|
71
|
+
attestationType: 'none' as const,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createService() {
|
|
75
|
+
return new PasskeysService(new InMemoryPasskeyRepository(), config)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('PasskeysService', () => {
|
|
79
|
+
it('stores the generated registration challenge in the session', async () => {
|
|
80
|
+
const member = Member.create('member-1', 'Test User', 'test@example.com', 'hash')
|
|
81
|
+
const session = createSession()
|
|
82
|
+
const service = createService()
|
|
83
|
+
|
|
84
|
+
const options = await service.generateRegistrationOptions(member, session)
|
|
85
|
+
expect(options.challenge).toBeTruthy()
|
|
86
|
+
expect(session.__store.get('membership.passkeys.registration.challenge')).toBe(
|
|
87
|
+
options.challenge
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('includes stored credentials when generating authentication options', async () => {
|
|
92
|
+
const member = Member.create('member-2', 'Verifier', 'verify@example.com', 'hash')
|
|
93
|
+
const passkeyRepo = new InMemoryPasskeyRepository()
|
|
94
|
+
const service = new PasskeysService(passkeyRepo, config)
|
|
95
|
+
const credential = MemberPasskey.create({
|
|
96
|
+
memberId: member.id,
|
|
97
|
+
credentialId: Buffer.from('credential-123').toString('base64url'),
|
|
98
|
+
publicKey: Buffer.from('public-key').toString('base64'),
|
|
99
|
+
counter: 0,
|
|
100
|
+
transports: ['usb'],
|
|
101
|
+
})
|
|
102
|
+
await passkeyRepo.save(credential)
|
|
103
|
+
|
|
104
|
+
const session = createSession()
|
|
105
|
+
const options = await service.generateAuthenticationOptions(member, session)
|
|
106
|
+
|
|
107
|
+
expect(options.allowCredentials?.length).toBeGreaterThan(0)
|
|
108
|
+
expect(session.__store.get('membership.passkeys.authentication.challenge')).toBe(
|
|
109
|
+
options.challenge
|
|
110
|
+
)
|
|
111
|
+
expect(options.allowCredentials?.[0].id).toBe(credential.credentialId)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from 'bun:test'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { DB, Schema } from '@gravito/atlas'
|
|
4
|
+
import { DevMailbox, MemoryTransport, OrbitSignal } from '@gravito/signal'
|
|
5
|
+
import { RegisterMember } from '../src/Application/UseCases/RegisterMember'
|
|
6
|
+
import { AtlasMemberRepository } from '../src/Infrastructure/Persistence/AtlasMemberRepository'
|
|
7
|
+
|
|
8
|
+
describe('Membership Email Integration', () => {
|
|
9
|
+
const repo = new AtlasMemberRepository()
|
|
10
|
+
const mailbox = new DevMailbox()
|
|
11
|
+
const transport = new MemoryTransport(mailbox)
|
|
12
|
+
|
|
13
|
+
// Mock Core with Signal
|
|
14
|
+
const mockCore: any = {
|
|
15
|
+
hasher: {
|
|
16
|
+
make: async (_val: string) => `hashed_\${val}`,
|
|
17
|
+
check: async (_plain: string, hash: string) => `hashed_\${plain}` === hash,
|
|
18
|
+
},
|
|
19
|
+
hooks: {
|
|
20
|
+
actions: {} as Record<string, Function[]>,
|
|
21
|
+
addAction(name: string, cb: Function) {
|
|
22
|
+
this.actions[name] = this.actions[name] || []
|
|
23
|
+
this.actions[name].push(cb)
|
|
24
|
+
},
|
|
25
|
+
async doAction(name: string, data: any) {
|
|
26
|
+
if (this.actions[name]) {
|
|
27
|
+
for (const cb of this.actions[name]) {
|
|
28
|
+
await cb(data)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
container: {
|
|
34
|
+
bindings: new Map(),
|
|
35
|
+
singleton(key: string, cb: Function) {
|
|
36
|
+
this.bindings.set(key, cb())
|
|
37
|
+
},
|
|
38
|
+
make(key: string) {
|
|
39
|
+
if (key === 'i18n') {
|
|
40
|
+
return { t: (k: string) => k }
|
|
41
|
+
}
|
|
42
|
+
return this.bindings.get(key)
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
logger: {
|
|
46
|
+
info: console.log,
|
|
47
|
+
error: console.error,
|
|
48
|
+
warn: console.warn,
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Setup Signal
|
|
53
|
+
const signal = new OrbitSignal({
|
|
54
|
+
transport,
|
|
55
|
+
from: { address: 'no-reply@gravito.dev', name: 'Gravito' },
|
|
56
|
+
viewsDir: path.resolve(import.meta.dir, '../views'),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// Setup UseCase
|
|
60
|
+
const registerUseCase = new RegisterMember(repo, mockCore)
|
|
61
|
+
|
|
62
|
+
beforeAll(async () => {
|
|
63
|
+
// 1. Database
|
|
64
|
+
DB.addConnection('default', { driver: 'sqlite', database: ':memory:' })
|
|
65
|
+
await Schema.create('members', (table) => {
|
|
66
|
+
table.string('id').primary()
|
|
67
|
+
table.string('name')
|
|
68
|
+
table.string('email').unique()
|
|
69
|
+
table.string('password_hash')
|
|
70
|
+
table.string('status')
|
|
71
|
+
table.text('roles')
|
|
72
|
+
table.string('verification_token')
|
|
73
|
+
table.timestamp('email_verified_at').nullable()
|
|
74
|
+
table.string('password_reset_token').nullable()
|
|
75
|
+
table.timestamp('password_reset_expires_at').nullable()
|
|
76
|
+
table.string('current_session_id').nullable()
|
|
77
|
+
table.string('remember_token').nullable()
|
|
78
|
+
table.timestamp('created_at').default('CURRENT_TIMESTAMP')
|
|
79
|
+
table.text('metadata').nullable()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// 2. Register Hook (Manually since we are not booting the full provider)
|
|
83
|
+
mockCore.hooks.addAction('membership:send-verification', async (data: any) => {
|
|
84
|
+
await signal.send(
|
|
85
|
+
new (await import('../src/Application/Mail/WelcomeMail')).WelcomeMail(
|
|
86
|
+
data.email,
|
|
87
|
+
data.token
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('註冊後應觸發發送驗證郵件且包含美化樣式', async () => {
|
|
94
|
+
const email = 'test@example.com'
|
|
95
|
+
await registerUseCase.execute({
|
|
96
|
+
name: 'Test User',
|
|
97
|
+
email,
|
|
98
|
+
passwordPlain: 'password',
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const messages = mailbox.list()
|
|
102
|
+
expect(messages.length).toBe(1)
|
|
103
|
+
expect(messages[0].envelope.subject).toBe('membership.emails.welcome_subject')
|
|
104
|
+
// 驗證是否包含 CSS 樣式標籤 (代表模板已渲染)
|
|
105
|
+
expect(messages[0].html).toContain('<style>')
|
|
106
|
+
expect(messages[0].html).toContain('Gravito App')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('觸發忘記密碼應發送重設郵件', async () => {
|
|
110
|
+
// 1. 註冊一個 Hook (模擬 ServiceProvider)
|
|
111
|
+
mockCore.hooks.addAction('membership:send-reset-password', async (data: any) => {
|
|
112
|
+
await signal.send(
|
|
113
|
+
new (await import('../src/Application/Mail/ForgotPasswordMail')).ForgotPasswordMail(
|
|
114
|
+
data.email,
|
|
115
|
+
data.token
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
mailbox.clear()
|
|
121
|
+
|
|
122
|
+
// 2. 模擬觸發 Hook
|
|
123
|
+
await mockCore.hooks.doAction('membership:send-reset-password', {
|
|
124
|
+
email: 'reset@example.com',
|
|
125
|
+
token: 'secret-token',
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const messages = mailbox.list()
|
|
129
|
+
expect(messages.length).toBe(1)
|
|
130
|
+
expect(messages[0].envelope.subject).toBe('membership.emails.reset_password_subject')
|
|
131
|
+
expect(messages[0].html).toContain('secret-token')
|
|
132
|
+
expect(messages[0].html).toContain('reset_password_button')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('等級變更後應發送晉級通知郵件', async () => {
|
|
136
|
+
mockCore.hooks.addAction('membership:level-changed', async (data: any) => {
|
|
137
|
+
await signal.send(
|
|
138
|
+
new (await import('../src/Application/Mail/MemberLevelChangedMail')).MemberLevelChangedMail(
|
|
139
|
+
data.email,
|
|
140
|
+
data.oldLevel,
|
|
141
|
+
data.newLevel
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
mailbox.clear()
|
|
147
|
+
|
|
148
|
+
await mockCore.hooks.doAction('membership:level-changed', {
|
|
149
|
+
email: 'vip@example.com',
|
|
150
|
+
oldLevel: 'Silver',
|
|
151
|
+
newLevel: 'Gold',
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const messages = mailbox.list()
|
|
155
|
+
expect(messages.length).toBe(1)
|
|
156
|
+
expect(messages[0].envelope.subject).toBe('membership.emails.level_changed_subject')
|
|
157
|
+
expect(messages[0].html).toContain('Silver')
|
|
158
|
+
expect(messages[0].html).toContain('Gold')
|
|
159
|
+
expect(messages[0].html).toContain('level_changed_badge')
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { DB, Schema } from '@gravito/atlas'
|
|
3
|
+
import { PlanetCore, setApp } from '@gravito/core'
|
|
4
|
+
import { OrbitSignal } from '@gravito/signal'
|
|
5
|
+
import type { LoginMember } from '../src/Application/UseCases/LoginMember'
|
|
6
|
+
import type { RegisterMember } from '../src/Application/UseCases/RegisterMember'
|
|
7
|
+
import { verifySingleDevice } from '../src/Interface/Http/Middleware/VerifySingleDevice'
|
|
8
|
+
import { MembershipServiceProvider } from '../src/index'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 🛰️ Gravito Membership "Grand Review" (大校閱)
|
|
12
|
+
*
|
|
13
|
+
* 此腳本模擬全系統在 Launchpad 環境下的運行狀況
|
|
14
|
+
*/
|
|
15
|
+
async function grandReview() {
|
|
16
|
+
console.log('\n🚀 [Grand Review] 啟動全系統校閱流程...')
|
|
17
|
+
|
|
18
|
+
// 1. 初始化核心與軌道
|
|
19
|
+
const core = await PlanetCore.boot({
|
|
20
|
+
config: {
|
|
21
|
+
APP_NAME: 'Membership Review',
|
|
22
|
+
PORT: 3001,
|
|
23
|
+
'membership.auth.single_device': true, // 開啟多設備限制
|
|
24
|
+
'membership.branding.name': 'Review Admiral', // 自定義品牌
|
|
25
|
+
'membership.branding.primary_color': '#10b981', // 自定義顏色 (綠色)
|
|
26
|
+
'app.url': 'https://review.local',
|
|
27
|
+
'database.default': 'sqlite',
|
|
28
|
+
'database.connections.sqlite': {
|
|
29
|
+
driver: 'sqlite',
|
|
30
|
+
database: ':memory:',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
orbits: [
|
|
34
|
+
new OrbitSignal({
|
|
35
|
+
devMode: true,
|
|
36
|
+
from: { address: 'system@gravito.dev', name: 'Gravito Core' },
|
|
37
|
+
viewsDir: path.resolve(import.meta.dir, '../views'),
|
|
38
|
+
}),
|
|
39
|
+
],
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// 1.2 強制設置全局 app 實例,供 Mailable 內部使用
|
|
43
|
+
setApp(core)
|
|
44
|
+
|
|
45
|
+
// 1.5 初始化 Atlas
|
|
46
|
+
DB.addConnection('default', {
|
|
47
|
+
driver: 'sqlite',
|
|
48
|
+
database: ':memory:',
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// 2. 註冊服務
|
|
52
|
+
core.container.instance('i18n', {
|
|
53
|
+
t: (k: string) => k,
|
|
54
|
+
addResource: () => {},
|
|
55
|
+
on: () => {},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// 取得真正的 Repository
|
|
59
|
+
const realRepo = new (
|
|
60
|
+
await import('../src/Infrastructure/Persistence/AtlasMemberRepository')
|
|
61
|
+
).AtlasMemberRepository()
|
|
62
|
+
core.container.instance('membership.repo', realRepo)
|
|
63
|
+
|
|
64
|
+
// Mock Auth (Sentinel)
|
|
65
|
+
const mockAuth = {
|
|
66
|
+
guard: () => ({
|
|
67
|
+
attempt: async () => true,
|
|
68
|
+
user: async () => {
|
|
69
|
+
// 從 Repo 抓出剛才註冊的人
|
|
70
|
+
const members = await realRepo.findAll()
|
|
71
|
+
return members[0]
|
|
72
|
+
},
|
|
73
|
+
logout: async () => {},
|
|
74
|
+
}),
|
|
75
|
+
}
|
|
76
|
+
core.container.instance('auth', mockAuth)
|
|
77
|
+
|
|
78
|
+
await core.use(new MembershipServiceProvider())
|
|
79
|
+
await core.bootstrap()
|
|
80
|
+
|
|
81
|
+
console.log('✅ [System] 核心與衛星模組已就緒。')
|
|
82
|
+
|
|
83
|
+
// 3. 準備資料庫 (執行遷移)
|
|
84
|
+
console.log('📦 [Database] 正在建立會員資料表...')
|
|
85
|
+
await Schema.create('members', (table) => {
|
|
86
|
+
table.string('id').primary()
|
|
87
|
+
table.string('name')
|
|
88
|
+
table.string('email').unique()
|
|
89
|
+
table.string('password_hash')
|
|
90
|
+
table.string('status').default('pending')
|
|
91
|
+
table.text('roles').default('["member"]')
|
|
92
|
+
table.string('verification_token').nullable()
|
|
93
|
+
table.timestamp('email_verified_at').nullable()
|
|
94
|
+
table.string('password_reset_token').nullable()
|
|
95
|
+
table.timestamp('password_reset_expires_at').nullable()
|
|
96
|
+
table.string('current_session_id').nullable()
|
|
97
|
+
table.string('remember_token').nullable()
|
|
98
|
+
table.timestamp('created_at').default('CURRENT_TIMESTAMP')
|
|
99
|
+
table.timestamp('updated_at').nullable()
|
|
100
|
+
table.text('metadata').nullable()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const _repo = core.container.make<any>('membership.repo')
|
|
104
|
+
const register = core.container.make<RegisterMember>('membership.register')
|
|
105
|
+
const login = core.container.make<LoginMember>('membership.login')
|
|
106
|
+
|
|
107
|
+
// --- 測試案例 A: 註冊與郵件發送 ---
|
|
108
|
+
console.log('\n🧪 [Test A] 模擬新會員註冊...')
|
|
109
|
+
const email = 'commander@gravito.dev'
|
|
110
|
+
await register.execute({
|
|
111
|
+
name: 'Gravito Commander',
|
|
112
|
+
email: email,
|
|
113
|
+
passwordPlain: 'mission-critical-123',
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
console.log('📬 [Signal] 請檢查上方日誌,應包含美化後的 Welcome Mail HTML。')
|
|
117
|
+
|
|
118
|
+
// --- 測試案例 B: 多設備限制 ---
|
|
119
|
+
console.log('\n🧪 [Test B] 模擬多設備登入限制...')
|
|
120
|
+
|
|
121
|
+
// 模擬 Session A
|
|
122
|
+
const mockSessionA = {
|
|
123
|
+
id: () => 'session_device_1',
|
|
124
|
+
get: (k: string) => (k === 'login_web_auth_session' ? email : null),
|
|
125
|
+
put: () => {},
|
|
126
|
+
regenerate: () => {},
|
|
127
|
+
}
|
|
128
|
+
core.container.instance('session', mockSessionA)
|
|
129
|
+
|
|
130
|
+
console.log('📱 設備 1 正在登入...')
|
|
131
|
+
await login.execute({ email, passwordPlain: 'mission-critical-123' })
|
|
132
|
+
|
|
133
|
+
// 模擬 Session B (另一個設備)
|
|
134
|
+
const mockSessionB = {
|
|
135
|
+
id: () => 'session_device_2',
|
|
136
|
+
get: (k: string) => (k === 'login_web_auth_session' ? email : null),
|
|
137
|
+
put: () => {},
|
|
138
|
+
regenerate: () => {},
|
|
139
|
+
}
|
|
140
|
+
core.container.instance('session', mockSessionB)
|
|
141
|
+
|
|
142
|
+
console.log('💻 設備 2 (新設備) 正在登入...')
|
|
143
|
+
await login.execute({ email, passwordPlain: 'mission-critical-123' })
|
|
144
|
+
|
|
145
|
+
// 模擬設備 1 的後續請求,應被攔截
|
|
146
|
+
console.log('🛡️ 驗證設備 1 是否被強制登出...')
|
|
147
|
+
core.container.instance('session', mockSessionA) // 切換回設備 1 的環境
|
|
148
|
+
|
|
149
|
+
// 建立模擬 Context
|
|
150
|
+
const mockContext: any = {
|
|
151
|
+
get: (key: string) => {
|
|
152
|
+
if (key === 'core') {
|
|
153
|
+
return core
|
|
154
|
+
}
|
|
155
|
+
return null
|
|
156
|
+
},
|
|
157
|
+
req: { header: () => 'application/json' },
|
|
158
|
+
json: (d: any) => d,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
await verifySingleDevice(mockContext, async () => {
|
|
163
|
+
console.log('❌ [Fail] 設備 1 居然還能訪問!')
|
|
164
|
+
})
|
|
165
|
+
} catch (err: any) {
|
|
166
|
+
console.log(`✅ [Pass] 設備 1 被攔截,錯誤訊息: [31m${err.message}[0m`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
console.log('\n🏁 [Grand Review] 校閱完成!所有系統運作正常。')
|
|
170
|
+
process.exit(0)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
grandReview().catch((err) => {
|
|
174
|
+
console.error('💥 校閱過程中發生崩潰:', err)
|
|
175
|
+
process.exit(1)
|
|
176
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { DB, Schema } from '@gravito/atlas'
|
|
3
|
+
import { RegisterMember } from '../src/Application/UseCases/RegisterMember'
|
|
4
|
+
import { AtlasMemberRepository } from '../src/Infrastructure/Persistence/AtlasMemberRepository'
|
|
5
|
+
|
|
6
|
+
describe('Membership Satellite Integration', () => {
|
|
7
|
+
const repo = new AtlasMemberRepository()
|
|
8
|
+
|
|
9
|
+
// Mock Core
|
|
10
|
+
const mockCore: any = {
|
|
11
|
+
hasher: {
|
|
12
|
+
make: async (val: string) => `hashed_${val}`,
|
|
13
|
+
check: async (plain: string, hash: string) => `hashed_${plain}` === hash,
|
|
14
|
+
},
|
|
15
|
+
hooks: {
|
|
16
|
+
doAction: async () => {},
|
|
17
|
+
},
|
|
18
|
+
container: {
|
|
19
|
+
make: (key: string) => {
|
|
20
|
+
if (key === 'i18n') {
|
|
21
|
+
return { t: (k: string) => k }
|
|
22
|
+
}
|
|
23
|
+
return null
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const registerUseCase = new RegisterMember(repo, mockCore)
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
// 1. 配置 SQLite 記憶體資料庫
|
|
32
|
+
DB.addConnection('default', {
|
|
33
|
+
driver: 'sqlite',
|
|
34
|
+
database: ':memory:',
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// 2. 建立測試用的資料表
|
|
38
|
+
await Schema.dropIfExists('members')
|
|
39
|
+
await Schema.create('members', (table) => {
|
|
40
|
+
table.string('id').primary()
|
|
41
|
+
table.string('name')
|
|
42
|
+
table.string('email').unique()
|
|
43
|
+
table.string('password_hash')
|
|
44
|
+
table.string('status')
|
|
45
|
+
table.text('roles').default('["member"]')
|
|
46
|
+
table.string('verification_token').nullable()
|
|
47
|
+
table.timestamp('email_verified_at').nullable()
|
|
48
|
+
table.string('password_reset_token').nullable()
|
|
49
|
+
table.timestamp('password_reset_expires_at').nullable()
|
|
50
|
+
table.string('current_session_id').nullable()
|
|
51
|
+
table.string('remember_token').nullable()
|
|
52
|
+
table.timestamp('created_at').default('CURRENT_TIMESTAMP')
|
|
53
|
+
table.timestamp('updated_at').nullable()
|
|
54
|
+
table.text('metadata').nullable()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('應該能成功註冊新會員', async () => {
|
|
59
|
+
const input = {
|
|
60
|
+
name: 'Carl',
|
|
61
|
+
email: `carl-${Date.now()}@example.com`,
|
|
62
|
+
passwordPlain: 'secret123',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result = await registerUseCase.execute(input)
|
|
66
|
+
|
|
67
|
+
expect(result.id).toBeDefined()
|
|
68
|
+
expect(result.email).toBe(input.email)
|
|
69
|
+
|
|
70
|
+
// 驗證是否真的存入 Repository
|
|
71
|
+
const savedMember = await repo.findById(result.id)
|
|
72
|
+
expect(savedMember).not.toBeNull()
|
|
73
|
+
expect(savedMember?.name).toBe(input.name)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { Member, MemberStatus } from '../src/Domain/Entities/Member'
|
|
3
|
+
|
|
4
|
+
describe('Member Domain Entity', () => {
|
|
5
|
+
it('應該能正確建立新會員實體且預設狀態為 PENDING', () => {
|
|
6
|
+
const member = Member.create('user-1', 'Carl', 'carl@example.com', 'hash-123')
|
|
7
|
+
|
|
8
|
+
expect(member.id).toBe('user-1')
|
|
9
|
+
expect(member.name).toBe('Carl')
|
|
10
|
+
expect(member.email).toBe('carl@example.com')
|
|
11
|
+
expect(member.status).toBe(MemberStatus.PENDING)
|
|
12
|
+
expect(member.createdAt).toBeInstanceOf(Date)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('執行 verifyEmail() 後狀態應該變更為 ACTIVE', () => {
|
|
16
|
+
const member = Member.create('u1', 'N', 'e', 'h')
|
|
17
|
+
|
|
18
|
+
member.verifyEmail()
|
|
19
|
+
|
|
20
|
+
expect(member.status).toBe(MemberStatus.ACTIVE)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('應該支援 metadata 的存取與擴充', () => {
|
|
24
|
+
// 模擬從資料庫重建包含 metadata 的實體
|
|
25
|
+
const member = Member.reconstitute('u1', {
|
|
26
|
+
name: 'Carl',
|
|
27
|
+
email: 'c',
|
|
28
|
+
passwordHash: 'h',
|
|
29
|
+
status: MemberStatus.ACTIVE,
|
|
30
|
+
createdAt: new Date(),
|
|
31
|
+
updatedAt: new Date(),
|
|
32
|
+
metadata: {
|
|
33
|
+
vat_number: '12345678',
|
|
34
|
+
level: 'gold',
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(member.metadata.vat_number).toBe('12345678')
|
|
39
|
+
expect(member.metadata.level).toBe('gold')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('當無 metadata 時應該回傳空物件而非 undefined', () => {
|
|
43
|
+
const member = Member.create('u1', 'N', 'e', 'h')
|
|
44
|
+
expect(member.metadata).toBeDefined()
|
|
45
|
+
expect(Object.keys(member.metadata).length).toBe(0)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { DB, Schema } from '@gravito/atlas'
|
|
3
|
+
import { UpdateSettings } from '../src/Application/UseCases/UpdateSettings'
|
|
4
|
+
import { Member, MemberStatus } from '../src/Domain/Entities/Member'
|
|
5
|
+
import { AtlasMemberRepository } from '../src/Infrastructure/Persistence/AtlasMemberRepository'
|
|
6
|
+
|
|
7
|
+
describe('Membership UpdateSettings Integration', () => {
|
|
8
|
+
const repo = new AtlasMemberRepository()
|
|
9
|
+
let updateUseCase: UpdateSettings
|
|
10
|
+
|
|
11
|
+
// Mock Core with Hasher
|
|
12
|
+
const mockCore: any = {
|
|
13
|
+
hasher: {
|
|
14
|
+
make: async (val: string) => `hashed_${val}`,
|
|
15
|
+
check: async (plain: string, hash: string) => `hashed_${plain}` === hash,
|
|
16
|
+
},
|
|
17
|
+
hooks: {
|
|
18
|
+
doAction: async () => {},
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
DB.addConnection('default', { driver: 'sqlite', database: ':memory:' })
|
|
24
|
+
await Schema.dropIfExists('members')
|
|
25
|
+
await Schema.create('members', (table) => {
|
|
26
|
+
table.string('id').primary()
|
|
27
|
+
table.string('name')
|
|
28
|
+
table.string('email').unique()
|
|
29
|
+
table.string('password_hash')
|
|
30
|
+
table.string('status')
|
|
31
|
+
table.text('roles').default('["member"]')
|
|
32
|
+
table.string('verification_token').nullable()
|
|
33
|
+
table.timestamp('email_verified_at').nullable()
|
|
34
|
+
table.string('password_reset_token').nullable()
|
|
35
|
+
table.timestamp('password_reset_expires_at').nullable()
|
|
36
|
+
table.string('current_session_id').nullable()
|
|
37
|
+
table.string('remember_token').nullable()
|
|
38
|
+
table.timestamp('created_at').default('CURRENT_TIMESTAMP')
|
|
39
|
+
table.timestamp('updated_at').nullable()
|
|
40
|
+
table.text('metadata').nullable()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
updateUseCase = new UpdateSettings(repo, mockCore)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should update profile and merge metadata', async () => {
|
|
47
|
+
// 1. Create initial member
|
|
48
|
+
const initialHash = await mockCore.hasher.make('old-pass')
|
|
49
|
+
const member = Member.reconstitute('u1', {
|
|
50
|
+
name: 'Old Name',
|
|
51
|
+
email: 'test@test.com',
|
|
52
|
+
passwordHash: initialHash,
|
|
53
|
+
status: MemberStatus.ACTIVE,
|
|
54
|
+
roles: ['member'],
|
|
55
|
+
createdAt: new Date(),
|
|
56
|
+
updatedAt: new Date(),
|
|
57
|
+
metadata: { initial: 'data' },
|
|
58
|
+
})
|
|
59
|
+
await repo.save(member)
|
|
60
|
+
|
|
61
|
+
// 2. Perform update
|
|
62
|
+
const result = await updateUseCase.execute({
|
|
63
|
+
memberId: 'u1',
|
|
64
|
+
name: 'New Name',
|
|
65
|
+
metadata: { theme: 'dark' },
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
expect(result.name).toBe('New Name')
|
|
69
|
+
expect(result.metadata?.initial).toBe('data')
|
|
70
|
+
expect(result.metadata?.theme).toBe('dark')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should securely change password', async () => {
|
|
74
|
+
const pass = await mockCore.hasher.make('current-pass')
|
|
75
|
+
const member = Member.create('u2', 'N', 'e2@e.com', pass)
|
|
76
|
+
await repo.save(member)
|
|
77
|
+
|
|
78
|
+
// Should throw if current password wrong
|
|
79
|
+
try {
|
|
80
|
+
await updateUseCase.execute({
|
|
81
|
+
memberId: 'u2',
|
|
82
|
+
currentPassword: 'wrong',
|
|
83
|
+
newPassword: 'new',
|
|
84
|
+
})
|
|
85
|
+
expect(true).toBe(false) // Should not reach here
|
|
86
|
+
} catch (e: any) {
|
|
87
|
+
expect(e.message).toBe('Invalid current password')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Should succeed if correct
|
|
91
|
+
await updateUseCase.execute({
|
|
92
|
+
memberId: 'u2',
|
|
93
|
+
currentPassword: 'current-pass',
|
|
94
|
+
newPassword: 'secure-new-pass',
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const updated = await repo.findById('u2')
|
|
98
|
+
const isNewPassValid = await mockCore.hasher.check('secure-new-pass', updated?.passwordHash)
|
|
99
|
+
expect(isNewPassValid).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
})
|