task-86(店铺中心): 实现密钥敏感字段掩码
新增 secret-mask.ts:店铺密钥令牌等凭证明文默认掩码展示,主动点击后按可注入 时间短时揭示、超时清空;明文不写入全局状态/持久化存储/地址栏/日志。 TDD: task-86.test.ts 8 用例先 RED 后 GREEN。
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
/** 店铺中心敏感字段掩码与短时揭示(任务 86):店铺密钥令牌等凭证明文默认以掩码展示,
|
||||||
|
* 用户主动点击后才在内存中短时揭示;不写入全局状态、持久化存储、地址栏或日志。纯逻辑,可注入时间便于测试。 */
|
||||||
|
|
||||||
|
/** 掩码占位符(默认固定长度,与旧页面 ****** 一致)。 */
|
||||||
|
export const SECRET_PLACEHOLDER = '******'
|
||||||
|
|
||||||
|
/** 未指定时长时的默认揭示窗口(毫秒)。 */
|
||||||
|
export const DEFAULT_REVEAL_MS = 5_000
|
||||||
|
|
||||||
|
export interface SecretRevealState {
|
||||||
|
/** 当前被揭示的记录行 id;无揭示时为 null。 */
|
||||||
|
keyId: number | null
|
||||||
|
/** 揭示中的明文,仅存内存、离开即清空。 */
|
||||||
|
token?: string
|
||||||
|
/** 揭示过期时间点(毫秒)。 */
|
||||||
|
expiresAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空揭示状态(首屏/页面卸载默认)。 */
|
||||||
|
export function createSecretRevealState(): SecretRevealState {
|
||||||
|
return { keyId: null, token: undefined, expiresAt: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function secretPlaceholder(): string {
|
||||||
|
return SECRET_PLACEHOLDER
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任何秘密展示一律落为掩码占位符;真正明文只经揭示会话在内存中出现。 */
|
||||||
|
export function maskSensitive(_value: unknown): string {
|
||||||
|
return SECRET_PLACEHOLDER
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开始揭示指定行的秘密:记录明文与过期点(默认短时窗口);返回新状态,不改入参。 */
|
||||||
|
export function revealSecret(
|
||||||
|
state: SecretRevealState,
|
||||||
|
keyId: number,
|
||||||
|
token: string,
|
||||||
|
now: number,
|
||||||
|
expiresAt?: number,
|
||||||
|
): SecretRevealState {
|
||||||
|
return {
|
||||||
|
keyId,
|
||||||
|
token: typeof token === 'string' ? token : '',
|
||||||
|
expiresAt: expiresAt === undefined ? now + DEFAULT_REVEAL_MS : expiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 到达过期点即清空揭示明文,返回新状态;未过期原样返回。 */
|
||||||
|
export function expireReveal(state: SecretRevealState, now: number): SecretRevealState {
|
||||||
|
if (state.expiresAt !== undefined && state.expiresAt !== null && now >= state.expiresAt) {
|
||||||
|
return createSecretRevealState()
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
import {
|
||||||
|
createSecretRevealState,
|
||||||
|
expireReveal,
|
||||||
|
maskSensitive,
|
||||||
|
revealSecret,
|
||||||
|
secretPlaceholder,
|
||||||
|
} from '../src/pages/shop/secret-mask.ts'
|
||||||
|
|
||||||
|
test('test_task_086_secret_mask_normal_primary_path', () => {
|
||||||
|
// 正常主路径:非空令牌默认掩码展示,点击后短时返回明文。
|
||||||
|
assert.equal(maskSensitive('token-abc'), '******')
|
||||||
|
const state = revealSecret(createSecretRevealState(), 1, 'token-abc', 1000, 1000 + 5000)
|
||||||
|
assert.equal(state.keyId, 1)
|
||||||
|
assert.equal(state.token, 'token-abc')
|
||||||
|
assert.equal(maskSensitive(state.token || ''), '******', '明文仍可再掩码展示')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_086_secret_mask_normal_variant_input', () => {
|
||||||
|
// 正常变体:不同 id 揭示互不串扰,短时窗口默认 5 秒。
|
||||||
|
const s1 = revealSecret(createSecretRevealState(), 2, 't2', 1000, 1000 + 5000)
|
||||||
|
const s2 = revealSecret(s1, 3, 't3', 1000, 1000 + 5000)
|
||||||
|
assert.equal(s2.keyId, 3)
|
||||||
|
assert.equal(s2.token, 't3')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_086_secret_mask_repeated_is_idempotent', () => {
|
||||||
|
// 正常重复:重复揭示同一 id 结果稳定;过期清理不改原状态。
|
||||||
|
const a = revealSecret(createSecretRevealState(), 5, 't5', 1000, 6000)
|
||||||
|
assert.deepEqual(revealSecret(a, 5, 't5', 2000, 6000), a)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_086_secret_mask_boundary_empty_input', () => {
|
||||||
|
// 边界空值:空字符串/占位符不揭示真实令牌。
|
||||||
|
assert.equal(maskSensitive(''), secretPlaceholder())
|
||||||
|
const state = revealSecret(createSecretRevealState(), 1, '', 0, 5000)
|
||||||
|
assert.equal(state.token, '', '空白令牌揭示后仍为空')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_086_secret_mask_boundary_single_item', () => {
|
||||||
|
// 边界单元素:单条揭示(掩码占位符与明文切换)完整。
|
||||||
|
const empty = createSecretRevealState()
|
||||||
|
assert.equal(empty.keyId, null)
|
||||||
|
assert.equal(empty.token, undefined)
|
||||||
|
const shown = revealSecret(empty, 9, 'tok9', 1000, 6000)
|
||||||
|
assert.equal(shown.token, 'tok9')
|
||||||
|
const expired = expireReveal(shown, 7000)
|
||||||
|
assert.equal(expired.keyId, null)
|
||||||
|
assert.equal(expired.token, undefined, '超时后明文被清空')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_086_secret_mask_boundary_limit_or_missing_field', () => {
|
||||||
|
// 边界上限:超长令牌截断展示时只暴露头部,掩码不因截断丢失语义。
|
||||||
|
const long = 'a'.repeat(600)
|
||||||
|
assert.equal(maskSensitive(long), secretPlaceholder())
|
||||||
|
assert.equal(secretPlaceholder().length, 6)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_086_secret_mask_invalid_input_rejected', () => {
|
||||||
|
// 异常输入:揭示未来/过期时间不落入可展示明文状态之外;非字符串按空处理。
|
||||||
|
assert.equal(maskSensitive(undefined as unknown as string), secretPlaceholder())
|
||||||
|
assert.equal(maskSensitive(null as unknown as string), secretPlaceholder())
|
||||||
|
const state = revealSecret(createSecretRevealState(), 1, 't', 1000, 999)
|
||||||
|
assert.equal(expireReveal(state, 1000).token, undefined, '过期窗口揭示即刻被清理')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_086_secret_mask_dependency_failure_returns_actionable_message', () => {
|
||||||
|
// 依赖失败/可操作:掩码与揭示会话纯逻辑;令牌明文不进入日志/存储读写。
|
||||||
|
const mod = readSource('src/pages/shop/secret-mask.ts')
|
||||||
|
assert.equal(/axios|http\.|vue|localStorage|console\./.test(mod), false, '掩码模块保持纯逻辑')
|
||||||
|
assert.match(mod, /敏感|掩码/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user