fix(mcp): подготовить доступ и отзыв разрешений для приёмки
This commit is contained in:
commit
ef7bb79d4e
311 files changed
+73119
No files matched your search
@@ -0,0 +1,21 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
extends: [js.configs.recommended, tseslint.configs.recommended],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ ignoreRestSiblings: true, argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@authportal/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts",
|
||||
"dev": "tsup src/index.ts --format esm --dts --watch",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "catalog:",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"eslint": "catalog:",
|
||||
"globals": "catalog:",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
APP_IDS,
|
||||
APPS,
|
||||
appAuthModeSchema,
|
||||
appIdSchema,
|
||||
type AppAuthMode,
|
||||
type AppId,
|
||||
type AppMeta,
|
||||
} from './auth.js'
|
||||
|
||||
export const appSwitcherIconSchema = z.enum([
|
||||
'server',
|
||||
'cloud',
|
||||
'globe',
|
||||
'dashboard',
|
||||
'chart',
|
||||
])
|
||||
|
||||
export const appSwitcherEntrySchema = z.object({
|
||||
id: appIdSchema,
|
||||
name: z.string().min(1),
|
||||
subtitle: z.string().optional(),
|
||||
url: z.string().url(),
|
||||
icon: appSwitcherIconSchema,
|
||||
shortcut: z.string().optional(),
|
||||
enabled: z.boolean(),
|
||||
sort: z.number().int().optional(),
|
||||
/** App-scoped tenant (e.g. EvoBGP UUID) — goes into JWT, not public switcher. */
|
||||
tenantId: z.string().optional(),
|
||||
/** jwt = fragment SSO; oidc = open base URL. */
|
||||
authMode: appAuthModeSchema.optional(),
|
||||
})
|
||||
|
||||
export const appSwitcherConfigSchema = z.object({
|
||||
menuLabel: z.string().min(1),
|
||||
apps: z.array(appSwitcherEntrySchema).min(1),
|
||||
})
|
||||
|
||||
export type AppSwitcherIconName = z.infer<typeof appSwitcherIconSchema>
|
||||
export type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>
|
||||
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>
|
||||
|
||||
const DEFAULT_ICONS: Record<AppId, AppSwitcherIconName> = {
|
||||
cfdm: 'cloud',
|
||||
vps: 'server',
|
||||
bgp: 'globe',
|
||||
fw: 'server',
|
||||
dns: 'globe',
|
||||
cdn: 'cloud',
|
||||
mm: 'dashboard',
|
||||
}
|
||||
|
||||
const DEFAULT_AUTH_MODE: Record<AppId, AppAuthMode> = {
|
||||
cfdm: 'jwt',
|
||||
vps: 'jwt',
|
||||
bgp: 'jwt',
|
||||
fw: 'jwt',
|
||||
dns: 'oidc',
|
||||
cdn: 'jwt',
|
||||
mm: 'jwt',
|
||||
}
|
||||
|
||||
/** Seed / fallback when DB is empty. */
|
||||
export function defaultAppSwitcherConfig(): AppSwitcherConfig {
|
||||
return {
|
||||
menuLabel: 'Приложения',
|
||||
apps: APPS.map((app, index) => ({
|
||||
id: app.id,
|
||||
name: app.title,
|
||||
subtitle: app.description,
|
||||
url: app.url,
|
||||
icon: DEFAULT_ICONS[app.id],
|
||||
enabled: true,
|
||||
sort: index,
|
||||
authMode: DEFAULT_AUTH_MODE[app.id],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAppSwitcherConfig(raw: unknown): AppSwitcherConfig {
|
||||
const parsed = appSwitcherConfigSchema.safeParse(raw)
|
||||
if (!parsed.success) return defaultAppSwitcherConfig()
|
||||
return normalizeAppSwitcherConfig(parsed.data)
|
||||
}
|
||||
|
||||
/** Ensure all APP_IDS present; sort; drop unknown. */
|
||||
export function normalizeAppSwitcherConfig(
|
||||
config: AppSwitcherConfig,
|
||||
): AppSwitcherConfig {
|
||||
const byId = new Map(config.apps.map((a) => [a.id, a]))
|
||||
const defaults = defaultAppSwitcherConfig()
|
||||
const apps = APP_IDS.map((id, index) => {
|
||||
const existing = byId.get(id)
|
||||
const fallback = defaults.apps.find((a) => a.id === id)!
|
||||
return {
|
||||
...fallback,
|
||||
...existing,
|
||||
id,
|
||||
sort: existing?.sort ?? index,
|
||||
enabled: existing?.enabled ?? true,
|
||||
icon: existing?.icon ?? fallback.icon,
|
||||
tenantId: existing?.tenantId?.trim() || undefined,
|
||||
authMode:
|
||||
existing?.authMode ??
|
||||
fallback.authMode ??
|
||||
DEFAULT_AUTH_MODE[id],
|
||||
}
|
||||
}).sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
|
||||
|
||||
return {
|
||||
menuLabel: config.menuLabel || 'Приложения',
|
||||
apps,
|
||||
}
|
||||
}
|
||||
|
||||
/** Public GET must not expose tenant IDs. */
|
||||
export function publicAppSwitcherConfig(
|
||||
config: AppSwitcherConfig,
|
||||
): AppSwitcherConfig {
|
||||
const normalized = normalizeAppSwitcherConfig(config)
|
||||
return {
|
||||
menuLabel: normalized.menuLabel,
|
||||
apps: normalized.apps.map(({ tenantId: _tid, ...rest }) => rest),
|
||||
}
|
||||
}
|
||||
|
||||
/** Map appId → tenantId for JWT (only apps the user may access). */
|
||||
export function tenantsClaimForUser(
|
||||
config: AppSwitcherConfig,
|
||||
userApps: readonly string[],
|
||||
): Record<string, string> {
|
||||
const allowed = new Set(userApps)
|
||||
const out: Record<string, string> = {}
|
||||
for (const app of normalizeAppSwitcherConfig(config).apps) {
|
||||
if (!allowed.has(app.id)) continue
|
||||
const tid = app.tenantId?.trim()
|
||||
if (tid) out[app.id] = tid
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** AppMeta list with URLs from switcher store (for /apps + catalog). */
|
||||
export function appsMetaFromSwitcher(config: AppSwitcherConfig): AppMeta[] {
|
||||
const normalized = normalizeAppSwitcherConfig(config)
|
||||
return normalized.apps
|
||||
.filter((a) => a.enabled !== false)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.name,
|
||||
description: a.subtitle ?? APPS.find((x) => x.id === a.id)?.description ?? '',
|
||||
url: a.url,
|
||||
authMode: a.authMode ?? DEFAULT_AUTH_MODE[a.id],
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const AUDIT_SEVERITIES = ['info', 'warning', 'critical'] as const
|
||||
export type AuditSeverity = (typeof AUDIT_SEVERITIES)[number]
|
||||
export const auditSeveritySchema = z.enum(AUDIT_SEVERITIES)
|
||||
|
||||
export const AUDIT_SOURCE_APPS = [
|
||||
'portal',
|
||||
'vps',
|
||||
'cfdm',
|
||||
'bgp',
|
||||
'fw',
|
||||
'dns',
|
||||
'cdn',
|
||||
'mm',
|
||||
] as const
|
||||
export type AuditSourceApp = (typeof AUDIT_SOURCE_APPS)[number]
|
||||
export const auditSourceAppSchema = z.enum(AUDIT_SOURCE_APPS)
|
||||
|
||||
export const AUDIT_TARGET_TYPES = [
|
||||
'user',
|
||||
'settings',
|
||||
'session',
|
||||
'system',
|
||||
'app_resource',
|
||||
'credential',
|
||||
] as const
|
||||
export type AuditTargetType = (typeof AUDIT_TARGET_TYPES)[number]
|
||||
export const auditTargetTypeSchema = z.enum(AUDIT_TARGET_TYPES)
|
||||
|
||||
/** Well-known portal-native action keys. */
|
||||
export const AUDIT_ACTIONS = [
|
||||
'auth.login',
|
||||
'auth.login_failed',
|
||||
'auth.logout',
|
||||
'auth.sso_handoff',
|
||||
'user.create',
|
||||
'user.update',
|
||||
'user.delete',
|
||||
'user.access_update',
|
||||
'app_switcher.update',
|
||||
'audit.settings_update',
|
||||
'audit.purge',
|
||||
] as const
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number]
|
||||
export const auditActionSchema = z.enum(AUDIT_ACTIONS)
|
||||
|
||||
export const AUDIT_KINDS = ['logins', 'changes'] as const
|
||||
export type AuditKind = (typeof AUDIT_KINDS)[number]
|
||||
export const auditKindSchema = z.enum(AUDIT_KINDS)
|
||||
|
||||
export const AUTH_AUDIT_ACTIONS = [
|
||||
'auth.login',
|
||||
'auth.login_failed',
|
||||
'auth.logout',
|
||||
'auth.sso_handoff',
|
||||
] as const
|
||||
|
||||
export const auditLogEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
event_id: z.string().nullable(),
|
||||
source_app: auditSourceAppSchema,
|
||||
action: z.string(),
|
||||
severity: auditSeveritySchema,
|
||||
actor_user_id: z.string().nullable(),
|
||||
actor_email: z.string().nullable(),
|
||||
actor_name: z.string().nullable(),
|
||||
target_type: auditTargetTypeSchema.nullable(),
|
||||
target_id: z.string().nullable(),
|
||||
summary: z.string(),
|
||||
details: z.record(z.string(), z.unknown()).nullable(),
|
||||
ip: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
})
|
||||
export type AuditLogEntry = z.infer<typeof auditLogEntrySchema>
|
||||
|
||||
export const auditListQuerySchema = z.object({
|
||||
action: z.string().optional(),
|
||||
severity: auditSeveritySchema.optional(),
|
||||
user_id: z.string().optional(),
|
||||
actor_email: z.string().email().optional(),
|
||||
source_app: auditSourceAppSchema.optional(),
|
||||
kind: auditKindSchema.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(200),
|
||||
})
|
||||
export type AuditListQuery = z.infer<typeof auditListQuerySchema>
|
||||
|
||||
export const DEFAULT_AUDIT_RETENTION_DAYS = 90
|
||||
export const MIN_AUDIT_RETENTION_DAYS = 7
|
||||
export const MAX_AUDIT_RETENTION_DAYS = 3650
|
||||
|
||||
export const auditSettingsSchema = z.object({
|
||||
retention_days: z
|
||||
.number()
|
||||
.int()
|
||||
.min(MIN_AUDIT_RETENTION_DAYS)
|
||||
.max(MAX_AUDIT_RETENTION_DAYS),
|
||||
})
|
||||
export type AuditSettings = z.infer<typeof auditSettingsSchema>
|
||||
|
||||
export const putAuditSettingsSchema = auditSettingsSchema
|
||||
export type PutAuditSettings = z.infer<typeof putAuditSettingsSchema>
|
||||
|
||||
export const auditPurgeResponseSchema = z.object({
|
||||
deleted: z.number().int().nonnegative(),
|
||||
retention_days: z.number().int(),
|
||||
})
|
||||
export type AuditPurgeResponse = z.infer<typeof auditPurgeResponseSchema>
|
||||
|
||||
export const ingestAuditEventSchema = z.object({
|
||||
event_id: z.string().min(1).max(128),
|
||||
source_app: z.enum(['vps', 'cfdm', 'bgp', 'fw', 'dns', 'cdn', 'mm']),
|
||||
action: z.string().min(1).max(200),
|
||||
severity: auditSeveritySchema.optional(),
|
||||
actor_user_id: z.string().nullable().optional(),
|
||||
actor_email: z.string().email().nullable().optional(),
|
||||
actor_name: z.string().nullable().optional(),
|
||||
target_type: auditTargetTypeSchema.nullable().optional(),
|
||||
target_id: z.string().nullable().optional(),
|
||||
summary: z.string().min(1).max(500),
|
||||
details: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
ip: z.string().nullable().optional(),
|
||||
created_at: z.string().optional(),
|
||||
})
|
||||
export type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>
|
||||
|
||||
export const ingestAuditRequestSchema = z.object({
|
||||
events: z.array(ingestAuditEventSchema).min(1).max(50),
|
||||
})
|
||||
export type IngestAuditRequest = z.infer<typeof ingestAuditRequestSchema>
|
||||
|
||||
export const ingestAuditResponseSchema = z.object({
|
||||
accepted: z.number().int().nonnegative(),
|
||||
duplicates: z.number().int().nonnegative(),
|
||||
})
|
||||
export type IngestAuditResponse = z.infer<typeof ingestAuditResponseSchema>
|
||||
@@ -0,0 +1,541 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const APP_IDS = ['cfdm', 'vps', 'bgp', 'fw', 'dns', 'cdn', 'mm'] as const
|
||||
export type AppId = (typeof APP_IDS)[number]
|
||||
export const appIdSchema = z.enum(APP_IDS)
|
||||
|
||||
export const APP_AUTH_MODES = ['jwt', 'oidc'] as const
|
||||
export type AppAuthMode = (typeof APP_AUTH_MODES)[number]
|
||||
export const appAuthModeSchema = z.enum(APP_AUTH_MODES)
|
||||
|
||||
export const PERMISSION_ACTIONS = ['read', 'write', 'admin', 'approve', 'pause'] as const
|
||||
export type PermissionAction = (typeof PERMISSION_ACTIONS)[number]
|
||||
export const permissionActionSchema = z.enum(PERMISSION_ACTIONS)
|
||||
|
||||
export const PERMISSION_ACTION_LABELS: Readonly<Record<PermissionAction, string>> = Object.freeze({
|
||||
read: 'Чтение', write: 'Редактирование', admin: 'Администрирование',
|
||||
approve: 'Одобрение планов', pause: 'Пауза автоматизации',
|
||||
})
|
||||
export function isIndependentPermissionSection(appId: AppId, sectionId: string): boolean {
|
||||
return appId === 'mm' && sectionId === 'mcp'
|
||||
}
|
||||
/** Toggle ONE explicitly supported independent key; never cascade or normalize unrelated grants. */
|
||||
export function withIndependentPermission(granted: readonly string[], appId: AppId, sectionId: string, action: PermissionAction, enabled: boolean): string[] {
|
||||
const section = PERMISSION_CATALOG.find(app => app.appId === appId)?.sections.find(section => section.id === sectionId)
|
||||
if (!isIndependentPermissionSection(appId, sectionId) || !section?.actions.includes(action)) throw new Error('Unsupported independent permission')
|
||||
const key = permissionKey(appId, sectionId, action)
|
||||
return enabled ? granted.includes(key) ? [...granted] : [...granted, key] : granted.filter(value => value !== key)
|
||||
}
|
||||
|
||||
|
||||
export type AppMeta = {
|
||||
id: AppId
|
||||
title: string
|
||||
description: string
|
||||
url: string
|
||||
/** jwt = fragment SSO; oidc = open base URL (external OIDC RP). */
|
||||
authMode: AppAuthMode
|
||||
}
|
||||
|
||||
export const APPS: AppMeta[] = [
|
||||
{
|
||||
id: 'cfdm',
|
||||
title: 'Cloudflare Domain Manager',
|
||||
description: 'Домены, DNS, сертификаты, группы и сервисы',
|
||||
url: 'https://cfdm.shnt.top',
|
||||
authMode: 'jwt',
|
||||
},
|
||||
{
|
||||
id: 'vps',
|
||||
title: 'VPS Tracker',
|
||||
description: 'Серверы, аккаунты, платежи и синхронизация',
|
||||
url: 'https://vps.shnt.top',
|
||||
authMode: 'jwt',
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
title: 'EvoBGP',
|
||||
description: 'Модули, сеть, операции и мониторинг BGP',
|
||||
url: 'https://bgp.shnt.top',
|
||||
authMode: 'jwt',
|
||||
},
|
||||
{
|
||||
id: 'fw',
|
||||
title: 'EvoFirewall',
|
||||
description: 'Централизованный firewall: агенты, списки, политики',
|
||||
url: 'https://fw.shnt.top',
|
||||
authMode: 'jwt',
|
||||
},
|
||||
{
|
||||
id: 'dns',
|
||||
title: 'Technitium DNS',
|
||||
description: 'DNS-сервер: зоны, DHCP, блокировки (OIDC SSO)',
|
||||
url: 'https://dns.shnt.top',
|
||||
authMode: 'oidc',
|
||||
},
|
||||
{
|
||||
id: 'cdn',
|
||||
title: 'CDN Manager',
|
||||
description: 'Флот DNS: ноды, алиасы, sync Cloudflare',
|
||||
url: 'https://cdn.shnt.top',
|
||||
authMode: 'jwt',
|
||||
},
|
||||
{
|
||||
id: 'mm',
|
||||
title: 'MikrotikManager',
|
||||
description: 'MikroTik: серверы, фильтры, BGP, uptime',
|
||||
url: 'https://mm.shnt.top',
|
||||
authMode: 'jwt',
|
||||
},
|
||||
]
|
||||
|
||||
export type CatalogSection = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
actions: PermissionAction[]
|
||||
}
|
||||
|
||||
export type AppPermissionCatalog = {
|
||||
appId: AppId
|
||||
title: string
|
||||
sections: CatalogSection[]
|
||||
}
|
||||
|
||||
function section(
|
||||
id: string,
|
||||
title: string,
|
||||
description: string,
|
||||
actions: PermissionAction[] = ['read', 'write'],
|
||||
): CatalogSection {
|
||||
return { id, title, description, actions }
|
||||
}
|
||||
|
||||
export const PERMISSION_CATALOG: AppPermissionCatalog[] = [
|
||||
{
|
||||
appId: 'cfdm',
|
||||
title: 'Cloudflare Domain Manager',
|
||||
sections: [
|
||||
section('domains', 'Домены', 'Список и карточки доменов'),
|
||||
section('dns', 'DNS', 'DNS-записи'),
|
||||
section('certificates', 'Сертификаты', 'TLS-сертификаты'),
|
||||
section('groups', 'Группы', 'Группы доменов'),
|
||||
section('services', 'Сервисы', 'Сервисы и привязки'),
|
||||
section('settings', 'Настройки', 'Настройки приложения', ['admin']),
|
||||
],
|
||||
},
|
||||
{
|
||||
appId: 'vps',
|
||||
title: 'VPS Tracker',
|
||||
sections: [
|
||||
section('dashboard', 'Дашборд', 'Обзор и KPI', ['read']),
|
||||
section('vps', 'VPS', 'Виртуальные серверы'),
|
||||
section('accounts', 'Аккаунты', 'Аккаунты провайдеров'),
|
||||
section('payments', 'Платежи', 'Платежи и баланс'),
|
||||
section('sync', 'Синхронизация', 'Запуск sync', ['write']),
|
||||
section('spaces', 'Пространства', 'Управление пространствами и выдачей доступа', [
|
||||
'admin',
|
||||
]),
|
||||
section('settings', 'Настройки', 'Настройки приложения', ['admin']),
|
||||
],
|
||||
},
|
||||
{
|
||||
appId: 'bgp',
|
||||
title: 'EvoBGP',
|
||||
sections: [
|
||||
section('dashboard', 'Панель', 'KPI и обзор', ['read']),
|
||||
section('modules', 'Модули', 'Модули префиксов'),
|
||||
section('lookup', 'Проверка', 'IP/домен в списках и community', ['read']),
|
||||
section('network', 'Сеть', 'BGP-пиры и спикеры'),
|
||||
section('directories', 'Справочники', 'Communities и DoH'),
|
||||
section('operations', 'Операции', 'Ревизии (read/write) и apply/rollback (admin)', [
|
||||
'read',
|
||||
'write',
|
||||
'admin',
|
||||
]),
|
||||
section('schedule', 'Задачи', 'Расписание refresh'),
|
||||
section('monitoring', 'Мониторинг', 'Health и BIRD', ['read']),
|
||||
section('access', 'Доступ', 'API-ключи', ['admin']),
|
||||
section('tenant_settings', 'Настройки BIRD', 'Tenant BIRD config', [
|
||||
'admin',
|
||||
]),
|
||||
section('settings', 'Настройки UI', 'Токен и подключение', ['read']),
|
||||
],
|
||||
},
|
||||
{
|
||||
appId: 'fw',
|
||||
title: 'EvoFirewall',
|
||||
sections: [
|
||||
section('dashboard', 'Дашборд', 'KPI и обзор', ['read']),
|
||||
section('agents', 'Агенты', 'Linux / MikroTik клиенты'),
|
||||
section('lists', 'Списки IP', 'static / JSON / domains / EvoBGP'),
|
||||
section('policies', 'Правила', 'Allow/deny политики и overrides'),
|
||||
section('stats', 'Статистика', 'Пакеты и история', ['read']),
|
||||
section('settings', 'Настройки', 'Enroll seed и интеграции', ['admin']),
|
||||
],
|
||||
},
|
||||
{
|
||||
appId: 'dns',
|
||||
title: 'Technitium DNS',
|
||||
sections: [
|
||||
section('console', 'Консоль', 'Полный доступ Administrators', ['admin']),
|
||||
section('dns', 'DNS', 'Зоны и записи (DNS Administrators)'),
|
||||
section('dhcp', 'DHCP', 'DHCP scopes (DHCP Administrators)'),
|
||||
section('settings', 'Настройки', 'SSO и системные настройки', ['admin']),
|
||||
],
|
||||
},
|
||||
{
|
||||
appId: 'cdn',
|
||||
title: 'CDN Manager',
|
||||
sections: [
|
||||
section('dashboard', 'Панель', 'KPI и обзор', ['read']),
|
||||
section('nodes', 'Ноды', 'Канонические хосты A/AAAA'),
|
||||
section('aliases', 'Алиасы', 'CNAME и retarget'),
|
||||
section('zones', 'Зоны', 'Cloudflare zones и BIND export'),
|
||||
section('sync', 'Синхронизация', 'Pull/diff/apply DNS', ['write']),
|
||||
section('topology', 'Топология', 'Схема флота', ['read']),
|
||||
section('settings', 'Настройки', 'Naming, TTL, Cloudflare', ['admin']),
|
||||
],
|
||||
},
|
||||
{
|
||||
appId: 'mm',
|
||||
title: 'MikrotikManager',
|
||||
sections: [
|
||||
section('dashboard', 'Дашборд', 'Обзор и KPI', ['read']),
|
||||
section('servers', 'Серверы', 'MikroTik / CHR хосты'),
|
||||
section('filters', 'Фильтры', 'Firewall и address-list'),
|
||||
section('bgp', 'BGP', 'BGP-сессии и префиксы'),
|
||||
section('uptime', 'Uptime', 'Пинги и speed-probes'),
|
||||
section('traffic', 'Трафик', 'Интерфейсы и графики'),
|
||||
section('alerts', 'Алерты', 'Правила и Telegram'),
|
||||
section('backups', 'Бэкапы', 'Конфиги и расписание'),
|
||||
section('certificates', 'Сертификаты', 'ACME / TLS'),
|
||||
section('network', 'Сеть', 'GRE, OSPF, пути'),
|
||||
section('users', 'Пользователи MM', 'Учётные записи и привязки интерфейсов в MikrotikManager', ['read', 'write']),
|
||||
section('mcp', 'MCP', 'Одобрение и пауза назначаются отдельно; администрирование включает чтение', ['read', 'approve', 'pause', 'admin']),
|
||||
section('settings', 'Настройки', 'Настройки приложения', ['admin']),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function permissionKey(
|
||||
appId: AppId,
|
||||
sectionId: string,
|
||||
action: PermissionAction,
|
||||
): string {
|
||||
return `${appId}:${sectionId}:${action}`
|
||||
}
|
||||
|
||||
/** Hierarchy: admin ⊃ write ⊃ read within the same section. */
|
||||
export function hasPermission(
|
||||
granted: readonly string[],
|
||||
required: string,
|
||||
): boolean {
|
||||
if (granted.includes(required)) return true
|
||||
const parts = required.split(':')
|
||||
if (parts.length !== 3) return false
|
||||
const [app, section, action] = parts
|
||||
if (action === 'read') {
|
||||
return (
|
||||
granted.includes(`${app}:${section}:write`) ||
|
||||
granted.includes(`${app}:${section}:admin`)
|
||||
)
|
||||
}
|
||||
if (action === 'write') {
|
||||
return granted.includes(`${app}:${section}:admin`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowlist entries: `.shnt.top`, `localhost`, full origins `http://localhost:5173`,
|
||||
* or token `private` for RFC1918 LAN hosts (192.168/10/172.16-31).
|
||||
*/
|
||||
export function isReturnToAllowed(
|
||||
returnTo: string,
|
||||
allowlistCsv: string,
|
||||
): boolean {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(returnTo)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
|
||||
|
||||
const entries = allowlistCsv
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const allowPrivate = entries.some((e) => e.toLowerCase() === 'private')
|
||||
if (allowPrivate && isPrivateHostname(url.hostname)) return true
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.toLowerCase() === 'private') continue
|
||||
if (entry.startsWith('.')) {
|
||||
const suffix = entry.slice(1)
|
||||
if (url.hostname === suffix || url.hostname.endsWith(entry)) return true
|
||||
continue
|
||||
}
|
||||
if (entry.includes('://')) {
|
||||
try {
|
||||
if (url.origin === new URL(entry).origin) return true
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (url.hostname === entry || url.hostname.endsWith(`.${entry}`)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isPrivateHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname.endsWith('.local')) return true
|
||||
const m = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(hostname)
|
||||
if (!m) return false
|
||||
const a = Number(m[1])
|
||||
const b = Number(m[2])
|
||||
if (a === 10) return true
|
||||
if (a === 192 && b === 168) return true
|
||||
if (a === 172 && b >= 16 && b <= 31) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Decode JWT payload without verifying signature (claims sync only). */
|
||||
export function readJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length < 2) return null
|
||||
const json = atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
return JSON.parse(json) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode JWT `exp` without verifying signature. Returns null if missing/invalid. */
|
||||
export function readJwtExp(token: string): number | null {
|
||||
const payload = readJwtPayload(token)
|
||||
if (!payload) return null
|
||||
return typeof payload.exp === 'number' ? payload.exp : null
|
||||
}
|
||||
|
||||
/** True when token is missing exp or exp is in the past (30s clock-skew grace). */
|
||||
export function isJwtExpired(token: string, nowMs: number = Date.now()): boolean {
|
||||
const exp = readJwtExp(token)
|
||||
if (exp == null) return true
|
||||
return exp * 1000 < nowMs - 30_000
|
||||
}
|
||||
|
||||
export function buildSsoRedirectUrl(
|
||||
returnTo: string,
|
||||
accessToken: string,
|
||||
expiresAt: string,
|
||||
): string {
|
||||
const hash = new URLSearchParams({
|
||||
access_token: accessToken,
|
||||
expires_at: expiresAt,
|
||||
})
|
||||
const base = returnTo.split('#')[0] ?? returnTo
|
||||
return `${base}#${hash.toString()}`
|
||||
}
|
||||
|
||||
export function allPermissionKeys(): string[] {
|
||||
const keys: string[] = []
|
||||
for (const app of PERMISSION_CATALOG) {
|
||||
for (const sec of app.sections) {
|
||||
for (const action of sec.actions) {
|
||||
keys.push(permissionKey(app.appId, sec.id, action))
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Old bgp section ids → current catalog (peers/apply/settings renamed). */
|
||||
const LEGACY_PERMISSION_MAP: Record<string, string> = {
|
||||
'bgp:peers:read': 'bgp:network:read',
|
||||
'bgp:peers:write': 'bgp:network:write',
|
||||
'bgp:apply:write': 'bgp:operations:admin',
|
||||
'bgp:settings:admin': 'bgp:tenant_settings:admin',
|
||||
}
|
||||
|
||||
/**
|
||||
* Map legacy keys and drop anything not in the current catalog.
|
||||
* Safe for JWT issue and admin save after catalog changes.
|
||||
*/
|
||||
export function normalizePermissionKeys(
|
||||
permissions: readonly string[],
|
||||
): string[] {
|
||||
const allowed = new Set(allPermissionKeys())
|
||||
const out = new Set<string>()
|
||||
for (const raw of permissions) {
|
||||
const mapped = LEGACY_PERMISSION_MAP[raw] ?? raw
|
||||
if (allowed.has(mapped)) {
|
||||
out.add(mapped)
|
||||
}
|
||||
}
|
||||
return [...out].sort()
|
||||
}
|
||||
|
||||
export const permissionKeySchema = z
|
||||
.string()
|
||||
.regex(/^(?:[a-z]+:[a-z0-9_]+:(?:read|write|admin)|mm:mcp:(?:approve|pause))$/)
|
||||
|
||||
export const meResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
is_admin: z.boolean(),
|
||||
apps: z.array(appIdSchema),
|
||||
permissions: z.array(z.string()),
|
||||
})
|
||||
export type MeResponse = z.infer<typeof meResponseSchema>
|
||||
|
||||
export const loginRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
return_to: z.string().url().optional(),
|
||||
})
|
||||
export type LoginRequest = z.infer<typeof loginRequestSchema>
|
||||
|
||||
export const loginResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
expires_at: z.string(),
|
||||
token_type: z.literal('Bearer'),
|
||||
user: meResponseSchema,
|
||||
})
|
||||
export type LoginResponse = z.infer<typeof loginResponseSchema>
|
||||
|
||||
export const accessTokenClaimsSchema = z.object({
|
||||
sub: z.string(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
apps: z.array(z.string()),
|
||||
permissions: z.array(z.string()),
|
||||
is_admin: z.boolean().optional(),
|
||||
iss: z.string(),
|
||||
aud: z.string().optional(),
|
||||
exp: z.number(),
|
||||
iat: z.number(),
|
||||
})
|
||||
export type AccessTokenClaims = z.infer<typeof accessTokenClaimsSchema>
|
||||
|
||||
export const catalogResponseSchema = z.object({
|
||||
apps: z.array(
|
||||
z.object({
|
||||
id: appIdSchema,
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string(),
|
||||
authMode: appAuthModeSchema.optional(),
|
||||
}),
|
||||
),
|
||||
permissions: z.array(
|
||||
z.object({
|
||||
appId: appIdSchema,
|
||||
title: z.string(),
|
||||
sections: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
actions: z.array(permissionActionSchema),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type CatalogResponse = z.infer<typeof catalogResponseSchema>
|
||||
|
||||
export const adminUserSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
is_admin: z.boolean(),
|
||||
disabled: z.boolean(),
|
||||
apps: z.array(appIdSchema),
|
||||
permissions: z.array(z.string()),
|
||||
last_login_at: z.string().nullable(),
|
||||
last_login_ip: z.string().nullable(),
|
||||
passkey_count: z.number().int().nonnegative().default(0),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
export type AdminUser = z.infer<typeof adminUserSchema>
|
||||
|
||||
export const passkeyCredentialSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
created_at: z.string(),
|
||||
last_used_at: z.string().nullable(),
|
||||
device_type: z.string().nullable(),
|
||||
})
|
||||
export type PasskeyCredential = z.infer<typeof passkeyCredentialSchema>
|
||||
|
||||
export const webauthnOptionsResponseSchema = z.object({
|
||||
challenge_id: z.string(),
|
||||
options: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
export type WebauthnOptionsResponse = z.infer<
|
||||
typeof webauthnOptionsResponseSchema
|
||||
>
|
||||
|
||||
export const webauthnVerifyRequestSchema = z.object({
|
||||
challenge_id: z.string().min(1),
|
||||
response: z.unknown(),
|
||||
return_to: z.string().url().optional(),
|
||||
name: z.string().min(1).max(80).optional(),
|
||||
})
|
||||
export type WebauthnVerifyRequest = z.infer<typeof webauthnVerifyRequestSchema>
|
||||
|
||||
export const patchPasskeyRequestSchema = z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
})
|
||||
export type PatchPasskeyRequest = z.infer<typeof patchPasskeyRequestSchema>
|
||||
|
||||
export const adminSessionSchema = z.object({
|
||||
id: z.string(),
|
||||
user_id: z.string(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
ip: z.string().nullable(),
|
||||
user_agent: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
expires_at: z.string(),
|
||||
})
|
||||
export type AdminSession = z.infer<typeof adminSessionSchema>
|
||||
|
||||
export const ssoAccessRequestSchema = z.object({
|
||||
return_to: z.string().url(),
|
||||
})
|
||||
export type SsoAccessRequest = z.infer<typeof ssoAccessRequestSchema>
|
||||
|
||||
export const createUserRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
password: z.string().min(6),
|
||||
is_admin: z.boolean().default(false),
|
||||
apps: z.array(appIdSchema).default([]),
|
||||
permissions: z.array(permissionKeySchema).default([]),
|
||||
})
|
||||
export type CreateUserRequest = z.infer<typeof createUserRequestSchema>
|
||||
|
||||
export const patchUserRequestSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
name: z.string().min(1).optional(),
|
||||
password: z.string().min(6).optional(),
|
||||
is_admin: z.boolean().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
})
|
||||
export type PatchUserRequest = z.infer<typeof patchUserRequestSchema>
|
||||
|
||||
export const putUserAccessRequestSchema = z.object({
|
||||
apps: z.array(appIdSchema),
|
||||
permissions: z.array(permissionKeySchema),
|
||||
})
|
||||
export type PutUserAccessRequest = z.infer<typeof putUserAccessRequestSchema>
|
||||
@@ -0,0 +1,101 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const OIDC_SCOPES = ['openid', 'profile', 'email', 'groups'] as const
|
||||
export type OidcScope = (typeof OIDC_SCOPES)[number]
|
||||
|
||||
/** Remote groups for Technitium Group Map (and similar RPs). */
|
||||
export const TECHNITIUM_OIDC_GROUPS = {
|
||||
admins: 'technitium_admins',
|
||||
dnsAdmins: 'technitium_dns_admins',
|
||||
dhcpAdmins: 'technitium_dhcp_admins',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Map portal permissions / admin flag → OIDC group names for Technitium.
|
||||
* Used in id_token + userinfo `groups` and `roles` claims.
|
||||
*/
|
||||
export function oidcGroupsForUser(input: {
|
||||
isAdmin: boolean
|
||||
permissions: readonly string[]
|
||||
}): string[] {
|
||||
const granted = new Set(input.permissions)
|
||||
const groups: string[] = []
|
||||
if (input.isAdmin || granted.has('dns:console:admin')) {
|
||||
groups.push(TECHNITIUM_OIDC_GROUPS.admins)
|
||||
}
|
||||
if (
|
||||
granted.has('dns:dns:write') ||
|
||||
granted.has('dns:dns:admin') ||
|
||||
granted.has('dns:console:admin') ||
|
||||
input.isAdmin
|
||||
) {
|
||||
groups.push(TECHNITIUM_OIDC_GROUPS.dnsAdmins)
|
||||
}
|
||||
if (
|
||||
granted.has('dns:dhcp:write') ||
|
||||
granted.has('dns:dhcp:admin') ||
|
||||
granted.has('dns:console:admin') ||
|
||||
input.isAdmin
|
||||
) {
|
||||
groups.push(TECHNITIUM_OIDC_GROUPS.dhcpAdmins)
|
||||
}
|
||||
return [...new Set(groups)]
|
||||
}
|
||||
|
||||
export const createOidcClientRequestSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
redirect_uris: z.array(z.string().url()).min(1),
|
||||
scopes: z
|
||||
.array(z.enum(OIDC_SCOPES))
|
||||
.min(1)
|
||||
.default([...OIDC_SCOPES]),
|
||||
enabled: z.boolean().default(true),
|
||||
})
|
||||
export type CreateOidcClientRequest = z.infer<
|
||||
typeof createOidcClientRequestSchema
|
||||
>
|
||||
|
||||
export const patchOidcClientRequestSchema = z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
redirect_uris: z.array(z.string().url()).min(1).optional(),
|
||||
scopes: z.array(z.enum(OIDC_SCOPES)).min(1).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
export type PatchOidcClientRequest = z.infer<
|
||||
typeof patchOidcClientRequestSchema
|
||||
>
|
||||
|
||||
export const oidcClientPublicSchema = z.object({
|
||||
id: z.string(),
|
||||
client_id: z.string(),
|
||||
name: z.string(),
|
||||
redirect_uris: z.array(z.string()),
|
||||
scopes: z.array(z.string()),
|
||||
enabled: z.boolean(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
export type OidcClientPublic = z.infer<typeof oidcClientPublicSchema>
|
||||
|
||||
export const oidcClientCreatedSchema = oidcClientPublicSchema.extend({
|
||||
client_secret: z.string(),
|
||||
})
|
||||
export type OidcClientCreated = z.infer<typeof oidcClientCreatedSchema>
|
||||
|
||||
/** True when return_to is portal OIDC authorize (no JWT fragment handoff). */
|
||||
export function isPortalOidcAuthorizeUrl(
|
||||
returnTo: string,
|
||||
issuer: string,
|
||||
): boolean {
|
||||
try {
|
||||
const url = new URL(returnTo)
|
||||
const iss = new URL(issuer)
|
||||
if (url.origin !== iss.origin) return false
|
||||
return (
|
||||
url.pathname === '/oauth/authorize' ||
|
||||
url.pathname === '/oauth/authorize/'
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './contracts/auth.js'
|
||||
export * from './contracts/app-switcher.js'
|
||||
export * from './contracts/audit.js'
|
||||
export * from './contracts/oidc.js'
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
APP_IDS,
|
||||
allPermissionKeys,
|
||||
appsMetaFromSwitcher,
|
||||
buildSsoRedirectUrl,
|
||||
createOidcClientRequestSchema,
|
||||
defaultAppSwitcherConfig,
|
||||
hasPermission,
|
||||
isJwtExpired,
|
||||
isPortalOidcAuthorizeUrl,
|
||||
isReturnToAllowed,
|
||||
loginRequestSchema,
|
||||
normalizeAppSwitcherConfig,
|
||||
normalizePermissionKeys,
|
||||
oidcGroupsForUser,
|
||||
parseAppSwitcherConfig,
|
||||
permissionKey,
|
||||
publicAppSwitcherConfig,
|
||||
readJwtExp,
|
||||
readJwtPayload,
|
||||
tenantsClaimForUser,
|
||||
} from '../src/index.js'
|
||||
|
||||
function jwt(payload: object): string {
|
||||
const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString('base64url')
|
||||
return `${b64({ alg: 'none' })}.${b64(payload)}.sig`
|
||||
}
|
||||
|
||||
describe('permissions', () => {
|
||||
it('иерархия admin ⊃ write ⊃ read в пределах секции', () => {
|
||||
expect(hasPermission(['dns:dns:admin'], 'dns:dns:write')).toBe(true)
|
||||
expect(hasPermission(['dns:dns:admin'], 'dns:dns:read')).toBe(true)
|
||||
expect(hasPermission(['dns:dns:write'], 'dns:dns:read')).toBe(true)
|
||||
expect(hasPermission(['dns:dns:read'], 'dns:dns:write')).toBe(false)
|
||||
expect(hasPermission(['dns:dns:admin'], 'dns:dhcp:read')).toBe(false)
|
||||
expect(hasPermission([], 'broken')).toBe(false)
|
||||
})
|
||||
|
||||
it('permissionKey / allPermissionKeys согласованы', () => {
|
||||
expect(permissionKey('dns', 'dns', 'read')).toBe('dns:dns:read')
|
||||
const all = allPermissionKeys()
|
||||
expect(new Set(all).size).toBe(all.length)
|
||||
expect(all).toContain('dns:dns:read')
|
||||
})
|
||||
|
||||
it('normalizePermissionKeys: легаси мапятся, неизвестные отбрасываются, результат отсортирован', () => {
|
||||
const out = normalizePermissionKeys([
|
||||
'bgp:peers:read',
|
||||
'bgp:settings:admin',
|
||||
'nope:x:read',
|
||||
'dns:dns:read',
|
||||
'dns:dns:read',
|
||||
])
|
||||
expect(out).toEqual([...out].sort())
|
||||
expect(out).toContain('bgp:network:read')
|
||||
expect(out).toContain('bgp:tenant_settings:admin')
|
||||
expect(out).toContain('dns:dns:read')
|
||||
expect(out).not.toContain('nope:x:read')
|
||||
expect(out.filter((k) => k === 'dns:dns:read')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('oidcGroupsForUser', () => {
|
||||
it('админ получает все группы Technitium', () => {
|
||||
expect(oidcGroupsForUser({ isAdmin: true, permissions: [] })).toEqual([
|
||||
'technitium_admins',
|
||||
'technitium_dns_admins',
|
||||
'technitium_dhcp_admins',
|
||||
])
|
||||
})
|
||||
|
||||
it('права dns/dhcp дают соответствующие группы', () => {
|
||||
expect(
|
||||
oidcGroupsForUser({ isAdmin: false, permissions: ['dns:dns:write'] }),
|
||||
).toEqual(['technitium_dns_admins'])
|
||||
expect(
|
||||
oidcGroupsForUser({ isAdmin: false, permissions: ['dns:dhcp:admin'] }),
|
||||
).toEqual(['technitium_dhcp_admins'])
|
||||
expect(oidcGroupsForUser({ isAdmin: false, permissions: [] })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isReturnToAllowed', () => {
|
||||
it.each([
|
||||
['https://cfdm.shnt.top/x', '.shnt.top', true],
|
||||
['https://shnt.top', '.shnt.top', true],
|
||||
['https://evilshnt.top', '.shnt.top', false],
|
||||
['http://localhost:5173/cb', 'localhost', true],
|
||||
['http://192.168.1.5/x', 'private', true],
|
||||
['http://172.32.0.1/x', 'private', false],
|
||||
['https://8.8.8.8', 'private', false],
|
||||
['http://localhost:5173/a', 'http://localhost:5173', true],
|
||||
['http://localhost:5174/a', 'http://localhost:5173', false],
|
||||
['javascript:alert(1)', 'localhost', false],
|
||||
['not a url', 'localhost', false],
|
||||
])('%s vs %s → %s', (url, list, expected) => {
|
||||
expect(isReturnToAllowed(url, list)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('JWT helpers', () => {
|
||||
it('читает payload и exp, определяет протухание с запасом 30 c', () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600
|
||||
const t = jwt({ sub: 'u1', exp })
|
||||
expect(readJwtPayload(t)).toMatchObject({ sub: 'u1' })
|
||||
expect(readJwtExp(t)).toBe(exp)
|
||||
expect(isJwtExpired(t)).toBe(false)
|
||||
expect(isJwtExpired(jwt({ exp: 1 }))).toBe(true)
|
||||
const justExpired = jwt({ exp: Math.floor(Date.now() / 1000) - 10 })
|
||||
expect(isJwtExpired(justExpired)).toBe(false)
|
||||
expect(isJwtExpired(jwt({ sub: 'no-exp' }))).toBe(true)
|
||||
expect(readJwtPayload('garbage')).toBeNull()
|
||||
expect(readJwtExp('a.b')).toBeNull()
|
||||
})
|
||||
|
||||
it('buildSsoRedirectUrl кладёт токен во фрагмент и заменяет старый', () => {
|
||||
const url = buildSsoRedirectUrl('https://a.shnt.top/p?x=1#old', 'T', 'E')
|
||||
expect(url.startsWith('https://a.shnt.top/p?x=1#')).toBe(true)
|
||||
expect(url).toContain('access_token=T')
|
||||
expect(url).not.toContain('old')
|
||||
})
|
||||
})
|
||||
|
||||
describe('app switcher', () => {
|
||||
it('default содержит все приложения', () => {
|
||||
const d = defaultAppSwitcherConfig()
|
||||
expect(d.apps.map((a) => a.id).sort()).toEqual([...APP_IDS].sort())
|
||||
})
|
||||
|
||||
it('parseAppSwitcherConfig: мусор → default, неполный конфиг дополняется', () => {
|
||||
expect(parseAppSwitcherConfig({ nope: 1 })).toEqual(defaultAppSwitcherConfig())
|
||||
const partial = parseAppSwitcherConfig({
|
||||
menuLabel: 'Меню',
|
||||
apps: [
|
||||
{
|
||||
id: 'dns',
|
||||
name: 'DNS!',
|
||||
url: 'https://dns.example.com',
|
||||
icon: 'globe',
|
||||
enabled: false,
|
||||
tenantId: ' ',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(partial.menuLabel).toBe('Меню')
|
||||
expect(partial.apps).toHaveLength(APP_IDS.length)
|
||||
const dns = partial.apps.find((a) => a.id === 'dns')!
|
||||
expect(dns.name).toBe('DNS!')
|
||||
expect(dns.enabled).toBe(false)
|
||||
expect(dns.tenantId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('public-конфиг без tenantId; tenants-claim только по доступным приложениям', () => {
|
||||
const base = defaultAppSwitcherConfig()
|
||||
const cfg = normalizeAppSwitcherConfig({
|
||||
...base,
|
||||
apps: base.apps.map((a) => (a.id === 'bgp' ? { ...a, tenantId: 'tenant-1' } : a)),
|
||||
})
|
||||
expect(JSON.stringify(publicAppSwitcherConfig(cfg))).not.toContain('tenant-1')
|
||||
expect(tenantsClaimForUser(cfg, ['bgp'])).toEqual({ bgp: 'tenant-1' })
|
||||
expect(tenantsClaimForUser(cfg, ['dns'])).toEqual({})
|
||||
})
|
||||
|
||||
it('appsMetaFromSwitcher скрывает отключённые приложения', () => {
|
||||
const cfg = defaultAppSwitcherConfig()
|
||||
cfg.apps = cfg.apps.map((a) => (a.id === 'dns' ? { ...a, enabled: false } : a))
|
||||
expect(appsMetaFromSwitcher(cfg).some((a) => a.id === 'dns')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zod-схемы', () => {
|
||||
it('login: email и непустой пароль обязательны', () => {
|
||||
expect(loginRequestSchema.safeParse({ email: 'a@b.co', password: 'x' }).success).toBe(true)
|
||||
expect(loginRequestSchema.safeParse({ email: 'bad', password: 'x' }).success).toBe(false)
|
||||
expect(loginRequestSchema.safeParse({ email: 'a@b.co', password: '' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('createOidcClient: scopes по умолчанию, redirect_uris не пустой', () => {
|
||||
const ok = createOidcClientRequestSchema.parse({
|
||||
name: 'c',
|
||||
redirect_uris: ['https://x.example/cb'],
|
||||
})
|
||||
expect(ok.scopes).toContain('openid')
|
||||
expect(ok.enabled).toBe(true)
|
||||
expect(
|
||||
createOidcClientRequestSchema.safeParse({ name: 'c', redirect_uris: [] }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('isPortalOidcAuthorizeUrl', () => {
|
||||
const iss = 'https://auth.shnt.top'
|
||||
expect(isPortalOidcAuthorizeUrl(`${iss}/oauth/authorize?x=1`, iss)).toBe(true)
|
||||
expect(isPortalOidcAuthorizeUrl(`${iss}/oauth/authorize/`, iss)).toBe(true)
|
||||
expect(isPortalOidcAuthorizeUrl('https://evil.example/oauth/authorize', iss)).toBe(false)
|
||||
expect(isPortalOidcAuthorizeUrl(`${iss}/other`, iss)).toBe(false)
|
||||
expect(isPortalOidcAuthorizeUrl('nope', iss)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
include: ['test/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
reporter: ['text-summary', 'lcov'],
|
||||
// Стартовые пороги: поднимать по мере роста покрытия, не снижать.
|
||||
thresholds: { lines: 90 },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user