mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-08-29 00:41:25 +08:00
feat(apiKeyStrength): implement API key strength meter and evaluation logic
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { memo, useEffect, useMemo, useRef, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API_KEY_STRENGTH_SEGMENTS,
|
||||
evaluateApiKeyStrength,
|
||||
type ApiKeyStrengthTier,
|
||||
} from '@/utils/apiKeyStrength';
|
||||
import { segmentFillDelayMs } from './shared';
|
||||
import styles from './Blocks.module.scss';
|
||||
|
||||
// 三档语义色 + 段数承担第四档的区分:翡翠绿留给「活的流量」,此处用语义 success。
|
||||
const TIER_COLORS: Record<ApiKeyStrengthTier, string> = {
|
||||
weak: 'var(--error-color)',
|
||||
fair: 'var(--amber-color)',
|
||||
good: 'var(--success-color)',
|
||||
strong: 'var(--success-color)',
|
||||
};
|
||||
|
||||
const SEGMENT_INDEXES = Array.from({ length: API_KEY_STRENGTH_SEGMENTS }, (_, index) => index);
|
||||
|
||||
/**
|
||||
* 自拟 API Key 的强度参考条:四段依次点亮,只给档位标签,不参与保存校验。
|
||||
*/
|
||||
export const ApiKeyStrengthMeter = memo(function ApiKeyStrengthMeter({ value }: { value: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { tier, segments } = useMemo(() => evaluateApiKeyStrength(value), [value]);
|
||||
|
||||
// 上一次的段数决定这次谁需要排队;渲染只读,提交后再推进
|
||||
const previousSegments = useRef(segments);
|
||||
const cascadeFrom = previousSegments.current;
|
||||
useEffect(() => {
|
||||
previousSegments.current = segments;
|
||||
}, [segments]);
|
||||
|
||||
const empty = segments === 0;
|
||||
const tierLabel = empty
|
||||
? t('config_management.visual.api_keys.strength.empty')
|
||||
: t(`config_management.visual.api_keys.strength.${tier}`);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.strengthMeter}
|
||||
style={
|
||||
{
|
||||
'--strength-color': empty ? 'var(--text-quaternary)' : TIER_COLORS[tier],
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={styles.strengthTrack}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={API_KEY_STRENGTH_SEGMENTS}
|
||||
aria-valuenow={segments}
|
||||
aria-valuetext={tierLabel}
|
||||
aria-label={t('config_management.visual.api_keys.strength.label')}
|
||||
>
|
||||
{SEGMENT_INDEXES.map((index) => (
|
||||
<span key={index} className={styles.strengthSegment}>
|
||||
<span
|
||||
className={styles.strengthSegmentFill}
|
||||
data-filled={index < segments}
|
||||
style={
|
||||
{
|
||||
'--segment-delay': `${segmentFillDelayMs(index, segments, cascadeFrom)}ms`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className={styles.strengthLabel} aria-hidden="true">
|
||||
{empty ? '—' : tierLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { makeClientId } from '@/types/visualConfig';
|
||||
import { generateSecureApiKey } from '@/utils/apiKey';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { isValidApiKeyCharset } from '@/utils/validation';
|
||||
import { ApiKeyStrengthMeter } from './ApiKeyStrengthMeter';
|
||||
import styles from './Blocks.module.scss';
|
||||
|
||||
export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
|
||||
@@ -219,6 +220,7 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
|
||||
{t('config_management.visual.api_keys.generate')}
|
||||
</Button>
|
||||
</div>
|
||||
<ApiKeyStrengthMeter value={inputValue} />
|
||||
<div id={apiKeyHintId} className="hint">
|
||||
{t('config_management.visual.api_keys.input_hint')}
|
||||
</div>
|
||||
|
||||
@@ -151,6 +151,67 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 强度参考条:四段承载档位,颜色承载严重度。
|
||||
段的填充走 scaleX(只碰 transform),键入时逐字重算也不掉帧。 */
|
||||
|
||||
.strengthMeter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.strengthTrack {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.strengthSegment {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: $radius-full;
|
||||
overflow: hidden;
|
||||
// 轨道 = 填充色的淡化步阶,未点亮的段也带着当前状态
|
||||
background: color-mix(in srgb, var(--strength-color) 16%, transparent);
|
||||
transition: background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
|
||||
}
|
||||
|
||||
.strengthSegmentFill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--strength-color);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
// 段内比常规交互再快一档,把预算让给段间的 stagger
|
||||
transition:
|
||||
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out),
|
||||
background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
|
||||
|
||||
// 延迟写在目标态上:点亮时依次排队,熄灭是系统响应,立刻发生
|
||||
&[data-filled='true'] {
|
||||
transform: scaleX(1);
|
||||
transition-delay: var(--segment-delay, 0ms);
|
||||
}
|
||||
|
||||
// 降级只去掉位移和排队,颜色变化保留(它才是状态本身)
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: background-color var(--dur-hover, 200ms) linear;
|
||||
|
||||
&[data-filled='true'] {
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.strengthLabel {
|
||||
min-height: 16px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--strength-color);
|
||||
transition: color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
|
||||
}
|
||||
|
||||
/* ---------- 插件源认证 ---------- */
|
||||
|
||||
.storeAuthEditor {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 载荷/规则编辑器共享的纯工具(从旧 VisualConfigEditorBlocks 原样迁出,
|
||||
// 区块编辑器共享的纯工具(载荷/规则部分从旧 VisualConfigEditorBlocks 原样迁出,
|
||||
// 独立成文件以规避组件文件导出非组件的 react-refresh 限制)。
|
||||
|
||||
import type { useTranslation } from 'react-i18next';
|
||||
@@ -40,3 +40,20 @@ export function buildProtocolOptions(
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/** API Key 强度条相邻两段点亮的间隔;4 段全亮 = 135ms + 段内 160ms,整组仍在 300ms 内 */
|
||||
export const SEGMENT_STAGGER_MS = 45;
|
||||
|
||||
/**
|
||||
* 强度条段填充的起跑延迟:只有本次新增的段排队,已亮的段和熄灭都不延迟。
|
||||
* 因此「生成」一次点亮四段是依次的波,而键入让强度 +1 段是即时的。
|
||||
*/
|
||||
export function segmentFillDelayMs(
|
||||
index: number,
|
||||
segments: number,
|
||||
previousSegments: number
|
||||
): number {
|
||||
const filled = index < segments;
|
||||
if (!filled || index < previousSegments) return 0;
|
||||
return (index - Math.max(previousSegments, 0)) * SEGMENT_STAGGER_MS;
|
||||
}
|
||||
|
||||
@@ -1077,7 +1077,15 @@
|
||||
"input_placeholder": "Paste your API key",
|
||||
"input_hint": "This only modifies the local config file content, it will not sync to the API Key Management interface",
|
||||
"error_empty": "Please enter an API key",
|
||||
"error_invalid": "API key contains invalid characters"
|
||||
"error_invalid": "API key contains invalid characters",
|
||||
"strength": {
|
||||
"label": "API key strength",
|
||||
"empty": "Not entered",
|
||||
"weak": "Weak",
|
||||
"fair": "Fair",
|
||||
"good": "Good",
|
||||
"strong": "Strong"
|
||||
}
|
||||
},
|
||||
"payload_rules": {
|
||||
"rule": "Rule",
|
||||
|
||||
@@ -1064,7 +1064,15 @@
|
||||
"input_placeholder": "Вставьте API-ключ",
|
||||
"input_hint": "Меняет только содержимое локального файла конфигурации, не синхронизируется с интерфейсом управления API-ключами",
|
||||
"error_empty": "Введите API-ключ",
|
||||
"error_invalid": "API-ключ содержит недопустимые символы"
|
||||
"error_invalid": "API-ключ содержит недопустимые символы",
|
||||
"strength": {
|
||||
"label": "Надёжность API-ключа",
|
||||
"empty": "Не введено",
|
||||
"weak": "Слабый",
|
||||
"fair": "Средний",
|
||||
"good": "Хороший",
|
||||
"strong": "Надёжный"
|
||||
}
|
||||
},
|
||||
"payload_rules": {
|
||||
"rule": "Правило",
|
||||
|
||||
@@ -1077,7 +1077,15 @@
|
||||
"input_placeholder": "粘贴你的 API 密钥",
|
||||
"input_hint": "此处仅修改本地配置文件内容,不会自动同步到 API 密钥管理接口",
|
||||
"error_empty": "请输入 API 密钥",
|
||||
"error_invalid": "API 密钥包含无效字符"
|
||||
"error_invalid": "API 密钥包含无效字符",
|
||||
"strength": {
|
||||
"label": "API 密钥强度",
|
||||
"empty": "待输入",
|
||||
"weak": "弱",
|
||||
"fair": "一般",
|
||||
"good": "良好",
|
||||
"strong": "很强"
|
||||
}
|
||||
},
|
||||
"payload_rules": {
|
||||
"rule": "规则",
|
||||
|
||||
@@ -1103,7 +1103,15 @@
|
||||
"input_placeholder": "貼上你的 API 金鑰",
|
||||
"input_hint": "此處僅修改本地設定檔內容,不會自動同步到 API 金鑰管理介面",
|
||||
"error_empty": "請輸入 API 金鑰",
|
||||
"error_invalid": "API 金鑰包含無效字元"
|
||||
"error_invalid": "API 金鑰包含無效字元",
|
||||
"strength": {
|
||||
"label": "API 金鑰強度",
|
||||
"empty": "待輸入",
|
||||
"weak": "弱",
|
||||
"fair": "一般",
|
||||
"good": "良好",
|
||||
"strong": "很強"
|
||||
}
|
||||
},
|
||||
"payload_rules": {
|
||||
"rule": "規則",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* API Key 强度评估。
|
||||
*
|
||||
* 模型:字符集熵 × 可预测性折扣 → 四档。纯函数,UI 只消费 tier/segments,
|
||||
* 因此评分口径的调整不会牵动组件。
|
||||
*/
|
||||
|
||||
export type ApiKeyStrengthTier = 'weak' | 'fair' | 'good' | 'strong';
|
||||
|
||||
export interface ApiKeyStrength {
|
||||
tier: ApiKeyStrengthTier;
|
||||
/** 点亮的段数(0–4);0 表示空输入 */
|
||||
segments: number;
|
||||
/** 估算熵,向下取整到 bit */
|
||||
bits: number;
|
||||
}
|
||||
|
||||
/** 档位由弱到强,索引即点亮段数 - 1 */
|
||||
const TIER_ORDER: readonly ApiKeyStrengthTier[] = ['weak', 'fair', 'good', 'strong'];
|
||||
|
||||
export const API_KEY_STRENGTH_SEGMENTS = TIER_ORDER.length;
|
||||
|
||||
// 字符集大小:与 isValidApiKeyCharset 允许的 ASCII 可见字符对齐(0x21–0x7E 共 94 个)
|
||||
const CHARSET_CLASSES: readonly { pattern: RegExp; size: number }[] = [
|
||||
{ pattern: /[a-z]/, size: 26 },
|
||||
{ pattern: /[A-Z]/, size: 26 },
|
||||
{ pattern: /[0-9]/, size: 10 },
|
||||
{ pattern: /[^a-zA-Z0-9]/, size: 32 },
|
||||
];
|
||||
|
||||
/** 一眼可猜的口令片段,命中即大幅折价 */
|
||||
const GUESSABLE_TOKENS: readonly string[] = [
|
||||
'password',
|
||||
'passwd',
|
||||
'123456',
|
||||
'qwerty',
|
||||
'admin',
|
||||
'secret',
|
||||
'apikey',
|
||||
'api-key',
|
||||
'letmein',
|
||||
'changeme',
|
||||
'iloveyou',
|
||||
'default',
|
||||
'test',
|
||||
'demo',
|
||||
];
|
||||
|
||||
// 重复字符与顺序字符几乎不贡献猜测成本,按残值计入有效长度
|
||||
const REPEAT_WEIGHT = 0.25;
|
||||
const SEQUENCE_WEIGHT = 0.35;
|
||||
const GUESSABLE_FACTOR = 0.4;
|
||||
|
||||
// 熵阈值(bit)。48 位随机 base62 ≈ 285 bit,32 位十六进制 = 128 bit。
|
||||
const BITS_FOR_FAIR = 40;
|
||||
const BITS_FOR_GOOD = 64;
|
||||
const BITS_FOR_STRONG = 96;
|
||||
|
||||
// 长度封顶:熵再高也挡不住短串被离线爆破,短于阈值就锁在对应档
|
||||
const LENGTH_CAPS: readonly { below: number; tier: ApiKeyStrengthTier }[] = [
|
||||
{ below: 8, tier: 'weak' },
|
||||
{ below: 16, tier: 'fair' },
|
||||
{ below: 24, tier: 'good' },
|
||||
];
|
||||
|
||||
/** 字符种类过少时,长度带来的熵是假的(如 32 个 a) */
|
||||
const MIN_UNIQUE_FOR_FAIR = 5;
|
||||
|
||||
/**
|
||||
* 有效长度:与前一字符相同、或延续升/降序列的字符只按残值计入。
|
||||
* 随机串几乎不触发折扣,规律串会被显著压缩。
|
||||
*/
|
||||
function effectiveLength(key: string): number {
|
||||
let total = 0;
|
||||
let sequenceRun = 1;
|
||||
|
||||
for (let index = 0; index < key.length; index += 1) {
|
||||
const code = key.charCodeAt(index);
|
||||
const previous = index > 0 ? key.charCodeAt(index - 1) : Number.NaN;
|
||||
|
||||
if (code === previous) {
|
||||
total += REPEAT_WEIGHT;
|
||||
sequenceRun = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const delta = code - previous;
|
||||
if (delta === 1 || delta === -1) {
|
||||
sequenceRun += 1;
|
||||
// 前两个字符仍算新信息,第三个起才是可预测的顺序
|
||||
total += sequenceRun >= 3 ? SEQUENCE_WEIGHT : 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
sequenceRun = 1;
|
||||
total += 1;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 最小周期长度:`deadbeefdeadbeef` → 8,`abcabca` → 3,无周期则返回原长。
|
||||
* 用 (s + s).indexOf(s, 1) 求解,尾部不完整的周期同样能识别。
|
||||
*/
|
||||
function smallestPeriod(key: string): number {
|
||||
const period = `${key}${key}`.indexOf(key, 1);
|
||||
return period > 0 && period < key.length ? period : key.length;
|
||||
}
|
||||
|
||||
function charsetSize(key: string): number {
|
||||
return CHARSET_CLASSES.reduce(
|
||||
(size, charClass) => (charClass.pattern.test(key) ? size + charClass.size : size),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function tierForBits(bits: number): ApiKeyStrengthTier {
|
||||
if (bits >= BITS_FOR_STRONG) return 'strong';
|
||||
if (bits >= BITS_FOR_GOOD) return 'good';
|
||||
if (bits >= BITS_FOR_FAIR) return 'fair';
|
||||
return 'weak';
|
||||
}
|
||||
|
||||
function capTier(tier: ApiKeyStrengthTier, cap: ApiKeyStrengthTier): ApiKeyStrengthTier {
|
||||
return TIER_ORDER.indexOf(tier) <= TIER_ORDER.indexOf(cap) ? tier : cap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估用户自拟 API Key 的强度。仅供参考,不参与保存校验。
|
||||
*/
|
||||
export function evaluateApiKeyStrength(rawKey: string): ApiKeyStrength {
|
||||
const key = rawKey.trim();
|
||||
if (!key) return { tier: 'weak', segments: 0, bits: 0 };
|
||||
|
||||
const pool = charsetSize(key);
|
||||
const lowerCased = key.toLowerCase();
|
||||
const guessable = GUESSABLE_TOKENS.some((token) => lowerCased.includes(token));
|
||||
// 周期串的猜测成本只等于一个周期,重复部分按残值计入
|
||||
const period = smallestPeriod(key);
|
||||
const length = effectiveLength(key.slice(0, period)) + (key.length - period) * REPEAT_WEIGHT;
|
||||
const bits = Math.floor(length * Math.log2(pool) * (guessable ? GUESSABLE_FACTOR : 1));
|
||||
|
||||
let tier = tierForBits(bits);
|
||||
for (const { below, tier: cap } of LENGTH_CAPS) {
|
||||
if (key.length < below) tier = capTier(tier, cap);
|
||||
}
|
||||
if (new Set(key).size < MIN_UNIQUE_FOR_FAIR) tier = capTier(tier, 'weak');
|
||||
|
||||
return { tier, segments: TIER_ORDER.indexOf(tier) + 1, bits };
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { generateSecureApiKey } from '../src/utils/apiKey';
|
||||
import {
|
||||
API_KEY_STRENGTH_SEGMENTS,
|
||||
evaluateApiKeyStrength,
|
||||
type ApiKeyStrengthTier,
|
||||
} from '../src/utils/apiKeyStrength';
|
||||
|
||||
const TIER_ORDER: ApiKeyStrengthTier[] = ['weak', 'fair', 'good', 'strong'];
|
||||
|
||||
describe('API key strength', () => {
|
||||
test('empty input lights no segment', () => {
|
||||
for (const value of ['', ' ']) {
|
||||
expect(evaluateApiKeyStrength(value)).toEqual({ tier: 'weak', segments: 0, bits: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
test('segments track the tier index', () => {
|
||||
const samples = ['a', 'Tr0ub4dor', 'Xk7#mQ2vLp9$Rn4wZt6c', generateSecureApiKey()];
|
||||
|
||||
for (const sample of samples) {
|
||||
const { tier, segments } = evaluateApiKeyStrength(sample);
|
||||
expect(segments).toBe(TIER_ORDER.indexOf(tier) + 1);
|
||||
expect(segments).toBeLessThanOrEqual(API_KEY_STRENGTH_SEGMENTS);
|
||||
}
|
||||
});
|
||||
|
||||
test('generated keys always reach the top tier', () => {
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const { tier, segments } = evaluateApiKeyStrength(generateSecureApiKey());
|
||||
expect(tier).toBe('strong');
|
||||
expect(segments).toBe(API_KEY_STRENGTH_SEGMENTS);
|
||||
}
|
||||
});
|
||||
|
||||
test('short keys are capped regardless of charset richness', () => {
|
||||
// 7 位就算四类字符齐全也只能是最弱档
|
||||
expect(evaluateApiKeyStrength('aA1!bB2').tier).toBe('weak');
|
||||
// 15 位封顶在第二档
|
||||
expect(evaluateApiKeyStrength('aA1!bB2@cC3#dD4').tier).toBe('fair');
|
||||
// 23 位封顶在第三档
|
||||
expect(evaluateApiKeyStrength('aA1!bB2@cC3#dD4$eE5%fG').tier).toBe('good');
|
||||
});
|
||||
|
||||
test('repeated and sequential runs are discounted', () => {
|
||||
const repeated = evaluateApiKeyStrength('a'.repeat(48));
|
||||
expect(repeated.tier).toBe('weak');
|
||||
|
||||
const sequential = evaluateApiKeyStrength('abcdefghijklmnopqrstuvwxyz0123456789');
|
||||
const shuffled = evaluateApiKeyStrength('qzmXe4Rk9BtLw7Ncy2VsJp5Ghd8FaU3Zmr6Q');
|
||||
expect(sequential.bits).toBeLessThan(shuffled.bits);
|
||||
});
|
||||
|
||||
test('periodic keys score like a single period', () => {
|
||||
// 32 位却只有 8 位的猜测成本
|
||||
expect(evaluateApiKeyStrength('deadbeefdeadbeefdeadbeefdeadbeef').tier).toBe('fair');
|
||||
expect(evaluateApiKeyStrength('abababababababababababababab').tier).toBe('weak');
|
||||
// 尾部不完整的周期同样识别
|
||||
const periodic = evaluateApiKeyStrength('myproxy2024myproxy2024myproxy');
|
||||
const aperiodic = evaluateApiKeyStrength('myproxy2024ZtRv4Ns8Lc3Bd7hQwK');
|
||||
expect(periodic.bits).toBeLessThan(aperiodic.bits);
|
||||
});
|
||||
|
||||
test('guessable tokens drag the score down', () => {
|
||||
const withToken = evaluateApiKeyStrength('sk-password-9fKw2mQx7ZtRv4Ns8Lc3Bd');
|
||||
const withoutToken = evaluateApiKeyStrength('sk-hRvnqtwj-9fKw2mQx7ZtRv4Ns8Lc3Bd');
|
||||
|
||||
expect(withToken.bits).toBeLessThan(withoutToken.bits);
|
||||
expect(TIER_ORDER.indexOf(withToken.tier)).toBeLessThan(TIER_ORDER.indexOf(withoutToken.tier));
|
||||
});
|
||||
|
||||
test('longer keys never score below their prefix', () => {
|
||||
const base = 'Xk7#mQ2vLp9$Rn4wZt6cHj8&Bd5xVy3';
|
||||
let previous = 0;
|
||||
|
||||
for (let length = 1; length <= base.length; length += 1) {
|
||||
const { bits } = evaluateApiKeyStrength(base.slice(0, length));
|
||||
expect(bits).toBeGreaterThanOrEqual(previous);
|
||||
previous = bits;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import i18n from '@/i18n';
|
||||
import { ApiKeyStrengthMeter } from '@/features/config/components/blocks/ApiKeyStrengthMeter';
|
||||
import { SEGMENT_STAGGER_MS, segmentFillDelayMs } from '@/features/config/components/blocks/shared';
|
||||
import { generateSecureApiKey } from '@/utils/apiKey';
|
||||
|
||||
const LOCALES = ['en', 'zh-CN', 'zh-TW', 'ru'];
|
||||
|
||||
describe('ApiKeyStrengthMeter', () => {
|
||||
test('exposes the tier through the progressbar', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(ApiKeyStrengthMeter, { value: generateSecureApiKey() })
|
||||
);
|
||||
|
||||
expect(markup).toContain('aria-valuenow="4"');
|
||||
expect(markup).toContain('aria-valuemax="4"');
|
||||
expect(markup).toContain(
|
||||
`aria-valuetext="${i18n.t('config_management.visual.api_keys.strength.strong')}"`
|
||||
);
|
||||
expect(markup.match(/data-filled="true"/g)).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('empty input lights nothing and shows a placeholder label', () => {
|
||||
const markup = renderToStaticMarkup(createElement(ApiKeyStrengthMeter, { value: '' }));
|
||||
|
||||
expect(markup).toContain('aria-valuenow="0"');
|
||||
expect(markup).not.toContain('data-filled="true"');
|
||||
expect(markup).toContain('—');
|
||||
});
|
||||
|
||||
test('segments cascade only over the newly lit ones', () => {
|
||||
const delays = (segments: number, previous: number) =>
|
||||
[0, 1, 2, 3].map((index) => segmentFillDelayMs(index, segments, previous));
|
||||
|
||||
// 0 → 4(点「生成」):四段依次起跑
|
||||
expect(delays(4, 0)).toEqual([
|
||||
0,
|
||||
SEGMENT_STAGGER_MS,
|
||||
SEGMENT_STAGGER_MS * 2,
|
||||
SEGMENT_STAGGER_MS * 3,
|
||||
]);
|
||||
// 2 → 3(键入一个字符):新增的那段立刻亮,不为它的下标排队
|
||||
expect(delays(3, 2)).toEqual([0, 0, 0, 0]);
|
||||
// 1 → 3:只有新增的两段排队
|
||||
expect(delays(3, 1)).toEqual([0, 0, SEGMENT_STAGGER_MS, 0]);
|
||||
// 4 → 2(删字符):熄灭立即发生
|
||||
expect(delays(2, 4)).toEqual([0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
test('every tier label is translated in all locales', async () => {
|
||||
const original = i18n.language;
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await i18n.changeLanguage(locale);
|
||||
for (const key of ['label', 'empty', 'weak', 'fair', 'good', 'strong']) {
|
||||
const path = `config_management.visual.api_keys.strength.${key}`;
|
||||
expect(i18n.exists(path)).toBe(true);
|
||||
expect(i18n.t(path)).not.toBe(path);
|
||||
}
|
||||
}
|
||||
|
||||
await i18n.changeLanguage(original);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user