200 lines
7.6 KiB
TypeScript
200 lines
7.6 KiB
TypeScript
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)
|
|
})
|
|
})
|