fake-repo.js
4.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
/**
* createFakeUserRepo —— 全内存 UserRepo 实现,测试用(证明所有接缝可替换)。
*
* 与真实 repo.js 行为对齐:会话族 / refresh hash / 防爆破计数 / 验证码 hash 全在内存 Map,
* 无网络无 DB 即可跑通 register / login / oauth / refresh / code 全流程。
*/
/**
* @param {Object} [seed]
* @param {Array<import('./index.js').User>} [seed.users]
* @returns {import('./index.js').UserRepo}
*/
export function createFakeUserRepo(seed = {}) {
/** @type {Map<string, import('./index.js').User>} */
const usersById = new Map()
/** @type {Map<string, import('./index.js').Session>} */
const sessions = new Map()
/** @type {Array<{ key: string, ip?: string, at: Date }>} */
const attempts = []
/** @type {Map<string, import('./index.js').CodeRecord>} */
const codes = new Map()
for (const u of seed.users ?? []) {
usersById.set(u.id, { ...u, oauth_providers: u.oauth_providers ?? [] })
}
/** @param {string} e */
const norm = (e) => String(e ?? '').trim().toLowerCase()
/** @param {string} email @param {string} purpose */
const codeKey = (email, purpose) => `${norm(email)}|${purpose}`
let idSeq = usersById.size
/** @param {string} email */
function findUserByEmail(email) {
const e = norm(email)
for (const u of usersById.values()) if (norm(u.email) === e) return u
return null
}
return {
async findByEmail(email) {
const u = findUserByEmail(email)
return u ? clone(u) : null
},
async findById(id) {
const u = usersById.get(id)
return u ? clone(u) : null
},
async create({ email, password_hash, oauth_providers, locale }) {
idSeq += 1
const id = `u${idSeq}`
const user = {
id,
email: norm(email),
password_hash,
locale,
oauth_providers: oauth_providers ?? [],
}
usersById.set(id, user)
return clone(user)
},
async linkOauth({ user_id, provider, provider_user_id }) {
const u = usersById.get(user_id)
if (!u) throw new Error('user not found')
u.oauth_providers = u.oauth_providers ?? []
const dup = u.oauth_providers.some(
(p) => p.provider === provider && p.provider_user_id === provider_user_id,
)
if (!dup) u.oauth_providers.push({ provider, provider_user_id })
return clone(u)
},
async updatePasswordHash({ user_id, password_hash }) {
const u = usersById.get(user_id)
if (!u) throw new Error('user not found')
u.password_hash = password_hash
},
async saveSession({ user_id, sid, refresh_hash, family, expires_at }) {
sessions.set(sid, { sid, user_id, refresh_hash, family, expires_at, used: false, revoked: false })
},
async findSession({ sid }) {
const s = sessions.get(sid)
return s ? { ...s } : null
},
async markRefreshUsed({ sid }) {
const s = sessions.get(sid)
if (s) s.used = true
},
async revokeFamily({ family }) {
for (const s of sessions.values()) if (s.family === family) s.revoked = true
},
async revokeUserSessions({ user_id }) {
for (const s of sessions.values()) if (s.user_id === user_id) s.revoked = true
},
async recordAttempt({ key, ip, at }) {
attempts.push({ key, ip, at })
},
async countRecentAttempts({ key, windowMs }) {
const since = Date.now() - windowMs
return attempts.filter((a) => a.key === key && a.at.getTime() >= since).length
},
async saveCode({ email, purpose, code_hash, expires_at }) {
codes.set(codeKey(email, purpose), {
email: norm(email),
purpose,
code_hash,
expires_at,
attempts: 0,
})
},
async consumeCode({ email, purpose }) {
const rec = codes.get(codeKey(email, purpose))
if (!rec) return null
const snapshot = { ...rec }
rec.attempts += 1
return snapshot
},
}
}
/** @param {any} o */
function clone(o) {
// structuredClone 保留 Date(时间字段与真实 repo 一致返回 Date),JSON round-trip 会把它变字符串。
return structuredClone(o)
}