From 275fac528802601222767d50cd37a56f75d73a90 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 06:09:57 -0400 Subject: [PATCH] refactor(decnet_web/Credentials): extract types + helpers with tests --- .../components/Credentials/helpers.test.ts | 71 ++++++++++++++++ .../src/components/Credentials/helpers.ts | 80 +++++++++++++++++++ .../src/components/Credentials/types.ts | 11 +++ 3 files changed, 162 insertions(+) create mode 100644 decnet_web/src/components/Credentials/helpers.test.ts create mode 100644 decnet_web/src/components/Credentials/helpers.ts create mode 100644 decnet_web/src/components/Credentials/types.ts diff --git a/decnet_web/src/components/Credentials/helpers.test.ts b/decnet_web/src/components/Credentials/helpers.test.ts new file mode 100644 index 00000000..bff63887 --- /dev/null +++ b/decnet_web/src/components/Credentials/helpers.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { + nextSortState, reuseKey, sortCreds, sortReuse, truncHash, +} from './helpers'; +import type { CredentialEntry, CredentialReuseRow } from './types'; + +describe('truncHash', () => { + it('truncates and appends ellipsis', () => { + expect(truncHash('abcdef1234567890fedcba')).toBe('abcdef123456…'); + expect(truncHash('abcdef1234567890', 4)).toBe('abcd…'); + }); + it('returns em-dash for null/undefined', () => { + expect(truncHash(null)).toBe('—'); + expect(truncHash(undefined)).toBe('—'); + }); +}); + +describe('reuseKey', () => { + it('joins sha|kind|principal with empty string for null principal', () => { + expect(reuseKey('abc', 'plaintext', 'admin')).toBe('abc|plaintext|admin'); + expect(reuseKey('abc', 'sha512crypt', null)).toBe('abc|sha512crypt|'); + }); +}); + +describe('nextSortState', () => { + it('switches to a new column with asc', () => { + expect(nextSortState({ col: '', dir: 'asc' }, 'svc')).toEqual({ col: 'svc', dir: 'asc' }); + expect(nextSortState({ col: 'kind', dir: 'desc' }, 'svc')).toEqual({ col: 'svc', dir: 'asc' }); + }); + it('cycles asc → desc → off on the same column', () => { + expect(nextSortState({ col: 'svc', dir: 'asc' }, 'svc')).toEqual({ col: 'svc', dir: 'desc' }); + expect(nextSortState({ col: 'svc', dir: 'desc' }, 'svc')).toEqual({ col: '', dir: 'asc' }); + }); +}); + +const cred = (over: Partial = {}): CredentialEntry => ({ + id: '1', last_seen: '2026-05-01T00:00:00Z', decky_name: 'd1', service: 'ssh', + attacker_ip: '1.2.3.4', principal: 'root', secret_sha256: 'a', + secret_kind: 'plaintext', secret_printable: 'p', attempt_count: 5, + ...over, +} as CredentialEntry); + +describe('sortCreds', () => { + it('returns the input untouched for empty col', () => { + const rows = [cred({ id: 'A' }), cred({ id: 'B' })]; + expect(sortCreds(rows, '', 'asc')).toBe(rows); + }); + it('sorts numbers numerically (asc/desc)', () => { + const rows = [cred({ attempt_count: 3 }), cred({ attempt_count: 10 }), cred({ attempt_count: 1 })]; + expect(sortCreds(rows, 'hits', 'asc').map((r) => r.attempt_count)).toEqual([1, 3, 10]); + expect(sortCreds(rows, 'hits', 'desc').map((r) => r.attempt_count)).toEqual([10, 3, 1]); + }); + it('sorts strings via localeCompare', () => { + const rows = [cred({ decky_name: 'beta' }), cred({ decky_name: 'alpha' })]; + expect(sortCreds(rows, 'decky', 'asc').map((r) => r.decky_name)).toEqual(['alpha', 'beta']); + }); +}); + +const reuse = (over: Partial = {}): CredentialReuseRow => ({ + id: '1', last_seen: '2026-05-01T00:00:00Z', principal: null, + secret_sha256: 'a', secret_kind: 'plaintext', + target_count: 1, attempt_count: 1, deckies: [], services: [], + ...over, +} as CredentialReuseRow); + +describe('sortReuse', () => { + it('sorts target counts desc', () => { + const rows = [reuse({ target_count: 1 }), reuse({ target_count: 9 }), reuse({ target_count: 3 })]; + expect(sortReuse(rows, 'targets', 'desc').map((r) => r.target_count)).toEqual([9, 3, 1]); + }); +}); diff --git a/decnet_web/src/components/Credentials/helpers.ts b/decnet_web/src/components/Credentials/helpers.ts new file mode 100644 index 00000000..b7936574 --- /dev/null +++ b/decnet_web/src/components/Credentials/helpers.ts @@ -0,0 +1,80 @@ +import type { CredentialEntry, CredentialReuseRow, SortDir } from './types'; + +export const CREDS_LIMIT = 50; +export const REUSE_LIMIT = 25; +export const REUSE_MAP_CAP = 500; + +export const truncHash = (h: string | null | undefined, n = 12): string => + h ? `${h.slice(0, n)}…` : '—'; + +export const reuseKey = ( + sha: string, + kind: string, + principal: string | null, +): string => `${sha}|${kind}|${principal ?? ''}`; + +type CredSortCol = 'seen' | 'decky' | 'svc' | 'attacker' | 'principal' | 'kind' | 'hits' | ''; + +export function sortCreds( + rows: CredentialEntry[], + col: CredSortCol, + dir: SortDir, +): CredentialEntry[] { + if (!col) return rows; + const pick = (r: CredentialEntry): string | number => { + switch (col) { + case 'seen': return r.last_seen; + case 'decky': return r.decky_name; + case 'svc': return r.service; + case 'attacker': return r.attacker_ip; + case 'principal': return r.principal ?? ''; + case 'kind': return r.secret_kind; + case 'hits': return r.attempt_count; + default: return ''; + } + }; + return [...rows].sort((a, b) => { + const av = pick(a); const bv = pick(b); + const cmp = typeof av === 'number' && typeof bv === 'number' + ? av - bv + : String(av).localeCompare(String(bv)); + return dir === 'asc' ? cmp : -cmp; + }); +} + +type ReuseSortCol = 'seen' | 'principal' | 'kind' | 'targets' | 'attempts' | ''; + +export function sortReuse( + rows: CredentialReuseRow[], + col: ReuseSortCol, + dir: SortDir, +): CredentialReuseRow[] { + if (!col) return rows; + const pick = (r: CredentialReuseRow): string | number => { + switch (col) { + case 'seen': return r.last_seen; + case 'principal': return r.principal ?? ''; + case 'kind': return r.secret_kind; + case 'targets': return r.target_count; + case 'attempts': return r.attempt_count; + default: return ''; + } + }; + return [...rows].sort((a, b) => { + const av = pick(a); const bv = pick(b); + const cmp = typeof av === 'number' && typeof bv === 'number' + ? av - bv + : String(av).localeCompare(String(bv)); + return dir === 'asc' ? cmp : -cmp; + }); +} + +/** Cycle a sort column through asc → desc → off when clicked. */ +export function nextSortState( + current: { col: string; dir: SortDir }, + clicked: string, +): { col: string; dir: SortDir } { + if (current.col !== clicked) return { col: clicked, dir: 'asc' }; + if (current.dir === 'asc') return { col: clicked, dir: 'desc' }; + return { col: '', dir: 'asc' }; +} diff --git a/decnet_web/src/components/Credentials/types.ts b/decnet_web/src/components/Credentials/types.ts new file mode 100644 index 00000000..66e8229e --- /dev/null +++ b/decnet_web/src/components/Credentials/types.ts @@ -0,0 +1,11 @@ +export type { CredentialEntry } from '../CredentialsInspector'; +export type { CredentialReuseRow } from '../CredentialReuseInspector'; + +export type Tab = 'creds' | 'reuse'; + +export interface ReuseMapEntry { + id: string; + target_count: number; +} + +export type SortDir = 'asc' | 'desc';