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