refactor(decnet_web/Credentials): extract types + helpers with tests
This commit is contained in:
71
decnet_web/src/components/Credentials/helpers.test.ts
Normal file
71
decnet_web/src/components/Credentials/helpers.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
80
decnet_web/src/components/Credentials/helpers.ts
Normal file
80
decnet_web/src/components/Credentials/helpers.ts
Normal 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' };
|
||||
}
|
||||
11
decnet_web/src/components/Credentials/types.ts
Normal file
11
decnet_web/src/components/Credentials/types.ts
Normal 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';
|
||||
Reference in New Issue
Block a user