569 lines
18 KiB
TypeScript
569 lines
18 KiB
TypeScript
/**
|
||
* Тесты для IPC validation layer.
|
||
*
|
||
* Этот слой — security-boundary между renderer и main. Если он сломается,
|
||
* compromised renderer сможет писать в стор NaN, отрицательные, Infinity,
|
||
* сверхдлинные строки или undefined-enum'ы. Поэтому покрытие важно для:
|
||
*
|
||
* 1. Тип-проверок (строка/число/булево/массив)
|
||
* 2. Range-checks (reps ∈ [1,9999], minutes ∈ [1,1440] и т.д.)
|
||
* 3. Enum allowlist (theme/lang/notify-mode/stat)
|
||
* 4. Edge cases: NaN, Infinity, MAX_SAFE_INTEGER, 0, отрицательные, длина строк
|
||
* 5. Partial-patch semantics (отсутствие поля ≠ невалидное значение)
|
||
* 6. Сложный nested case: quietHours с HH:MM regex и dedup days
|
||
*/
|
||
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
validateExerciseInput,
|
||
validateExercisePatch,
|
||
validateChallengeInput,
|
||
validateChallengePatch,
|
||
validateMealInput,
|
||
validateMealPatch,
|
||
validateSettingsPatch,
|
||
validateId,
|
||
validateActualReps,
|
||
validateSnoozeMinutes
|
||
} from './validate'
|
||
|
||
describe('validateMealInput', () => {
|
||
it('принимает валидный приём пищи', () => {
|
||
const r = validateMealInput({
|
||
name: 'Обед',
|
||
time: '13:00',
|
||
icon: 'Soup',
|
||
enabled: true,
|
||
days: [1, 2, 3, 4, 5]
|
||
})
|
||
expect(r).toEqual({
|
||
name: 'Обед',
|
||
time: '13:00',
|
||
icon: 'Soup',
|
||
enabled: true,
|
||
days: [1, 2, 3, 4, 5]
|
||
})
|
||
})
|
||
|
||
it('дефолтит icon и enabled', () => {
|
||
const r = validateMealInput({ name: 'Ужин', time: '19:00', days: [] })
|
||
expect(r?.icon).toBe('UtensilsCrossed')
|
||
expect(r?.enabled).toBe(true)
|
||
})
|
||
|
||
it('реджектит без имени / времени', () => {
|
||
expect(validateMealInput({ time: '13:00', days: [] })).toBeNull()
|
||
expect(validateMealInput({ name: 'X', days: [] })).toBeNull()
|
||
})
|
||
|
||
it('реджектит кривое время', () => {
|
||
expect(validateMealInput({ name: 'X', time: '99:99', days: [] })).toBeNull()
|
||
expect(validateMealInput({ name: 'X', time: 'noon', days: [] })).toBeNull()
|
||
})
|
||
|
||
it('реджектит дни вне диапазона и дедупит', () => {
|
||
expect(
|
||
validateMealInput({ name: 'X', time: '13:00', days: [7] })
|
||
).toBeNull()
|
||
const r = validateMealInput({
|
||
name: 'X',
|
||
time: '13:00',
|
||
days: [1, 1, 2]
|
||
})
|
||
expect(r?.days).toEqual([1, 2])
|
||
})
|
||
|
||
it('реджектит не-объект', () => {
|
||
expect(validateMealInput(null)).toBeNull()
|
||
expect(validateMealInput('meal')).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('validateMealPatch', () => {
|
||
it('частичный патч только заданных полей', () => {
|
||
expect(validateMealPatch({ time: '07:30' })).toEqual({ time: '07:30' })
|
||
expect(validateMealPatch({ enabled: false })).toEqual({ enabled: false })
|
||
})
|
||
|
||
it('реджектит кривое время в патче', () => {
|
||
expect(validateMealPatch({ time: '25:00' })).toBeNull()
|
||
})
|
||
|
||
it('пропускает scheduler-поля с range-check', () => {
|
||
expect(validateMealPatch({ nextFireAt: 123 })).toEqual({ nextFireAt: 123 })
|
||
expect(validateMealPatch({ nextFireAt: -1 })).toBeNull()
|
||
})
|
||
|
||
it('реджектит кривые дни', () => {
|
||
expect(validateMealPatch({ days: [0, 8] })).toBeNull()
|
||
})
|
||
})
|
||
|
||
const validExercise = {
|
||
name: 'Push-ups',
|
||
reps: 10,
|
||
intervalMinutes: 30,
|
||
icon: 'Dumbbell',
|
||
enabled: true
|
||
}
|
||
|
||
describe('validateExerciseInput', () => {
|
||
it('accepts a fully-formed valid input', () => {
|
||
expect(validateExerciseInput(validExercise)).toEqual(validExercise)
|
||
})
|
||
|
||
it('rejects non-objects', () => {
|
||
expect(validateExerciseInput(null)).toBeNull()
|
||
expect(validateExerciseInput(undefined)).toBeNull()
|
||
expect(validateExerciseInput('string')).toBeNull()
|
||
expect(validateExerciseInput(42)).toBeNull()
|
||
expect(validateExerciseInput([])).toBeNull() // arrays not allowed
|
||
})
|
||
|
||
it('rejects missing required fields', () => {
|
||
expect(
|
||
validateExerciseInput({ ...validExercise, name: undefined })
|
||
).toBeNull()
|
||
expect(
|
||
validateExerciseInput({ ...validExercise, reps: undefined })
|
||
).toBeNull()
|
||
expect(
|
||
validateExerciseInput({ ...validExercise, intervalMinutes: undefined })
|
||
).toBeNull()
|
||
})
|
||
|
||
it('rejects out-of-range reps', () => {
|
||
expect(validateExerciseInput({ ...validExercise, reps: 0 })).toBeNull()
|
||
expect(validateExerciseInput({ ...validExercise, reps: -1 })).toBeNull()
|
||
expect(validateExerciseInput({ ...validExercise, reps: 10_000 })).toBeNull()
|
||
expect(validateExerciseInput({ ...validExercise, reps: NaN })).toBeNull()
|
||
expect(
|
||
validateExerciseInput({ ...validExercise, reps: Infinity })
|
||
).toBeNull()
|
||
})
|
||
|
||
it('truncates reps with Math.trunc (5.7 → 5)', () => {
|
||
const r = validateExerciseInput({ ...validExercise, reps: 5.7 })
|
||
expect(r?.reps).toBe(5)
|
||
})
|
||
|
||
it('rejects out-of-range intervalMinutes (> 24h)', () => {
|
||
expect(
|
||
validateExerciseInput({ ...validExercise, intervalMinutes: 0 })
|
||
).toBeNull()
|
||
expect(
|
||
validateExerciseInput({ ...validExercise, intervalMinutes: 1441 })
|
||
).toBeNull()
|
||
expect(
|
||
validateExerciseInput({ ...validExercise, intervalMinutes: -1 })
|
||
).toBeNull()
|
||
})
|
||
|
||
it('rejects empty name', () => {
|
||
expect(validateExerciseInput({ ...validExercise, name: '' })).toBeNull()
|
||
})
|
||
|
||
it('rejects name longer than MAX_STR_LEN (200)', () => {
|
||
expect(
|
||
validateExerciseInput({ ...validExercise, name: 'x'.repeat(201) })
|
||
).toBeNull()
|
||
})
|
||
|
||
it('accepts name exactly at MAX_STR_LEN', () => {
|
||
const r = validateExerciseInput({ ...validExercise, name: 'x'.repeat(200) })
|
||
expect(r?.name).toHaveLength(200)
|
||
})
|
||
|
||
it('defaults icon to Activity if missing', () => {
|
||
const { icon: _ignored, ...rest } = validExercise
|
||
void _ignored
|
||
expect(validateExerciseInput(rest)?.icon).toBe('Activity')
|
||
})
|
||
|
||
it('defaults enabled to true if missing', () => {
|
||
const { enabled: _ignored, ...rest } = validExercise
|
||
void _ignored
|
||
expect(validateExerciseInput(rest)?.enabled).toBe(true)
|
||
})
|
||
|
||
// Дизайн validateExerciseInput: required-поля (name/reps/intervalMinutes)
|
||
// строгие — невалидное значение reject'ит весь input. Optional-поля
|
||
// (icon/enabled) lenient — невалидное молча подменяется дефолтом. Это
|
||
// фиксирует контракт: malicious renderer не сможет создать запись с
|
||
// reps=-1, но если он пришлёт `enabled: 'yes'`, получит просто enabled=true.
|
||
it('coerces invalid enabled to true (lenient default for optional fields)', () => {
|
||
expect(
|
||
validateExerciseInput({ ...validExercise, enabled: 'yes' })?.enabled
|
||
).toBe(true)
|
||
expect(
|
||
validateExerciseInput({ ...validExercise, enabled: 1 })?.enabled
|
||
).toBe(true)
|
||
})
|
||
|
||
// А вот в patch optional-поля строгие — нет defaults, есть `if (v ===
|
||
// undefined) return null`. Это правильнее: если renderer пришёл с патчем,
|
||
// в котором есть поле, оно должно быть валидным.
|
||
it('strict patch: rejects invalid enabled in patch (unlike input)', () => {
|
||
expect(validateExercisePatch({ enabled: 'yes' })).toBeNull()
|
||
expect(validateExercisePatch({ enabled: 1 })).toBeNull()
|
||
})
|
||
|
||
it('rejects non-string name', () => {
|
||
expect(validateExerciseInput({ ...validExercise, name: 42 })).toBeNull()
|
||
expect(validateExerciseInput({ ...validExercise, name: null })).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('validateExercisePatch', () => {
|
||
it('accepts an empty patch (no-op update)', () => {
|
||
expect(validateExercisePatch({})).toEqual({})
|
||
})
|
||
|
||
it('accepts partial patches', () => {
|
||
expect(validateExercisePatch({ reps: 12 })).toEqual({ reps: 12 })
|
||
expect(validateExercisePatch({ name: 'New' })).toEqual({ name: 'New' })
|
||
expect(validateExercisePatch({ enabled: false })).toEqual({
|
||
enabled: false
|
||
})
|
||
})
|
||
|
||
it('rejects patch with a single invalid field', () => {
|
||
// Patch is all-or-nothing: one bad field rejects the whole patch.
|
||
expect(validateExercisePatch({ name: 'OK', reps: -1 })).toBeNull()
|
||
expect(validateExercisePatch({ name: '', reps: 10 })).toBeNull()
|
||
})
|
||
|
||
it('rejects non-object', () => {
|
||
expect(validateExercisePatch(null)).toBeNull()
|
||
expect(validateExercisePatch([])).toBeNull()
|
||
})
|
||
|
||
it('accepts nextFireAt and lastDoneAt with valid ranges', () => {
|
||
expect(validateExercisePatch({ nextFireAt: 0 })).toEqual({ nextFireAt: 0 })
|
||
expect(validateExercisePatch({ lastDoneAt: 1_000_000_000_000 })).toEqual({
|
||
lastDoneAt: 1_000_000_000_000
|
||
})
|
||
})
|
||
|
||
it('rejects negative timestamps', () => {
|
||
expect(validateExercisePatch({ nextFireAt: -1 })).toBeNull()
|
||
expect(validateExercisePatch({ lastDoneAt: -1 })).toBeNull()
|
||
})
|
||
|
||
it('rejects NaN/Infinity timestamps', () => {
|
||
expect(validateExercisePatch({ nextFireAt: NaN })).toBeNull()
|
||
expect(validateExercisePatch({ nextFireAt: Infinity })).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('validateChallengeInput', () => {
|
||
const valid = {
|
||
name: 'Deaths → squats',
|
||
gameId: 'dota2',
|
||
stat: 'deaths' as const,
|
||
multiplier: 3,
|
||
exerciseName: 'Приседания',
|
||
icon: 'Activity',
|
||
enabled: true
|
||
}
|
||
|
||
it('accepts valid input', () => {
|
||
expect(validateChallengeInput(valid)).toEqual(valid)
|
||
})
|
||
|
||
it('rejects unknown stat', () => {
|
||
expect(validateChallengeInput({ ...valid, stat: 'pizza' })).toBeNull()
|
||
})
|
||
|
||
it('accepts all valid stats', () => {
|
||
const stats = [
|
||
'deaths',
|
||
'kills',
|
||
'assists',
|
||
'last_hits',
|
||
'denies',
|
||
'duration_min'
|
||
]
|
||
for (const stat of stats) {
|
||
expect(validateChallengeInput({ ...valid, stat })).not.toBeNull()
|
||
}
|
||
})
|
||
|
||
it('rejects negative multiplier', () => {
|
||
expect(validateChallengeInput({ ...valid, multiplier: -1 })).toBeNull()
|
||
})
|
||
|
||
it('rejects multiplier > 1000', () => {
|
||
expect(validateChallengeInput({ ...valid, multiplier: 1001 })).toBeNull()
|
||
})
|
||
|
||
it('accepts zero multiplier (legitimate "disable" semantics)', () => {
|
||
expect(
|
||
validateChallengeInput({ ...valid, multiplier: 0 })?.multiplier
|
||
).toBe(0)
|
||
})
|
||
|
||
it('accepts fractional multiplier (e.g. 0.5×)', () => {
|
||
expect(
|
||
validateChallengeInput({ ...valid, multiplier: 0.5 })?.multiplier
|
||
).toBe(0.5)
|
||
})
|
||
})
|
||
|
||
describe('validateChallengePatch', () => {
|
||
it('accepts empty patch', () => {
|
||
expect(validateChallengePatch({})).toEqual({})
|
||
})
|
||
|
||
it('rejects unknown stat in patch', () => {
|
||
expect(validateChallengePatch({ stat: 'mana' })).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('validateSettingsPatch', () => {
|
||
it('accepts empty patch', () => {
|
||
expect(validateSettingsPatch({})).toEqual({})
|
||
})
|
||
|
||
it('accepts each boolean toggle independently', () => {
|
||
expect(validateSettingsPatch({ globalEnabled: false })).toEqual({
|
||
globalEnabled: false
|
||
})
|
||
expect(validateSettingsPatch({ soundEnabled: true })).toEqual({
|
||
soundEnabled: true
|
||
})
|
||
})
|
||
|
||
it('rejects unknown theme', () => {
|
||
expect(validateSettingsPatch({ theme: 'sepia' })).toBeNull()
|
||
})
|
||
|
||
it('accepts all valid themes', () => {
|
||
expect(validateSettingsPatch({ theme: 'light' })?.theme).toBe('light')
|
||
expect(validateSettingsPatch({ theme: 'dark' })?.theme).toBe('dark')
|
||
expect(validateSettingsPatch({ theme: 'system' })?.theme).toBe('system')
|
||
})
|
||
|
||
it('rejects unknown language', () => {
|
||
expect(validateSettingsPatch({ language: 'fr' })).toBeNull()
|
||
})
|
||
|
||
it('rejects unknown notification mode', () => {
|
||
expect(validateSettingsPatch({ notificationMode: 'sms' })).toBeNull()
|
||
})
|
||
|
||
it('rejects out-of-range snoozeMinutes', () => {
|
||
expect(validateSettingsPatch({ snoozeMinutes: 0 })).toBeNull()
|
||
expect(validateSettingsPatch({ snoozeMinutes: 1441 })).toBeNull()
|
||
expect(validateSettingsPatch({ snoozeMinutes: -5 })).toBeNull()
|
||
})
|
||
|
||
it('accepts voicePromptsEnabled boolean', () => {
|
||
expect(validateSettingsPatch({ voicePromptsEnabled: true })).toEqual({
|
||
voicePromptsEnabled: true
|
||
})
|
||
expect(validateSettingsPatch({ voicePromptsEnabled: false })).toEqual({
|
||
voicePromptsEnabled: false
|
||
})
|
||
})
|
||
|
||
it('rejects non-boolean voicePromptsEnabled in patch', () => {
|
||
expect(validateSettingsPatch({ voicePromptsEnabled: 'yes' })).toBeNull()
|
||
expect(validateSettingsPatch({ voicePromptsEnabled: 1 })).toBeNull()
|
||
})
|
||
|
||
it('accepts meetingAutoPause boolean', () => {
|
||
expect(validateSettingsPatch({ meetingAutoPause: true })).toEqual({
|
||
meetingAutoPause: true
|
||
})
|
||
})
|
||
|
||
it('rejects non-boolean meetingAutoPause', () => {
|
||
expect(validateSettingsPatch({ meetingAutoPause: 'yes' })).toBeNull()
|
||
})
|
||
|
||
describe('lastSeenVersion', () => {
|
||
it('accepts valid semver', () => {
|
||
const r = validateSettingsPatch({ lastSeenVersion: '0.5.7' })
|
||
expect(r?.lastSeenVersion).toBe('0.5.7')
|
||
expect(validateSettingsPatch({ lastSeenVersion: '10.20.30' })).toEqual({
|
||
lastSeenVersion: '10.20.30'
|
||
})
|
||
})
|
||
|
||
it('accepts pre-release suffix', () => {
|
||
const r = validateSettingsPatch({ lastSeenVersion: '0.5.7-beta.1' })
|
||
expect(r?.lastSeenVersion).toBe('0.5.7-beta.1')
|
||
})
|
||
|
||
it('treats null/undefined as reset to undefined', () => {
|
||
const r1 = validateSettingsPatch({ lastSeenVersion: null })
|
||
expect(r1).toEqual({ lastSeenVersion: undefined })
|
||
const r2 = validateSettingsPatch({ lastSeenVersion: undefined })
|
||
// 'lastSeenVersion' is `in raw` even if undefined — both treated reset.
|
||
expect(r2).toEqual({ lastSeenVersion: undefined })
|
||
})
|
||
|
||
it('rejects malformed strings', () => {
|
||
expect(validateSettingsPatch({ lastSeenVersion: '0.5' })).toBeNull()
|
||
expect(validateSettingsPatch({ lastSeenVersion: 'v0.5.7' })).toBeNull()
|
||
expect(validateSettingsPatch({ lastSeenVersion: 'beta' })).toBeNull()
|
||
expect(validateSettingsPatch({ lastSeenVersion: '' })).toBeNull()
|
||
})
|
||
|
||
it('rejects non-strings', () => {
|
||
expect(validateSettingsPatch({ lastSeenVersion: 42 })).toBeNull()
|
||
expect(
|
||
validateSettingsPatch({ lastSeenVersion: ['1', '0', '0'] })
|
||
).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('quietHours subobject', () => {
|
||
const baseQh = {
|
||
enabled: true,
|
||
from: '22:00',
|
||
to: '08:00',
|
||
days: [0, 1, 2, 3, 4, 5, 6]
|
||
}
|
||
|
||
it('accepts a valid quietHours', () => {
|
||
expect(validateSettingsPatch({ quietHours: baseQh })?.quietHours).toEqual(
|
||
baseQh
|
||
)
|
||
})
|
||
|
||
it('rejects non-object quietHours', () => {
|
||
expect(validateSettingsPatch({ quietHours: 'always' })).toBeNull()
|
||
expect(validateSettingsPatch({ quietHours: null })).toBeNull()
|
||
})
|
||
|
||
it('rejects malformed HH:MM', () => {
|
||
expect(
|
||
validateSettingsPatch({ quietHours: { ...baseQh, from: '2500' } })
|
||
).toBeNull()
|
||
expect(
|
||
validateSettingsPatch({ quietHours: { ...baseQh, from: '25:00' } })
|
||
).toBeNull()
|
||
expect(
|
||
validateSettingsPatch({ quietHours: { ...baseQh, to: '09:99' } })
|
||
).toBeNull()
|
||
expect(
|
||
validateSettingsPatch({ quietHours: { ...baseQh, to: 'bedtime' } })
|
||
).toBeNull()
|
||
expect(
|
||
validateSettingsPatch({ quietHours: { ...baseQh, from: '8' } })
|
||
).toBeNull()
|
||
})
|
||
|
||
it('accepts HH:MM with 1-digit hour (9:30)', () => {
|
||
// Regex is /^\d{1,2}:\d{2}$/ — допускаем «9:30», парсер сам разберётся.
|
||
const r = validateSettingsPatch({
|
||
quietHours: { ...baseQh, from: '9:30' }
|
||
})
|
||
expect(r?.quietHours?.from).toBe('9:30')
|
||
})
|
||
|
||
it('dedupes days array', () => {
|
||
const r = validateSettingsPatch({
|
||
quietHours: { ...baseQh, days: [1, 2, 2, 3, 1] }
|
||
})
|
||
expect(r?.quietHours?.days).toEqual([1, 2, 3])
|
||
})
|
||
|
||
it('rejects out-of-range day (7)', () => {
|
||
expect(
|
||
validateSettingsPatch({ quietHours: { ...baseQh, days: [0, 7] } })
|
||
).toBeNull()
|
||
})
|
||
|
||
it('rejects negative day', () => {
|
||
expect(
|
||
validateSettingsPatch({ quietHours: { ...baseQh, days: [-1] } })
|
||
).toBeNull()
|
||
})
|
||
|
||
it('rejects non-array days', () => {
|
||
expect(
|
||
validateSettingsPatch({ quietHours: { ...baseQh, days: 'all' } })
|
||
).toBeNull()
|
||
})
|
||
|
||
it('accepts empty days array (window effectively disabled)', () => {
|
||
const r = validateSettingsPatch({
|
||
quietHours: { ...baseQh, days: [] }
|
||
})
|
||
expect(r?.quietHours?.days).toEqual([])
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('validateId', () => {
|
||
it('accepts reasonable id strings', () => {
|
||
expect(validateId('abc')).toBe('abc')
|
||
expect(validateId('uuid-v4-style-thing-123')).toBe(
|
||
'uuid-v4-style-thing-123'
|
||
)
|
||
})
|
||
|
||
it('rejects non-strings', () => {
|
||
expect(validateId(42)).toBeNull()
|
||
expect(validateId(null)).toBeNull()
|
||
expect(validateId(undefined)).toBeNull()
|
||
expect(validateId({})).toBeNull()
|
||
})
|
||
|
||
it('rejects empty string', () => {
|
||
expect(validateId('')).toBeNull()
|
||
})
|
||
|
||
it('rejects strings longer than 64 chars', () => {
|
||
expect(validateId('x'.repeat(65))).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('validateActualReps', () => {
|
||
it('returns undefined for undefined/null (means: use planned reps)', () => {
|
||
expect(validateActualReps(undefined)).toBeUndefined()
|
||
expect(validateActualReps(null)).toBeUndefined()
|
||
})
|
||
|
||
it('accepts zero (partial completion = "did 0 of 10")', () => {
|
||
expect(validateActualReps(0)).toBe(0)
|
||
})
|
||
|
||
it('accepts large values up to cap', () => {
|
||
expect(validateActualReps(100_000)).toBe(100_000)
|
||
})
|
||
|
||
it('rejects negative', () => {
|
||
expect(validateActualReps(-1)).toBeUndefined()
|
||
})
|
||
|
||
it('rejects values above cap', () => {
|
||
expect(validateActualReps(100_001)).toBeUndefined()
|
||
})
|
||
|
||
it('rejects NaN/Infinity', () => {
|
||
expect(validateActualReps(NaN)).toBeUndefined()
|
||
expect(validateActualReps(Infinity)).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('validateSnoozeMinutes', () => {
|
||
it('accepts valid minutes', () => {
|
||
expect(validateSnoozeMinutes(15)).toBe(15)
|
||
expect(validateSnoozeMinutes(1)).toBe(1)
|
||
expect(validateSnoozeMinutes(1440)).toBe(1440)
|
||
})
|
||
|
||
it('rejects 0 and above 24h', () => {
|
||
expect(validateSnoozeMinutes(0)).toBeNull()
|
||
expect(validateSnoozeMinutes(1441)).toBeNull()
|
||
})
|
||
|
||
it('rejects non-numbers', () => {
|
||
expect(validateSnoozeMinutes('15')).toBeNull()
|
||
expect(validateSnoozeMinutes(null)).toBeNull()
|
||
})
|
||
})
|