feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
canBindOAuthIdentity,
|
||||
isAllowedDuringPasswordChange,
|
||||
isPublicApiPath,
|
||||
shouldBlockDuringPasswordChange,
|
||||
shouldBypassPublicApiAuthentication
|
||||
} from '../../server/utils/auth-route-policy.ts'
|
||||
|
||||
test('匿名用户可以访问公共接口', () => {
|
||||
assert.equal(isPublicApiPath('/api/songs/public', 'GET'), true)
|
||||
assert.equal(shouldBypassPublicApiAuthentication('/api/songs/public', 'GET', false), true)
|
||||
})
|
||||
|
||||
test('OAuth 注册选项接口对匿名请求放行(binding-token 由接口自身校验)', () => {
|
||||
assert.equal(isPublicApiPath('/api/auth/oauth-register-options', 'GET'), true)
|
||||
assert.equal(isPublicApiPath('/api/auth/oauth-register-options', 'POST'), false)
|
||||
})
|
||||
|
||||
test('携带登录态访问公共接口时必须经过认证检查', () => {
|
||||
assert.equal(shouldBypassPublicApiAuthentication('/api/songs/public', 'GET', true), false)
|
||||
assert.equal(shouldBypassPublicApiAuthentication('/api/site-config', 'GET', true), false)
|
||||
assert.equal(shouldBypassPublicApiAuthentication('/api/proxy/image', 'GET', true), false)
|
||||
})
|
||||
|
||||
test('受保护接口不能绕过认证', () => {
|
||||
assert.equal(shouldBypassPublicApiAuthentication('/api/admin/users', 'GET', false), false)
|
||||
assert.equal(shouldBypassPublicApiAuthentication('/api/admin/users', 'GET', true), false)
|
||||
})
|
||||
|
||||
test('强制改密期间只放行必要的认证接口', () => {
|
||||
assert.equal(isAllowedDuringPasswordChange('/api/auth/change-password', 'POST'), true)
|
||||
assert.equal(isAllowedDuringPasswordChange('/api/auth/set-initial-password', 'POST'), true)
|
||||
assert.equal(isAllowedDuringPasswordChange('/api/auth/logout', 'POST'), true)
|
||||
assert.equal(isAllowedDuringPasswordChange('/api/site-config', 'GET'), true)
|
||||
assert.equal(isAllowedDuringPasswordChange('/api/songs/public', 'GET'), false)
|
||||
assert.equal(isAllowedDuringPasswordChange('/api/admin/system-settings', 'GET'), false)
|
||||
})
|
||||
|
||||
test('公共 API 路径匹配不会接受相似但未注册的路径', () => {
|
||||
assert.equal(isPublicApiPath('/api/auth/loginX', 'POST'), false)
|
||||
assert.equal(isPublicApiPath('/api/site-config.evil', 'GET'), false)
|
||||
assert.equal(isPublicApiPath('/api/site-config/extra', 'GET'), false)
|
||||
assert.equal(isPublicApiPath('/api/auth/webauthn/login/options', 'POST'), true)
|
||||
assert.equal(isAllowedDuringPasswordChange('/api/auth/change-passwordX', 'POST'), false)
|
||||
})
|
||||
|
||||
test('公共 API 只放行已注册的 HTTP 方法', () => {
|
||||
assert.equal(isPublicApiPath('/api/auth/login', 'POST'), true)
|
||||
assert.equal(isPublicApiPath('/api/auth/login', 'GET'), false)
|
||||
assert.equal(isPublicApiPath('/api/site-config', 'GET'), true)
|
||||
assert.equal(isPublicApiPath('/api/site-config', 'POST'), false)
|
||||
assert.equal(isPublicApiPath('/api/music/state', 'POST'), true)
|
||||
assert.equal(isPublicApiPath('/api/music/state', 'GET'), false)
|
||||
})
|
||||
|
||||
test('强制改密门控放行公共 API,但阻止业务写入和 OAuth provider 路由', () => {
|
||||
assert.equal(shouldBlockDuringPasswordChange('/api/site-config', 'GET', true), false)
|
||||
assert.equal(shouldBlockDuringPasswordChange('/api/sys/time', 'GET', true), false)
|
||||
assert.equal(shouldBlockDuringPasswordChange('/api/music/state', 'POST', true), false)
|
||||
assert.equal(shouldBlockDuringPasswordChange('/api/auth/github', 'GET', true), true)
|
||||
assert.equal(shouldBlockDuringPasswordChange('/api/auth/github/callback', 'GET', true), true)
|
||||
assert.equal(shouldBlockDuringPasswordChange('/api/admin/users', 'GET', true), true)
|
||||
assert.equal(shouldBlockDuringPasswordChange('/api/admin/users', 'GET', false), false)
|
||||
})
|
||||
|
||||
test('强制改密状态禁止持久化 OAuth 身份绑定', () => {
|
||||
assert.equal(canBindOAuthIdentity(true), false)
|
||||
assert.equal(canBindOAuthIdentity(false), true)
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { resolveAvatarSource } from '../../server/utils/user-avatar.ts'
|
||||
|
||||
test('清除聚合登录绑定:准确识别聚合登录身份并排除其他提供商', () => {
|
||||
const identities = [
|
||||
{ id: 1, userId: 101, provider: 'aggregate:qq', providerUserId: 'qq_123' },
|
||||
{ id: 2, userId: 101, provider: 'aggregate:wx', providerUserId: 'wx_456' },
|
||||
{ id: 3, userId: 101, provider: 'github', providerUserId: 'gh_789' },
|
||||
{ id: 4, userId: 102, provider: 'aggregate', providerUserId: 'agg_999' },
|
||||
{ id: 5, userId: 102, provider: 'casdoor', providerUserId: 'cas_888' },
|
||||
{ id: 6, userId: 103, provider: 'google', providerUserId: 'goog_777' }
|
||||
]
|
||||
|
||||
const isAggregate = (provider: string) => provider === 'aggregate' || provider.startsWith('aggregate:')
|
||||
|
||||
const targetIdentities = identities.filter((i) => isAggregate(i.provider))
|
||||
const remainingIdentities = identities.filter((i) => !isAggregate(i.provider))
|
||||
|
||||
assert.equal(targetIdentities.length, 3)
|
||||
assert.deepEqual(
|
||||
targetIdentities.map((i) => i.id),
|
||||
[1, 2, 4]
|
||||
)
|
||||
|
||||
const affectedUserIds = Array.from(new Set(targetIdentities.map((i) => i.userId)))
|
||||
assert.deepEqual(affectedUserIds, [101, 102])
|
||||
|
||||
assert.equal(remainingIdentities.length, 3)
|
||||
assert.deepEqual(
|
||||
remainingIdentities.map((i) => i.id),
|
||||
[3, 5, 6]
|
||||
)
|
||||
})
|
||||
|
||||
test('清除聚合登录绑定:当用户头像来源为聚合登录且拥有其他第三方身份时,正确回退头像', () => {
|
||||
const user = {
|
||||
id: 101,
|
||||
avatarProvider: 'aggregate:qq',
|
||||
avatarProviderUserId: 'qq_123'
|
||||
}
|
||||
|
||||
const allUserIdentities = [
|
||||
{
|
||||
id: 1,
|
||||
userId: 101,
|
||||
provider: 'aggregate:qq',
|
||||
providerUserId: 'qq_123',
|
||||
avatar: 'https://example.com/qq.png',
|
||||
createdAt: new Date('2024-01-01')
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userId: 101,
|
||||
provider: 'github',
|
||||
providerUserId: 'gh_789',
|
||||
avatar: 'https://example.com/gh.png',
|
||||
createdAt: new Date('2024-01-02')
|
||||
}
|
||||
]
|
||||
|
||||
const targetKeys = new Set(['aggregate:qq:qq_123'])
|
||||
const remainingIdentities = allUserIdentities.filter(
|
||||
(i) => !targetKeys.has(`${i.provider}:${i.providerUserId}`)
|
||||
)
|
||||
|
||||
const isAvatarSourceDeleted =
|
||||
user.avatarProvider &&
|
||||
(user.avatarProvider === 'aggregate' ||
|
||||
user.avatarProvider.startsWith('aggregate:') ||
|
||||
targetKeys.has(`${user.avatarProvider}:${user.avatarProviderUserId}`))
|
||||
|
||||
assert.equal(isAvatarSourceDeleted, true)
|
||||
|
||||
const fallback = resolveAvatarSource(user, remainingIdentities)
|
||||
assert.deepEqual(fallback, {
|
||||
provider: 'github',
|
||||
providerUserId: 'gh_789',
|
||||
url: 'https://example.com/gh.png'
|
||||
})
|
||||
})
|
||||
|
||||
test('清除聚合登录绑定:当用户头像来源为聚合登录且无其他第三方身份时,头像源置为 null', () => {
|
||||
const user = {
|
||||
id: 102,
|
||||
avatarProvider: 'aggregate',
|
||||
avatarProviderUserId: 'agg_999'
|
||||
}
|
||||
|
||||
const allUserIdentities = [
|
||||
{
|
||||
id: 4,
|
||||
userId: 102,
|
||||
provider: 'aggregate',
|
||||
providerUserId: 'agg_999',
|
||||
avatar: 'https://example.com/agg.png',
|
||||
createdAt: new Date('2024-01-01')
|
||||
}
|
||||
]
|
||||
|
||||
const targetKeys = new Set(['aggregate:agg_999'])
|
||||
const remainingIdentities = allUserIdentities.filter(
|
||||
(i) => !targetKeys.has(`${i.provider}:${i.providerUserId}`)
|
||||
)
|
||||
|
||||
const fallback = resolveAvatarSource(user, remainingIdentities)
|
||||
assert.equal(fallback, null)
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { isTrustedOrigin, normalizeOrigin } from '../../server/utils/request-utils.ts'
|
||||
|
||||
test('允许 HTTPS 来源访问按 HTTP 配置的同主机内部 API', () => {
|
||||
const source = normalizeOrigin('https://voicehub.hyzx1915.top', 'http:')
|
||||
const trusted = normalizeOrigin('http://voicehub.hyzx1915.top', 'http:')
|
||||
|
||||
assert.equal(isTrustedOrigin(source, trusted), true)
|
||||
})
|
||||
|
||||
test('同主机但协议降级仍按跨域拦截', () => {
|
||||
const source = normalizeOrigin('http://voicehub.hyzx1915.top', 'https:')
|
||||
const trusted = normalizeOrigin('https://voicehub.hyzx1915.top', 'https:')
|
||||
|
||||
assert.equal(isTrustedOrigin(source, trusted), false)
|
||||
})
|
||||
|
||||
test('主机名或端口不同不能视为可信来源', () => {
|
||||
assert.equal(
|
||||
isTrustedOrigin(
|
||||
normalizeOrigin('https://voicehub.hyzx1915.top', 'http:'),
|
||||
normalizeOrigin('http://evil.example.com', 'http:')
|
||||
),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
isTrustedOrigin(
|
||||
normalizeOrigin('https://voicehub.hyzx1915.top:8443', 'http:'),
|
||||
normalizeOrigin('http://voicehub.hyzx1915.top:9443', 'http:')
|
||||
),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('协议升级时保留显式端口匹配', () => {
|
||||
assert.equal(
|
||||
isTrustedOrigin(
|
||||
normalizeOrigin('https://voicehub.hyzx1915.top:8443', 'http:'),
|
||||
normalizeOrigin('http://voicehub.hyzx1915.top:8443', 'http:')
|
||||
),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('相同来源仍然可信', () => {
|
||||
assert.equal(
|
||||
isTrustedOrigin(
|
||||
normalizeOrigin('https://voicehub.hyzx1915.top', 'https:'),
|
||||
normalizeOrigin('https://voicehub.hyzx1915.top', 'https:')
|
||||
),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('EdgeOne 实际转发 Host 可作为同源校验依据', () => {
|
||||
const source = normalizeOrigin('https://preview.edgeone.app', 'https:')
|
||||
const configured = normalizeOrigin('https://voicehub.example.com', 'https:')
|
||||
const forwardedHost = normalizeOrigin('https://preview.edgeone.app', 'https:')
|
||||
|
||||
assert.equal(isTrustedOrigin(source, configured), false)
|
||||
assert.equal(isTrustedOrigin(source, forwardedHost), true)
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { parseClassInput } from '../../app/utils/grade-class-input.ts'
|
||||
|
||||
test('数字区间生成对应数量的班名', () => {
|
||||
assert.deepEqual(parseClassInput('1-3'), ['1班', '2班', '3班'])
|
||||
assert.deepEqual(parseClassInput('1-1'), ['1班'])
|
||||
})
|
||||
|
||||
test('逗号分隔与区间可混合使用并去重', () => {
|
||||
const result = parseClassInput('一班,3-4,一班')
|
||||
assert.deepEqual(result, ['一班', '3班', '4班'])
|
||||
})
|
||||
|
||||
test('支持中文/英文逗号、换行、分号分隔', () => {
|
||||
assert.deepEqual(parseClassInput('一班,二班\n三班;4-5'), ['一班', '二班', '三班', '4班', '5班'])
|
||||
})
|
||||
|
||||
test('非法区间按字面保留', () => {
|
||||
assert.deepEqual(parseClassInput('5-3'), ['5-3'])
|
||||
assert.deepEqual(parseClassInput('一-三'), ['一-三'])
|
||||
})
|
||||
|
||||
test('超长区间按字面保留(上限 100 防呆)', () => {
|
||||
const result = parseClassInput('1-101')
|
||||
assert.deepEqual(result, ['1-101'])
|
||||
})
|
||||
|
||||
test('空输入与非字符串返回空数组', () => {
|
||||
assert.deepEqual(parseClassInput(''), [])
|
||||
assert.deepEqual(parseClassInput(undefined), [])
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
resolveGradeClassOptions,
|
||||
smartSort
|
||||
} from '../../server/utils/grade-class-core.ts'
|
||||
|
||||
test('配置非空时优先使用配置项', () => {
|
||||
const config = [{ grade: '高一', class: '一班' }]
|
||||
const fallback = [{ grade: '高二', class: '二班' }]
|
||||
|
||||
assert.deepEqual(resolveGradeClassOptions(config, fallback), config)
|
||||
})
|
||||
|
||||
test('配置为空时回退到用户提取并按学段排序', () => {
|
||||
const config = []
|
||||
const fallback = [
|
||||
{ grade: '高二', class: '二班' },
|
||||
{ grade: '高一', class: '一班' }
|
||||
]
|
||||
|
||||
const result = resolveGradeClassOptions(config, fallback)
|
||||
assert.deepEqual(result.map((item) => item.grade), ['高一', '高二'])
|
||||
})
|
||||
|
||||
test('过滤年级或班级为空的数据项', () => {
|
||||
const items = [
|
||||
{ grade: '高一', class: '一班' },
|
||||
{ grade: '', class: '二班' },
|
||||
{ grade: '高一', class: '' }
|
||||
]
|
||||
|
||||
const result = resolveGradeClassOptions(items, [])
|
||||
assert.equal(result.length, 1)
|
||||
assert.deepEqual(result[0], { grade: '高一', class: '一班' })
|
||||
})
|
||||
|
||||
test('smartSort 按学段权重排序,未知学段按字典序', () => {
|
||||
assert.equal(smartSort('初一', '高一') < 0, true)
|
||||
assert.equal(smartSort('高一', '高二') < 0, true)
|
||||
assert.equal(smartSort('教师', '高一') > 0, true)
|
||||
const values = ['十年级', '二年级']
|
||||
values.sort(smartSort)
|
||||
assert.deepEqual(values, ['二年级', '十年级'])
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
canSendSystemNotification,
|
||||
createNotificationSenderSnapshot,
|
||||
createNotificationReadUpdate,
|
||||
createNotificationUserDeleteUpdate,
|
||||
resolveImportantFlag,
|
||||
resolveNotificationSource,
|
||||
shouldCheckImportantNotification,
|
||||
shouldDeliverSystemNotification,
|
||||
shouldRetainNotificationHistory,
|
||||
serializeNotificationSender
|
||||
} from '../../server/utils/important-notification-policy.ts'
|
||||
|
||||
test('未登录、无用户或强制改密时不检查重要通知', () => {
|
||||
assert.equal(shouldCheckImportantNotification(false, null), false)
|
||||
assert.equal(shouldCheckImportantNotification(true, null), false)
|
||||
assert.equal(shouldCheckImportantNotification(true, 1, true), false)
|
||||
assert.equal(shouldCheckImportantNotification(true, 1), true)
|
||||
})
|
||||
|
||||
test('关闭通知使用服务端已读更新并刷新更新时间', () => {
|
||||
const updatedAt = new Date('2026-07-29T12:00:00.000Z')
|
||||
assert.deepEqual(createNotificationReadUpdate(updatedAt), { read: true, updatedAt })
|
||||
})
|
||||
|
||||
test('用户删除通知时保留记录并同步标记为已读', () => {
|
||||
const updatedAt = new Date('2026-07-29T12:30:00.000Z')
|
||||
assert.deepEqual(createNotificationUserDeleteUpdate(updatedAt), {
|
||||
userDeleted: true,
|
||||
read: true,
|
||||
updatedAt
|
||||
})
|
||||
})
|
||||
|
||||
test('重要通知绕过普通通知开关,普通通知保持原策略', () => {
|
||||
assert.equal(shouldDeliverSystemNotification(false, false), false)
|
||||
assert.equal(shouldDeliverSystemNotification(false, true), true)
|
||||
assert.equal(shouldDeliverSystemNotification(true, false), true)
|
||||
})
|
||||
|
||||
test('只有后台手工发送的通知需要在用户删除后保留历史', () => {
|
||||
assert.equal(shouldRetainNotificationHistory('SYSTEM_NOTICE', 'ADMIN_MANUAL'), true)
|
||||
assert.equal(shouldRetainNotificationHistory('SYSTEM_NOTICE', 'SYSTEM'), false)
|
||||
assert.equal(shouldRetainNotificationHistory('SONG_PLAYED', 'SYSTEM'), false)
|
||||
})
|
||||
|
||||
test('只有管理员角色可以发送系统通知', () => {
|
||||
assert.equal(canSendSystemNotification('ADMIN'), true)
|
||||
assert.equal(canSendSystemNotification('SUPER_ADMIN'), true)
|
||||
assert.equal(canSendSystemNotification('SONG_ADMIN'), false)
|
||||
assert.equal(canSendSystemNotification('USER'), false)
|
||||
})
|
||||
|
||||
test('important 只接受布尔值,缺省值为 false', () => {
|
||||
assert.equal(resolveImportantFlag(undefined), false)
|
||||
assert.equal(resolveImportantFlag(true), true)
|
||||
assert.equal(resolveImportantFlag(false), false)
|
||||
assert.equal(resolveImportantFlag('true'), null)
|
||||
assert.equal(resolveImportantFlag(1), null)
|
||||
})
|
||||
|
||||
test('系统通知不保存发送人,管理员通知保存发送时的名称快照', () => {
|
||||
const systemSender = createNotificationSenderSnapshot()
|
||||
assert.deepEqual(systemSender, {
|
||||
senderId: null,
|
||||
senderName: null,
|
||||
senderUsername: null
|
||||
})
|
||||
assert.equal(serializeNotificationSender(systemSender), null)
|
||||
assert.equal(resolveNotificationSource(null), 'SYSTEM')
|
||||
|
||||
const adminSender = createNotificationSenderSnapshot({
|
||||
id: 7,
|
||||
name: ' 张老师 ',
|
||||
username: ' admin '
|
||||
})
|
||||
assert.deepEqual(adminSender, {
|
||||
senderId: 7,
|
||||
senderName: '张老师',
|
||||
senderUsername: 'admin'
|
||||
})
|
||||
assert.deepEqual(serializeNotificationSender(adminSender), {
|
||||
id: 7,
|
||||
name: '张老师',
|
||||
username: 'admin'
|
||||
})
|
||||
assert.equal(
|
||||
resolveNotificationSource({ id: 7, name: '张老师', username: 'admin' }),
|
||||
'ADMIN_MANUAL'
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
canSetInitialPassword,
|
||||
getPasswordSetupState
|
||||
} from '../../server/utils/initial-password-policy.ts'
|
||||
|
||||
test('历史有密码用户应进入普通改密流程', () => {
|
||||
assert.deepEqual(
|
||||
getPasswordSetupState(
|
||||
{ password: 'existing-hash', passwordChangedAt: null },
|
||||
false
|
||||
),
|
||||
{ hasSetPassword: true, needsInitialPasswordSetup: false }
|
||||
)
|
||||
})
|
||||
|
||||
test('首次强制改密用户应进入初始设置流程', () => {
|
||||
assert.deepEqual(
|
||||
getPasswordSetupState(
|
||||
{ password: 'temporary-hash', passwordChangedAt: null },
|
||||
true
|
||||
),
|
||||
{ hasSetPassword: true, needsInitialPasswordSetup: true }
|
||||
)
|
||||
})
|
||||
|
||||
test('无密码 OAuth 用户应进入初始设置流程', () => {
|
||||
assert.deepEqual(
|
||||
getPasswordSetupState(
|
||||
{ password: null, passwordChangedAt: null },
|
||||
false
|
||||
),
|
||||
{ hasSetPassword: false, needsInitialPasswordSetup: true }
|
||||
)
|
||||
})
|
||||
|
||||
test('已完成密码设置的用户应进入普通改密流程', () => {
|
||||
assert.deepEqual(
|
||||
getPasswordSetupState(
|
||||
{ password: 'existing-hash', passwordChangedAt: new Date() },
|
||||
true
|
||||
),
|
||||
{ hasSetPassword: true, needsInitialPasswordSetup: false }
|
||||
)
|
||||
})
|
||||
|
||||
test('强制改密账号可以设置初始密码', () => {
|
||||
assert.equal(
|
||||
canSetInitialPassword(
|
||||
{ requirePasswordChange: true },
|
||||
{ password: 'existing-hash', passwordChangedAt: null }
|
||||
),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('真正无密码的账号可以设置初始密码', () => {
|
||||
assert.equal(
|
||||
canSetInitialPassword(
|
||||
{ requirePasswordChange: false },
|
||||
{ password: null, passwordChangedAt: null }
|
||||
),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('非强制状态的有密码账号不能免旧密码设置', () => {
|
||||
assert.equal(
|
||||
canSetInitialPassword(
|
||||
{ requirePasswordChange: false },
|
||||
{ password: 'existing-hash', passwordChangedAt: null }
|
||||
),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('已经完成密码设置的账号不能重复使用初始设置接口', () => {
|
||||
assert.equal(
|
||||
canSetInitialPassword(
|
||||
{ requirePasswordChange: true },
|
||||
{ password: 'existing-hash', passwordChangedAt: new Date() }
|
||||
),
|
||||
false
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
resolveNotificationBatchReference,
|
||||
resolveNotificationHistoryFilters,
|
||||
resolveNotificationHistoryPagination,
|
||||
resolveNotificationHistoryStatus
|
||||
} from '../../server/utils/notification-history-policy.ts'
|
||||
|
||||
test('通知发送历史支持关键词、类型、发送人和时间排序筛选', () => {
|
||||
assert.deepEqual(
|
||||
resolveNotificationHistoryFilters({
|
||||
keyword: ' 放假通知 ',
|
||||
type: 'important',
|
||||
sender: '7',
|
||||
sortOrder: 'asc'
|
||||
}),
|
||||
{
|
||||
keyword: '放假通知',
|
||||
type: 'IMPORTANT',
|
||||
senderId: 7,
|
||||
sortOrder: 'ASC'
|
||||
}
|
||||
)
|
||||
assert.deepEqual(resolveNotificationHistoryFilters({}), {
|
||||
keyword: '',
|
||||
type: 'ALL',
|
||||
senderId: null,
|
||||
sortOrder: 'DESC'
|
||||
})
|
||||
})
|
||||
|
||||
test('通知发送历史拒绝无效筛选参数', () => {
|
||||
assert.equal(resolveNotificationHistoryFilters({ keyword: ['通知'] }), null)
|
||||
assert.equal(resolveNotificationHistoryFilters({ keyword: 'a'.repeat(101) }), null)
|
||||
assert.equal(resolveNotificationHistoryFilters({ type: 'SYSTEM' }), null)
|
||||
assert.equal(resolveNotificationHistoryFilters({ sender: '0' }), null)
|
||||
assert.equal(resolveNotificationHistoryFilters({ sender: 'admin' }), null)
|
||||
assert.equal(resolveNotificationHistoryFilters({ sortOrder: 'latest' }), null)
|
||||
})
|
||||
|
||||
test('通知历史状态筛选支持全部、已读和未读', () => {
|
||||
assert.equal(resolveNotificationHistoryStatus(undefined), 'ALL')
|
||||
assert.equal(resolveNotificationHistoryStatus('read'), 'READ')
|
||||
assert.equal(resolveNotificationHistoryStatus(' UNREAD '), 'UNREAD')
|
||||
})
|
||||
|
||||
test('通知历史状态筛选拒绝未知值和非字符串', () => {
|
||||
assert.equal(resolveNotificationHistoryStatus('ARCHIVED'), null)
|
||||
assert.equal(resolveNotificationHistoryStatus(true), null)
|
||||
})
|
||||
|
||||
test('通知历史分页使用安全缺省值并限制最大页大小', () => {
|
||||
assert.deepEqual(resolveNotificationHistoryPagination(undefined, undefined), {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
offset: 0
|
||||
})
|
||||
assert.deepEqual(resolveNotificationHistoryPagination('3', '500'), {
|
||||
page: 3,
|
||||
limit: 100,
|
||||
offset: 200
|
||||
})
|
||||
assert.deepEqual(resolveNotificationHistoryPagination('-1', '0'), {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
offset: 0
|
||||
})
|
||||
})
|
||||
|
||||
test('通知批次引用支持新批次和历史单条记录', () => {
|
||||
assert.deepEqual(resolveNotificationBatchReference('550E8400-E29B-41D4-A716-446655440000'), {
|
||||
batchId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
notificationId: null
|
||||
})
|
||||
assert.deepEqual(resolveNotificationBatchReference('legacy-42'), {
|
||||
batchId: null,
|
||||
notificationId: 42
|
||||
})
|
||||
})
|
||||
|
||||
test('通知批次引用拒绝无效值', () => {
|
||||
assert.equal(resolveNotificationBatchReference('legacy-0'), null)
|
||||
assert.equal(
|
||||
resolveNotificationBatchReference('legacy-group-550e8400e29b41d4a716446655440000'),
|
||||
null
|
||||
)
|
||||
assert.equal(resolveNotificationBatchReference('not-a-batch'), null)
|
||||
assert.equal(resolveNotificationBatchReference(undefined), null)
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
decodeOAuthStateCookie,
|
||||
encodeOAuthStateCookie,
|
||||
generateState,
|
||||
parseState
|
||||
} from '../../server/utils/oauth.ts'
|
||||
|
||||
test('OAuth state Cookie 可以安全保存标准 Base64 特殊字符', () => {
|
||||
const state = Buffer.from([251, 255]).toString('base64')
|
||||
assert.equal(state, '+/8=')
|
||||
|
||||
const encoded = encodeOAuthStateCookie(state)
|
||||
assert.equal(encoded, 'b64url:-_8')
|
||||
assert.equal(decodeOAuthStateCookie(encoded), state)
|
||||
})
|
||||
|
||||
test('OAuth state Cookie 对多组随机 Base64 保持往返一致', () => {
|
||||
for (let length = 1; length <= 64; length += 1) {
|
||||
const state = randomBytes(length).toString('base64')
|
||||
assert.equal(decodeOAuthStateCookie(encodeOAuthStateCookie(state)), state)
|
||||
}
|
||||
})
|
||||
|
||||
test('OAuth state Cookie 兼容旧版无前缀值', () => {
|
||||
const legacyState = '+/8='
|
||||
assert.equal(decodeOAuthStateCookie(legacyState), legacyState)
|
||||
})
|
||||
|
||||
test('OAuth state Cookie 拒绝空值、非法字符和非规范 Base64URL', () => {
|
||||
assert.equal(decodeOAuthStateCookie('b64url:'), '')
|
||||
assert.equal(decodeOAuthStateCookie('b64url:abc='), '')
|
||||
assert.equal(decodeOAuthStateCookie('b64url:abc+def'), '')
|
||||
assert.equal(decodeOAuthStateCookie('b64url:a'), '')
|
||||
})
|
||||
|
||||
test('生成的 OAuth state 经过 Cookie 编解码后仍可验证', () => {
|
||||
const origin = 'https://voicehub.example.com'
|
||||
const secret = 'oauth-state-cookie-test-secret'
|
||||
const { state, csrf } = generateState(origin, 'aggregate', secret, '/account', 'qq')
|
||||
const restoredState = decodeOAuthStateCookie(encodeOAuthStateCookie(state))
|
||||
const parsed = parseState(restoredState, origin, csrf, secret)
|
||||
|
||||
assert.equal(parsed?.target, origin)
|
||||
assert.equal(parsed?.provider, 'aggregate')
|
||||
assert.equal(parsed?.returnTo, '/account')
|
||||
assert.equal(parsed?.loginType, 'qq')
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
getInitialPasswordPolicyViolation,
|
||||
validateInitialPasswordPolicy,
|
||||
validatePasswordPolicy
|
||||
} from '../../app/utils/password-policy.ts'
|
||||
|
||||
test('密码策略接受包含三类字符的安全密码', () => {
|
||||
assert.equal(validatePasswordPolicy('StrongPass123'), null)
|
||||
assert.equal(validatePasswordPolicy('安全Pass123!'), null)
|
||||
})
|
||||
|
||||
test('密码策略允许6位以上的安全密码', () => {
|
||||
assert.equal(validatePasswordPolicy('Pass12'), null)
|
||||
})
|
||||
|
||||
test('密码策略拒绝少于6位的密码', () => {
|
||||
assert.match(validatePasswordPolicy('Pas12') || '', /长度至少为6位/)
|
||||
})
|
||||
|
||||
test('密码策略拒绝常见弱口令和字符类型不足的密码', () => {
|
||||
assert.match(validatePasswordPolicy('12345678') || '', /过于常见/)
|
||||
assert.match(validatePasswordPolicy('abcdefgh') || '', /三类/)
|
||||
})
|
||||
|
||||
test('密码策略拒绝超过 bcrypt 有效字节长度的密码', () => {
|
||||
assert.match(validatePasswordPolicy('Aa1' + '!'.repeat(70)) || '', /72字节/)
|
||||
})
|
||||
|
||||
test('初始密码策略接受6位以上且包含两类字符的密码', () => {
|
||||
assert.equal(validateInitialPasswordPolicy('abcdef7'), null)
|
||||
assert.equal(validateInitialPasswordPolicy('ABCDEF1'), null)
|
||||
assert.equal(validateInitialPasswordPolicy('abcdefg!'), null)
|
||||
assert.equal(validateInitialPasswordPolicy('abcde1'), null)
|
||||
})
|
||||
|
||||
test('初始密码策略拒绝不足6位的密码', () => {
|
||||
const violation = getInitialPasswordPolicyViolation('Pas12')
|
||||
assert.equal(violation?.code, 'AUTH_PASSWORD_TOO_SHORT')
|
||||
assert.match(violation?.message || '', /至少为6位/)
|
||||
})
|
||||
|
||||
test('初始密码策略拒绝仅包含一类字符的密码', () => {
|
||||
const violation = getInitialPasswordPolicyViolation('abcdefg')
|
||||
assert.equal(violation?.code, 'AUTH_INITIAL_PASSWORD_COMPLEXITY_REQUIRED')
|
||||
assert.match(violation?.message || '', /两类/)
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
buildQqCommentRequestParam,
|
||||
normalizeQqCommentPage,
|
||||
normalizeQqCommentList
|
||||
} from '../../server/utils/qqComment.ts'
|
||||
|
||||
test('QQ评论分页参数透传上一页游标', () => {
|
||||
assert.deepEqual(
|
||||
buildQqCommentRequestParam({
|
||||
topid: '12345',
|
||||
cursor: 'cursor-20',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
type: 'latest'
|
||||
}),
|
||||
{
|
||||
BizType: 1,
|
||||
BizId: '12345',
|
||||
LastCommentSeqNo: 'cursor-20',
|
||||
PageSize: 20,
|
||||
PageNum: 1,
|
||||
PicEnable: 1,
|
||||
HashTagID: '',
|
||||
SelfSeeEnable: 1,
|
||||
AudioEnable: 1
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('QQ评论只保留根评论并将回复挂到根评论下', () => {
|
||||
const reply = {
|
||||
CmId: 'reply-1',
|
||||
Content: '回复内容',
|
||||
Nick: '回复者',
|
||||
Location: '广东'
|
||||
}
|
||||
const result = normalizeQqCommentList([
|
||||
{
|
||||
CmId: 'root-1',
|
||||
Content: '评论正文',
|
||||
Nick: '评论者',
|
||||
SubComments: [reply]
|
||||
},
|
||||
reply
|
||||
])
|
||||
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0]?.commentId, 'root-1')
|
||||
assert.equal(result[0]?.content, '评论正文')
|
||||
assert.equal(result[0]?.replies.length, 1)
|
||||
assert.equal(result[0]?.replies[0]?.commentId, 'reply-1')
|
||||
assert.equal(result[0]?.replies[0]?.user.location, '广东')
|
||||
})
|
||||
|
||||
test('QQ评论去重并归一化图片与表情图片地址', () => {
|
||||
const result = normalizeQqCommentList([
|
||||
{
|
||||
CmId: 'root-1',
|
||||
Content: '带图评论',
|
||||
Nick: '评论者',
|
||||
Pic: 'http://music-file.y.qq.com/comment/example.jpg?a=1&b=2',
|
||||
EmoPic: '//y.gtimg.cn/music/emoji/example.png'
|
||||
},
|
||||
{
|
||||
CmId: 'root-1',
|
||||
Content: '重复评论',
|
||||
Nick: '评论者'
|
||||
}
|
||||
])
|
||||
|
||||
assert.equal(result.length, 1)
|
||||
assert.deepEqual(result[0]?.images, ['https://music-file.y.qq.com/comment/example.jpg?a=1&b=2'])
|
||||
assert.deepEqual(result[0]?.emojis, ['https://y.gtimg.cn/music/emoji/example.png'])
|
||||
})
|
||||
|
||||
test('QQ评论过滤只有头像和昵称但没有正文的无效项', () => {
|
||||
const result = normalizeQqCommentList([
|
||||
{ CmId: 'empty-1', Content: '', Nick: '空评论', Avatar: 'https://example.com/a.jpg' },
|
||||
{ CmId: 'valid-1', Content: '有效正文', Nick: '正常评论' }
|
||||
])
|
||||
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0]?.commentId, 'valid-1')
|
||||
})
|
||||
|
||||
test('QQ评论按根评论字段收拢顶层回复', () => {
|
||||
const result = normalizeQqCommentList([
|
||||
{ CmId: 'root-1', Content: '根评论', Nick: '评论者' },
|
||||
{ CmId: 'reply-1', RootCmId: 'root-1', Content: '一级回复', Nick: '回复者' },
|
||||
{ CmId: 'reply-2', ParentCmId: 'root-1', Content: '另一条回复', Nick: '回复者二' }
|
||||
])
|
||||
|
||||
assert.equal(result.length, 1)
|
||||
assert.deepEqual(
|
||||
result[0]?.replies.map((reply) => reply.commentId),
|
||||
['reply-1', 'reply-2']
|
||||
)
|
||||
})
|
||||
|
||||
test('QQ评论支持 SubCmListV1 回复列表', () => {
|
||||
const result = normalizeQqCommentList([
|
||||
{
|
||||
CmId: 'root-1',
|
||||
Content: '根评论',
|
||||
SubCmListV1: [{ CmId: 'reply-1', Content: '新版回复', Nick: '回复者' }]
|
||||
}
|
||||
])
|
||||
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0]?.replies[0]?.content, '新版回复')
|
||||
})
|
||||
|
||||
test('QQ评论保留多层回复供前端完整展示', () => {
|
||||
const result = normalizeQqCommentList([
|
||||
{
|
||||
CmId: 'root-1',
|
||||
Content: '根评论',
|
||||
SubComments: [
|
||||
{
|
||||
CmId: 'reply-1',
|
||||
Content: '一级回复',
|
||||
SubComments: [{ CmId: 'reply-2', Content: '二级回复' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
assert.equal(result[0]?.replies[0]?.commentId, 'reply-1')
|
||||
assert.equal(result[0]?.replies[0]?.replies[0]?.commentId, 'reply-2')
|
||||
})
|
||||
|
||||
test('QQ评论分页中根评论缺失时保留孤儿回复供后续归并', () => {
|
||||
const result = normalizeQqCommentPage([
|
||||
{
|
||||
CmId: 'reply-1',
|
||||
RootCmId: 'root-1',
|
||||
Content: '跨页回复',
|
||||
Nick: '回复者'
|
||||
}
|
||||
])
|
||||
|
||||
assert.equal(result.comments.length, 0)
|
||||
assert.equal(result.orphanReplies.length, 1)
|
||||
assert.equal(result.orphanReplies[0]?.commentId, 'reply-1')
|
||||
assert.equal(result.orphanReplies[0]?.rootCommentId, 'root-1')
|
||||
})
|
||||
|
||||
test('QQ评论正文字面量换行还原为真实换行', () => {
|
||||
const result = normalizeQqCommentList([
|
||||
{
|
||||
CmId: 'root-1',
|
||||
Content: '第一行\\r\\n第二行\\n第三行\\r结尾',
|
||||
Nick: '评论者',
|
||||
SubComments: [{ CmId: 'reply-1', Content: '回复\\n换行', Nick: '回复者' }]
|
||||
}
|
||||
])
|
||||
|
||||
assert.equal(result[0]?.content, '第一行\n第二行\n第三行\n结尾')
|
||||
assert.equal(result[0]?.replies[0]?.content, '回复\n换行')
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { validateGradeClassPair, resolveGradeClassError, REMARK_MAX_LENGTH } from '../../server/utils/register-validation.ts'
|
||||
|
||||
test('年级和班级必须成对填写', () => {
|
||||
assert.equal(validateGradeClassPair('高一', '一班'), null)
|
||||
assert.equal(validateGradeClassPair('', ''), null)
|
||||
assert.equal(validateGradeClassPair(undefined, undefined), null)
|
||||
|
||||
const onlyGrade = validateGradeClassPair('高一', '')
|
||||
assert.equal(onlyGrade?.code, 'AUTH_GRADE_CLASS_TOGETHER')
|
||||
|
||||
const onlyClass = validateGradeClassPair('', '一班')
|
||||
assert.equal(onlyClass?.code, 'AUTH_GRADE_CLASS_TOGETHER')
|
||||
})
|
||||
|
||||
test('站点开启注册必选年级班级时任一缺失按必填拒绝', () => {
|
||||
assert.equal(resolveGradeClassError('高一', '一班'), null)
|
||||
assert.equal(resolveGradeClassError('高一', '')?.code, 'AUTH_GRADE_CLASS_TOGETHER')
|
||||
assert.equal(resolveGradeClassError('高一', '一班', true), null)
|
||||
assert.equal(resolveGradeClassError('高一', '', true)?.code, 'AUTH_GRADE_CLASS_REQUIRED')
|
||||
assert.equal(resolveGradeClassError('', '一班', true)?.code, 'AUTH_GRADE_CLASS_REQUIRED')
|
||||
assert.equal(resolveGradeClassError('', '', true)?.code, 'AUTH_GRADE_CLASS_REQUIRED')
|
||||
assert.equal(resolveGradeClassError(undefined, undefined, true)?.code, 'AUTH_GRADE_CLASS_REQUIRED')
|
||||
})
|
||||
|
||||
test('注册备注长度上限为 200', () => {
|
||||
assert.equal(REMARK_MAX_LENGTH, 200)
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { decideDurationOutcome, normalizeStoredDuration } from '../../server/utils/song-duration-policy.ts'
|
||||
|
||||
test('往期导入搬运时长时按 0–7200 秒区间归一化', () => {
|
||||
assert.equal(normalizeStoredDuration(null), null)
|
||||
assert.equal(normalizeStoredDuration(undefined), null)
|
||||
assert.equal(normalizeStoredDuration(''), null)
|
||||
assert.equal(normalizeStoredDuration('abc'), null)
|
||||
assert.equal(normalizeStoredDuration(NaN), null)
|
||||
assert.equal(normalizeStoredDuration(-1), null)
|
||||
assert.equal(normalizeStoredDuration(7201), null)
|
||||
assert.equal(normalizeStoredDuration(0), 0)
|
||||
assert.equal(normalizeStoredDuration(29), 29)
|
||||
assert.equal(normalizeStoredDuration('215'), 215)
|
||||
assert.equal(normalizeStoredDuration(3600), 3600)
|
||||
assert.equal(normalizeStoredDuration(5400), 5400)
|
||||
assert.equal(normalizeStoredDuration(7200), 7200)
|
||||
})
|
||||
|
||||
test('管理员标注的长曲目不再被导入流程静默清空', () => {
|
||||
// 旧口径为 30–3600 秒,超 1 小时的曲目会被写成 null
|
||||
assert.equal(normalizeStoredDuration(4200), 4200)
|
||||
})
|
||||
|
||||
test('库中无时长且平台可查时补齐', () => {
|
||||
assert.deepEqual(decideDurationOutcome(null, 215), { outcome: 'fill', durationSeconds: 215 })
|
||||
})
|
||||
|
||||
test('平台查询失败时保留原值不写库', () => {
|
||||
assert.deepEqual(decideDurationOutcome(215, null), { outcome: 'nocheck', durationSeconds: 215 })
|
||||
assert.deepEqual(decideDurationOutcome(null, null), { outcome: 'nocheck', durationSeconds: null })
|
||||
})
|
||||
|
||||
test('提交时长与平台差异在容差内视为一致', () => {
|
||||
assert.deepEqual(decideDurationOutcome(215, 213), { outcome: 'keep', durationSeconds: 215 })
|
||||
assert.deepEqual(decideDurationOutcome(215, 220), { outcome: 'keep', durationSeconds: 215 })
|
||||
})
|
||||
|
||||
test('提交时长与平台差异超过容差时清空', () => {
|
||||
assert.deepEqual(decideDurationOutcome(215, 221), { outcome: 'clear', durationSeconds: null })
|
||||
assert.deepEqual(decideDurationOutcome(215, 60), { outcome: 'clear', durationSeconds: null })
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { normalizeForMatch, toSimplifiedChinese } from '../../app/utils/song-name-normalize.ts'
|
||||
|
||||
test('繁体标题与简体库存归一化后相等', () => {
|
||||
assert.equal(normalizeForMatch('海闊天空'), normalizeForMatch('海阔天空'))
|
||||
})
|
||||
|
||||
test('繁体歌手名归一化后相等', () => {
|
||||
assert.equal(normalizeForMatch('周杰倫'), normalizeForMatch('周杰伦'))
|
||||
assert.equal(normalizeForMatch('陳奕迅'), normalizeForMatch('陈奕迅'))
|
||||
})
|
||||
|
||||
test('归一化移除 feat./ft. 词条与空白标点', () => {
|
||||
// 口径沿用现网版本:仅移除 feat/ft 词条本体,其后标点由标点规则处理
|
||||
assert.equal(normalizeForMatch('一首歌 ft. 某人'), '一首歌.某人')
|
||||
assert.equal(
|
||||
normalizeForMatch('晴天 (feat. 周杰伦)'),
|
||||
normalizeForMatch('晴天.周杰伦')
|
||||
)
|
||||
assert.equal(normalizeForMatch('《红玫瑰》'), normalizeForMatch('红玫瑰'))
|
||||
})
|
||||
|
||||
test('&/& 统一为 and', () => {
|
||||
assert.equal(normalizeForMatch('A&B'), normalizeForMatch('A&B'))
|
||||
assert.equal(normalizeForMatch('A&B'), 'aandb')
|
||||
})
|
||||
|
||||
test('弯引号被视为可去除标点', () => {
|
||||
assert.equal(normalizeForMatch('“回忆”'), normalizeForMatch('回忆'))
|
||||
})
|
||||
|
||||
test('大小写不敏感', () => {
|
||||
assert.equal(normalizeForMatch('LOVE Story'), normalizeForMatch('love story'))
|
||||
})
|
||||
|
||||
test('回归: 含 feat 字母的普通英文单词不被误删', () => {
|
||||
// 旧实现使用字符类 [feat\.?|ft\.?] 会删掉所有 f/e/a/t 字母
|
||||
assert.equal(toSimplifiedChinese(normalizeForMatch('Coffee')), 'coffee')
|
||||
assert.equal(toSimplifiedChinese(normalizeForMatch('Eason Chan')), 'easonchan')
|
||||
})
|
||||
|
||||
test('空值与原样返回', () => {
|
||||
assert.equal(normalizeForMatch(''), '')
|
||||
assert.equal(toSimplifiedChinese(''), '')
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
normalizeLanguageLabels,
|
||||
parseNeteaseWikiTypes,
|
||||
parseTencentSongTypes,
|
||||
mapQqLanguage,
|
||||
matchBlacklistGenre,
|
||||
matchBlacklistLanguage
|
||||
} from '../../server/utils/song-type-resolver.ts'
|
||||
import {
|
||||
BLACKLIST_LANGUAGE_VALUES,
|
||||
BLACKLIST_GENRE_VALUES,
|
||||
QQ_LANGUAGE_CODE_MAP,
|
||||
QQ_GENRE_NAME_MAP
|
||||
} from '../../server/config/constants.ts'
|
||||
|
||||
test('网易云语种文本拆分与同义词归一', () => {
|
||||
assert.deepEqual(normalizeLanguageLabels('粤语'), ['粤语'])
|
||||
assert.deepEqual(normalizeLanguageLabels('英语、韩语'), ['英语', '韩语'])
|
||||
assert.deepEqual(normalizeLanguageLabels('国语'), ['华语'])
|
||||
assert.deepEqual(normalizeLanguageLabels('普通话,英语'), ['华语', '英语'])
|
||||
assert.deepEqual(normalizeLanguageLabels(''), [])
|
||||
assert.deepEqual(normalizeLanguageLabels(null), [])
|
||||
})
|
||||
|
||||
test('解析网易云歌曲百科的语种与曲风', () => {
|
||||
const payload = {
|
||||
code: 200,
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
rnData: {
|
||||
songName: '海阔天空',
|
||||
blocks: [
|
||||
{
|
||||
blockInfo: {
|
||||
blockCode: 2,
|
||||
wikiSubElementVos: [
|
||||
{ pattern: 1, title: '曲风', wikiSubMetaVos: [{ id: 1098, text: '摇滚-流行摇滚' }, { id: 1000, text: '流行' }] },
|
||||
{ pattern: 0, title: '语种', content: '粤语' },
|
||||
{ pattern: 0, title: '发行时间', content: '1993-09-09' }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
const types = parseNeteaseWikiTypes(payload)
|
||||
assert.deepEqual(types.languages, ['粤语'])
|
||||
assert.deepEqual(types.genres, ['摇滚-流行摇滚', '流行'])
|
||||
|
||||
// 空结构容错
|
||||
assert.deepEqual(parseNeteaseWikiTypes(null), { languages: [], genres: [] })
|
||||
assert.deepEqual(parseNeteaseWikiTypes({ code: 200, data: {} }), { languages: [], genres: [] })
|
||||
})
|
||||
|
||||
test('QQ 语种数字码映射', () => {
|
||||
assert.equal(mapQqLanguage(0), '华语')
|
||||
assert.equal(mapQqLanguage(1), '粤语')
|
||||
assert.equal(mapQqLanguage(2), '闽南语')
|
||||
assert.equal(mapQqLanguage(3), '日语')
|
||||
assert.equal(mapQqLanguage(4), '韩语')
|
||||
assert.equal(mapQqLanguage(5), '英语')
|
||||
assert.equal(mapQqLanguage(19), null)
|
||||
assert.equal(mapQqLanguage('abc'), null)
|
||||
assert.equal(mapQqLanguage(undefined), null)
|
||||
})
|
||||
|
||||
test('解析 QQ 详情的官网分类名(语种/曲风)', () => {
|
||||
const payload = {
|
||||
req: {
|
||||
data: {
|
||||
info: {
|
||||
lan: { content: [{ value: '国语' }, { value: ' 英语' }] },
|
||||
genre: { content: [{ value: 'Pop' }, { value: 'Rock' }] }
|
||||
},
|
||||
track_info: { language: 0, genre: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
const types = parseTencentSongTypes(payload)
|
||||
assert.deepEqual(types.languages, ['华语', '英语'])
|
||||
assert.deepEqual(types.genres, ['流行', '摇滚'])
|
||||
|
||||
// 未收录流派名归「其他」,重复值去重
|
||||
const other = parseTencentSongTypes({
|
||||
req: { data: { info: { genre: { content: [{ value: 'Animation' }, { value: 'Rock ' }] } } } }
|
||||
})
|
||||
assert.deepEqual(other.languages, [])
|
||||
assert.deepEqual(other.genres, ['其他', '摇滚'])
|
||||
|
||||
// 空结构容错
|
||||
assert.deepEqual(parseTencentSongTypes(null), { languages: [], genres: [] })
|
||||
assert.deepEqual(parseTencentSongTypes({ req: { data: {} } }), { languages: [], genres: [] })
|
||||
assert.deepEqual(parseTencentSongTypes({ req: { data: { info: {} } } }), { languages: [], genres: [] })
|
||||
})
|
||||
|
||||
test('QQ 语种:info.lan 缺失时回退数值码,未收录整数码归「其他」', () => {
|
||||
const fallback = parseTencentSongTypes({ req: { data: { track_info: { language: 5 } } } })
|
||||
assert.deepEqual(fallback.languages, ['英语'])
|
||||
|
||||
const unknownCode = parseTencentSongTypes({ req: { data: { track_info: { language: 99 } } } })
|
||||
assert.deepEqual(unknownCode.languages, ['其他'])
|
||||
|
||||
const missing = parseTencentSongTypes({ req: { data: { track_info: {} } } })
|
||||
assert.deepEqual(missing.languages, [])
|
||||
})
|
||||
|
||||
test('曲风与黑名单值按一级分类匹配', () => {
|
||||
assert.equal(matchBlacklistGenre('摇滚', ['摇滚-流行摇滚', '流行']), true)
|
||||
assert.equal(matchBlacklistGenre('流行', ['流行-华语流行']), true)
|
||||
assert.equal(matchBlacklistGenre('原声带', ['原声带-动画片原声']), true)
|
||||
assert.equal(matchBlacklistGenre('电子', ['流行-华语流行']), false)
|
||||
assert.equal(matchBlacklistGenre('R&B', ['R&B']), true)
|
||||
assert.equal(matchBlacklistGenre('', ['流行']), false)
|
||||
assert.equal(matchBlacklistGenre('流行', []), false)
|
||||
})
|
||||
|
||||
test('曲风匹配:「其他」命中候选列表之外的一级分类', () => {
|
||||
assert.equal(matchBlacklistGenre('其他', ['金属-重金属']), true)
|
||||
assert.equal(matchBlacklistGenre('其他', ['嘻哈说唱-硬核说唱', '原声带-电影原声']), true)
|
||||
assert.equal(matchBlacklistGenre('其他', ['中国传统特色-京剧']), true)
|
||||
assert.equal(matchBlacklistGenre('其他', ['流行-华语流行']), false)
|
||||
assert.equal(matchBlacklistGenre('其他', ['古典-巴洛克时期']), false)
|
||||
assert.equal(matchBlacklistGenre('其他', []), false) // 无法判定不命中
|
||||
})
|
||||
|
||||
test('语种匹配:「其他」命中主要候选值之外的小语种', () => {
|
||||
assert.equal(matchBlacklistLanguage('英语', ['英语']), true)
|
||||
assert.equal(matchBlacklistLanguage('英语', ['法语']), false)
|
||||
assert.equal(matchBlacklistLanguage('其他', ['法语']), true)
|
||||
assert.equal(matchBlacklistLanguage('其他', ['俄语', '西班牙语']), true)
|
||||
assert.equal(matchBlacklistLanguage('其他', ['华语']), false)
|
||||
assert.equal(matchBlacklistLanguage('其他', ['英语', '韩语']), false)
|
||||
assert.equal(matchBlacklistLanguage('其他', []), false) // 无法判定不命中
|
||||
assert.equal(matchBlacklistLanguage('', ['法语']), false)
|
||||
})
|
||||
|
||||
test('QQ 语种映射值必须落在语种候选值集合内', () => {
|
||||
for (const label of Object.values(QQ_LANGUAGE_CODE_MAP)) {
|
||||
assert.ok(
|
||||
(BLACKLIST_LANGUAGE_VALUES as readonly string[]).includes(label),
|
||||
`QQ 语种映射值 ${label} 不在候选值集合中`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('QQ 流派映射值必须落在曲风候选值集合内', () => {
|
||||
for (const label of Object.values(QQ_GENRE_NAME_MAP)) {
|
||||
assert.ok(
|
||||
(BLACKLIST_GENRE_VALUES as readonly string[]).includes(label),
|
||||
`QQ 流派映射值 ${label} 不在候选值集合中`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('候选值集合无重复', () => {
|
||||
assert.equal(new Set(BLACKLIST_LANGUAGE_VALUES).size, BLACKLIST_LANGUAGE_VALUES.length)
|
||||
assert.equal(new Set(BLACKLIST_GENRE_VALUES).size, BLACKLIST_GENRE_VALUES.length)
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
normalizeRestrictionHours,
|
||||
resolveSubmissionRestrictionPolicy
|
||||
} from '../../server/utils/submission-restriction-policy.ts'
|
||||
|
||||
test('时长归一化只接受正整数,其余视为未配置', () => {
|
||||
assert.equal(normalizeRestrictionHours(null), null)
|
||||
assert.equal(normalizeRestrictionHours(undefined), null)
|
||||
assert.equal(normalizeRestrictionHours(''), null)
|
||||
assert.equal(normalizeRestrictionHours('abc'), null)
|
||||
assert.equal(normalizeRestrictionHours(0), null)
|
||||
assert.equal(normalizeRestrictionHours(-3), null)
|
||||
assert.equal(normalizeRestrictionHours(1), 1)
|
||||
assert.equal(normalizeRestrictionHours('24'), 24)
|
||||
assert.equal(normalizeRestrictionHours(24.7), 24)
|
||||
})
|
||||
|
||||
test('关闭重复投稿限制后不做任何限制,普通用户可重复投稿已排期歌曲', () => {
|
||||
const policy = resolveSubmissionRestrictionPolicy({
|
||||
enableSubmissionRestriction: false,
|
||||
sameSongRestrictionHours: null,
|
||||
sameArtistRestrictionHours: null,
|
||||
submissionRestrictionScope: 'all'
|
||||
})
|
||||
assert.equal(policy.mode, 'none')
|
||||
})
|
||||
|
||||
test('开关关闭但残留时长也不生效,避免脏数据绕过开关', () => {
|
||||
const policy = resolveSubmissionRestrictionPolicy({
|
||||
enableSubmissionRestriction: false,
|
||||
sameSongRestrictionHours: 48,
|
||||
sameArtistRestrictionHours: null
|
||||
})
|
||||
assert.equal(policy.mode, 'none')
|
||||
})
|
||||
|
||||
test('系统设置缺失时按不限制降级', () => {
|
||||
assert.equal(resolveSubmissionRestrictionPolicy(null).mode, 'none')
|
||||
assert.equal(resolveSubmissionRestrictionPolicy(undefined).mode, 'none')
|
||||
assert.equal(resolveSubmissionRestrictionPolicy({}).mode, 'none')
|
||||
})
|
||||
|
||||
test('开启限制但未配置时长时沿用本学期查重规则', () => {
|
||||
const policy = resolveSubmissionRestrictionPolicy({
|
||||
enableSubmissionRestriction: true,
|
||||
sameSongRestrictionHours: null,
|
||||
sameArtistRestrictionHours: null
|
||||
})
|
||||
assert.equal(policy.mode, 'semester')
|
||||
})
|
||||
|
||||
test('开启限制且时长非法时退回本学期查重规则', () => {
|
||||
const policy = resolveSubmissionRestrictionPolicy({
|
||||
enableSubmissionRestriction: true,
|
||||
sameSongRestrictionHours: 0,
|
||||
sameArtistRestrictionHours: 'abc'
|
||||
})
|
||||
assert.equal(policy.mode, 'semester')
|
||||
})
|
||||
|
||||
test('开启限制且配置时长时按冷却窗口判定', () => {
|
||||
const policy = resolveSubmissionRestrictionPolicy({
|
||||
enableSubmissionRestriction: true,
|
||||
sameSongRestrictionHours: 24,
|
||||
sameArtistRestrictionHours: 72,
|
||||
submissionRestrictionScope: 'self'
|
||||
})
|
||||
assert.equal(policy.mode, 'window')
|
||||
assert.equal(policy.sameSongHours, 24)
|
||||
assert.equal(policy.sameArtistHours, 72)
|
||||
assert.equal(policy.scope, 'self')
|
||||
})
|
||||
|
||||
test('限制范围非法值回退为所有人', () => {
|
||||
const policy = resolveSubmissionRestrictionPolicy({
|
||||
enableSubmissionRestriction: true,
|
||||
sameSongRestrictionHours: 24,
|
||||
submissionRestrictionScope: 'everyone'
|
||||
})
|
||||
assert.equal(policy.scope, 'all')
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { JWTEnhanced } from '../../server/utils/jwt-enhanced.ts'
|
||||
|
||||
test('预认证令牌必须携带当前 tokenVersion', () => {
|
||||
assert.equal(JWTEnhanced.hasCurrentTokenVersion({ tokenVersion: 3 }, 3), true)
|
||||
assert.equal(JWTEnhanced.hasCurrentTokenVersion({ tokenVersion: 2 }, 3), false)
|
||||
})
|
||||
|
||||
test('迁移前或非法 tokenVersion 的预认证令牌按失效处理', () => {
|
||||
assert.equal(JWTEnhanced.hasCurrentTokenVersion({}, 0), false)
|
||||
assert.equal(JWTEnhanced.hasCurrentTokenVersion({ tokenVersion: '0' }, 0), false)
|
||||
assert.equal(JWTEnhanced.hasCurrentTokenVersion(null, 0), false)
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
ARCHIVED_USER_STATUSES,
|
||||
isArchivedStatus,
|
||||
resolveArchivedFilter
|
||||
} from '../../app/utils/user-archive.ts'
|
||||
|
||||
test('归档状态包含 graduate(限制访问-毕业生)与 withdrawn(限制访问-退学)', () => {
|
||||
assert.deepEqual([...ARCHIVED_USER_STATUSES], ['graduate', 'withdrawn'])
|
||||
})
|
||||
|
||||
test('isArchivedStatus 识别归档状态', () => {
|
||||
assert.equal(isArchivedStatus('graduate'), true)
|
||||
assert.equal(isArchivedStatus('withdrawn'), true)
|
||||
assert.equal(isArchivedStatus('active'), false)
|
||||
assert.equal(isArchivedStatus('pending'), false)
|
||||
assert.equal(isArchivedStatus(null), false)
|
||||
assert.equal(isArchivedStatus(undefined), false)
|
||||
})
|
||||
|
||||
test('archived=1 解析为仅查已归档', () => {
|
||||
assert.equal(resolveArchivedFilter('1'), 'archived')
|
||||
assert.equal(resolveArchivedFilter('true'), 'archived')
|
||||
})
|
||||
|
||||
test('archived=0 解析为排除已归档', () => {
|
||||
assert.equal(resolveArchivedFilter('0'), 'unarchived')
|
||||
assert.equal(resolveArchivedFilter('false'), 'unarchived')
|
||||
})
|
||||
|
||||
test('缺省或未知值不限制归档', () => {
|
||||
assert.equal(resolveArchivedFilter(undefined), 'all')
|
||||
assert.equal(resolveArchivedFilter(''), 'all')
|
||||
assert.equal(resolveArchivedFilter('yes'), 'all')
|
||||
})
|
||||
|
||||
test('数组参数取首项解析', () => {
|
||||
assert.equal(resolveArchivedFilter(['1']), 'archived')
|
||||
assert.equal(resolveArchivedFilter(['0']), 'unarchived')
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { getIdentityAvatarUrl, resolveAvatarSource } from '../../server/utils/user-avatar.ts'
|
||||
|
||||
test('getIdentityAvatarUrl 返回身份头像', () => {
|
||||
assert.equal(
|
||||
getIdentityAvatarUrl({
|
||||
provider: 'github',
|
||||
providerUserId: '1',
|
||||
avatar: 'https://example.com/a.png'
|
||||
}),
|
||||
'https://example.com/a.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('getIdentityAvatarUrl 拒绝非 http(s) 头像', () => {
|
||||
assert.equal(
|
||||
getIdentityAvatarUrl({
|
||||
provider: 'github',
|
||||
providerUserId: '1',
|
||||
avatar: 'javascript:alert(1)'
|
||||
}),
|
||||
null
|
||||
)
|
||||
assert.equal(
|
||||
getIdentityAvatarUrl({
|
||||
provider: 'qq',
|
||||
providerUserId: '1',
|
||||
avatar: 'data:image/png;base64,abc'
|
||||
}),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('GitHub 存量身份无 avatar 时回退用户名图片', () => {
|
||||
assert.equal(
|
||||
getIdentityAvatarUrl({
|
||||
provider: 'github',
|
||||
providerUserId: '1',
|
||||
providerUsername: 'octocat',
|
||||
avatar: null
|
||||
}),
|
||||
'https://github.com/octocat.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('WebAuthn / TOTP 身份不参与头像', () => {
|
||||
assert.equal(
|
||||
getIdentityAvatarUrl({ provider: 'webauthn', providerUserId: 'x', avatar: null }),
|
||||
null
|
||||
)
|
||||
assert.equal(getIdentityAvatarUrl({ provider: 'totp', providerUserId: 'x', avatar: null }), null)
|
||||
})
|
||||
|
||||
test('resolveAvatarSource 优先返回选中的身份', () => {
|
||||
const user = { avatarProvider: 'github', avatarProviderUserId: '2' }
|
||||
const identities = [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'github',
|
||||
providerUserId: '1',
|
||||
providerUsername: 'a',
|
||||
avatar: 'https://a.png',
|
||||
createdAt: new Date('2024-01-01')
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
provider: 'github',
|
||||
providerUserId: '2',
|
||||
providerUsername: 'b',
|
||||
avatar: 'https://b.png',
|
||||
createdAt: new Date('2024-01-02')
|
||||
}
|
||||
]
|
||||
assert.deepEqual(resolveAvatarSource(user, identities), {
|
||||
provider: 'github',
|
||||
providerUserId: '2',
|
||||
url: 'https://b.png'
|
||||
})
|
||||
})
|
||||
|
||||
test('resolveAvatarSource 未选择时按创建顺序取第一个可用头像', () => {
|
||||
const identities = [
|
||||
{
|
||||
id: 2,
|
||||
provider: 'github',
|
||||
providerUserId: '2',
|
||||
providerUsername: 'b',
|
||||
avatar: 'https://b.png',
|
||||
createdAt: new Date('2024-01-02')
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
provider: 'qq',
|
||||
providerUserId: '1',
|
||||
providerUsername: 'a',
|
||||
avatar: 'https://a.png',
|
||||
createdAt: new Date('2024-01-01')
|
||||
}
|
||||
]
|
||||
assert.deepEqual(resolveAvatarSource({}, identities), {
|
||||
provider: 'qq',
|
||||
providerUserId: '1',
|
||||
url: 'https://a.png'
|
||||
})
|
||||
})
|
||||
|
||||
test('resolveAvatarSource 全部为空时返回 null', () => {
|
||||
assert.equal(
|
||||
resolveAvatarSource({}, [
|
||||
{ provider: 'webauthn', providerUserId: 'x', avatar: null },
|
||||
{ provider: 'totp', providerUserId: 'y', avatar: null }
|
||||
]),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('解绑回退:删除当前来源后取剩余身份第一个可用头像', () => {
|
||||
const user = { avatarProvider: 'qq', avatarProviderUserId: '1' }
|
||||
const remaining = [
|
||||
{
|
||||
id: 2,
|
||||
provider: 'github',
|
||||
providerUserId: '2',
|
||||
providerUsername: 'octocat',
|
||||
avatar: null,
|
||||
createdAt: new Date('2024-01-03')
|
||||
}
|
||||
]
|
||||
assert.deepEqual(resolveAvatarSource(user, remaining), {
|
||||
provider: 'github',
|
||||
providerUserId: '2',
|
||||
url: 'https://github.com/octocat.png'
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user