refactor(decnet_web/Credentials): extract types + helpers with tests

This commit is contained in:
2026-05-09 06:09:57 -04:00
parent 2c1ccec8fa
commit 275fac5288
3 changed files with 162 additions and 0 deletions

View File

@@ -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> = {}): 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> = {}): 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]);
});
});

View File

@@ -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' };
}

View File

@@ -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';