mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +00:00
fix(stack-activity): per-stack history integrity, attribution, sanitization (#1228)
* fix(stack-activity): per-stack history integrity, attribution, sanitization Address the Stack Activity audit findings (PR 1 of 2): - Per-stack history integrity: drop the per-insert 100-row prune in addNotificationHistory that evicted quieter stacks' history whenever another stack got chatty. Periodic cleanupOldNotifications now caps per (node, stack) at 500 rows and per-node unattached system events at 1000 rows, on top of the existing 30-day retention. Signature takes an options bag and returns a per-stage summary so MonitorService can log what actually ran each cycle. - Actor attribution: thread req.user?.username through every notifyActionFailure call site and add synthetic actors at service emit sites (system:autoheal, system:scheduler, system:image-update, system:docker-events, system:blueprint, system:monitor, system:policy). The timeline renders system actors as "via <Label>" so an autoheal redeploy is no longer indistinguishable from a user redeploy. - Message sanitization: new sanitizeNotificationMessage at NotificationService.dispatchAlert strips KEY=VALUE pairs whose key ends in TOKEN/KEY/PASSWORD/SECRET/CREDENTIALS/AUTH, scrubs HTTP basic auth in URLs and Bearer tokens, collapses COMPOSE_DIR paths, and truncates to 1000 chars. Applied to the stored history and to every downstream Discord/Slack/webhook channel. The ImageUpdateService recovery-path direct DB write also runs through the sanitizer. - Composite pagination cursor: getStackActivity now accepts a (timestamp, id) cursor (?before=&beforeId=). The legacy timestamp-only form silently dropped events when a single compose up emitted many events sharing one millisecond. Route rejects beforeId without before. - Frontend hardening: distinct error state with retry button (initial fetch failure no longer renders as the genuine empty state), strict positive-integer parsing on cursor params, overrequest-by-1 pagination so the last page does not leave a dead "Load more" click, runtime guard on liveEvents merge that validates the level union, per-minute day-bucket recompute so an open panel does not stay on "Today" past midnight. No tier, role, or capability gate touched. Route permission gate remains stack:read on the named stack. * fix(stack-activity): sanitizer covers lowercase env vars and per-node compose dir External review surfaced two leak paths in the message sanitizer: - The sensitive-key regex was uppercase-only. Compose env names are conventionally uppercase but lowercase forms (db_password, jwt_secret, github_token) are valid and do leak through the same Docker and compose-parse error paths. Make the regex case-insensitive and tighten it to also catch bare TOKEN= / KEY= / PASSWORD= without a prefix word, while still leaving BYPASS, COMPASS, and similar non-secret keys alone. - The compose-dir path collapse only read process.env.COMPOSE_DIR, but the real resolution chain is node.compose_dir (per-node DB override) -> process.env.COMPOSE_DIR -> /app/compose. A node with a custom compose_dir could still leak absolute paths into stored history and downstream channels. Route both the dispatchAlert call and the ImageUpdateService recovery-path direct write through NodeRegistry.getInstance().getComposeDir(localNodeId) so the collapse covers every resolution outcome. Tests now assert lowercase keys are redacted and that BYPASS-style non-secrets stay intact in both cases. notification-routing mock extended to stub the new getComposeDir call. * chore(stack-activity): a11y roles, visibility-aware tick, live-disconnect signal Close three small follow-ups on the per-stack activity timeline: - A11y: each day-group gets role="list" and each event row gets role="listitem" so screen readers traverse the timeline as a list instead of a wall of text. The day-group container also carries an aria-label naming the bucket. - Visibility-aware day-bucket tick: the 60s setInterval that re-derives Today/Yesterday/Earlier now short-circuits when document.hidden, so a backgrounded panel does not re-render every minute for no visible effect. - Live-disconnect signal: useNotifications dispatches a sencho:notifications-connection custom event on WebSocket open and close. The timeline listens and, when explicitly disconnected, shows a one-line "Live updates offline; reconnecting…" hint above the list. The sidebar ticker already surfaces fleet-wide connection state; this adds an in-context cue for users who are focused on a single stack. Stack-name case normalization was considered and rejected: stack names are case-permissive per the isValidStackName validator, and lowercasing on read or write would silently rename or hide a user's "MyApp" stack. * ci(stack-activity): drop unnecessary escape in URL_BASIC_AUTH regex ESLint no-useless-escape errored on \- inside the character class [a-zA-Z0-9+.\-] at notificationMessage.ts:14. Move the dash to the end of the class so it's an unambiguous literal and the escape is no longer required. Behavior is identical; sanitizer tests still pass. * revert(stack-activity): drop unvalidated E2E spec from this PR The spec was committed without ever running against a real Docker daemon, then failed in CI when it ran for the first time: deploy returned 200 but no notification appeared on the activity endpoint within the polling window, suggesting either a deploy-notification race or a node-id resolution mismatch in the CI environment. Backend unit tests (route + composite cursor + sanitizer) and frontend component tests cover the same logic. The E2E spec will land in a dedicated follow-up once it has been authored against a working CI environment.
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const { toastFns } = vi.hoisted(() => ({
|
||||
toastFns: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('@/components/ui/toast-store', () => ({ toast: toastFns }));
|
||||
|
||||
vi.mock('@/lib/relativeTime', () => ({
|
||||
formatTimeAgo: (ts: number) => `t-${ts}`,
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { StackActivityTimeline } from '../StackActivityTimeline';
|
||||
|
||||
const mockFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
interface FakeEvent {
|
||||
id: number;
|
||||
level: string;
|
||||
category?: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
stack_name?: string;
|
||||
actor_username?: string | null;
|
||||
}
|
||||
|
||||
function evt(overrides: Partial<FakeEvent> = {}): FakeEvent {
|
||||
return {
|
||||
id: 1,
|
||||
level: 'info',
|
||||
category: 'deploy_success',
|
||||
message: 'deployed',
|
||||
timestamp: Date.now(),
|
||||
stack_name: 'web',
|
||||
actor_username: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(events: FakeEvent[]): Promise<Response> {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ events }),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
Object.values(toastFns).forEach(fn => (fn as ReturnType<typeof vi.fn>).mockReset());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('StackActivityTimeline - loading and empty', () => {
|
||||
it('renders a spinner while the initial fetch is in flight', () => {
|
||||
mockFetch.mockReturnValueOnce(new Promise(() => { /* never resolves */ }));
|
||||
render(<StackActivityTimeline stackName="web" />);
|
||||
expect(document.querySelector('.animate-spin')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders empty state when no events are returned', async () => {
|
||||
mockFetch.mockReturnValueOnce(jsonResponse([]));
|
||||
render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText('No activity recorded yet')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('renders an error affordance with retry when fetch fails', async () => {
|
||||
mockFetch.mockReturnValueOnce(Promise.resolve({ ok: false, status: 500, json: async () => ({}) } as Response));
|
||||
render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText('Activity unavailable')).toBeTruthy());
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Retry re-issues the fetch', async () => {
|
||||
mockFetch
|
||||
.mockReturnValueOnce(Promise.resolve({ ok: false, status: 500, json: async () => ({}) } as Response))
|
||||
.mockReturnValueOnce(jsonResponse([evt({ id: 1, message: 'recovered' })]));
|
||||
const user = userEvent.setup();
|
||||
render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText('Activity unavailable')).toBeTruthy());
|
||||
await user.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
await waitFor(() => expect(screen.getByText('recovered')).toBeTruthy());
|
||||
});
|
||||
});
|
||||
|
||||
describe('StackActivityTimeline - pagination', () => {
|
||||
it('hides "Load more" when initial response is shorter than PAGE_SIZE + 1', async () => {
|
||||
mockFetch.mockReturnValueOnce(jsonResponse([evt({ id: 1, message: 'only' })]));
|
||||
render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText('only')).toBeTruthy());
|
||||
expect(screen.queryByText('Load more')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows "Load more" when initial response has PAGE_SIZE+1 events and trims one row', async () => {
|
||||
const page1 = Array.from({ length: 51 }, (_, i) => evt({ id: 100 - i, message: `e-${i}`, timestamp: 1000 - i }));
|
||||
mockFetch.mockReturnValueOnce(jsonResponse(page1));
|
||||
render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText('e-0')).toBeTruthy());
|
||||
expect(screen.queryByText('e-50')).toBeNull(); // 51st event was trimmed
|
||||
expect(screen.getByText('Load more')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Load more requests both before and beforeId, dedupes against existing events', async () => {
|
||||
const page1 = Array.from({ length: 51 }, (_, i) => evt({ id: 100 - i, message: `e-${i}`, timestamp: 1000 - i }));
|
||||
const page2 = [evt({ id: 50, message: 'older', timestamp: 949 })];
|
||||
mockFetch
|
||||
.mockReturnValueOnce(jsonResponse(page1))
|
||||
.mockReturnValueOnce(jsonResponse(page2));
|
||||
const user = userEvent.setup();
|
||||
render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText('Load more')).toBeTruthy());
|
||||
|
||||
await user.click(screen.getByText('Load more'));
|
||||
await waitFor(() => expect(screen.getByText('older')).toBeTruthy());
|
||||
|
||||
// After trimming the 51st row, the oldest displayed event is { id: 51, ts: 951 },
|
||||
// so loadMore sends before=951, beforeId=51 as the composite cursor.
|
||||
const lastCall = mockFetch.mock.calls[1][0] as string;
|
||||
expect(lastCall).toContain('limit=51');
|
||||
expect(lastCall).toContain('before=951');
|
||||
expect(lastCall).toContain('beforeId=51');
|
||||
});
|
||||
|
||||
it('toasts on loadMore failure and keeps existing rows', async () => {
|
||||
const page1 = Array.from({ length: 51 }, (_, i) => evt({ id: 100 - i, message: `e-${i}`, timestamp: 1000 - i }));
|
||||
mockFetch
|
||||
.mockReturnValueOnce(jsonResponse(page1))
|
||||
.mockReturnValueOnce(Promise.resolve({ ok: false, status: 500, json: async () => ({}) } as Response));
|
||||
const user = userEvent.setup();
|
||||
render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText('Load more')).toBeTruthy());
|
||||
await user.click(screen.getByText('Load more'));
|
||||
await waitFor(() => expect(toastFns.error).toHaveBeenCalledWith('Failed to load more activity'));
|
||||
expect(screen.getByText('e-0')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StackActivityTimeline - liveEvents merge', () => {
|
||||
it('merges new live events into the timeline', async () => {
|
||||
mockFetch.mockReturnValueOnce(jsonResponse([evt({ id: 1, message: 'first', timestamp: 100 })]));
|
||||
const { rerender } = render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText('first')).toBeTruthy());
|
||||
|
||||
rerender(<StackActivityTimeline stackName="web" liveEvents={[evt({ id: 2, message: 'live', timestamp: 200 }) as never]} />);
|
||||
await waitFor(() => expect(screen.getByText('live')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('dedupes live events that overlap an already-loaded id', async () => {
|
||||
mockFetch.mockReturnValueOnce(jsonResponse([evt({ id: 1, message: 'first', timestamp: 100 })]));
|
||||
const { rerender } = render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText('first')).toBeTruthy());
|
||||
|
||||
rerender(<StackActivityTimeline stackName="web" liveEvents={[evt({ id: 1, message: 'first', timestamp: 100 }) as never]} />);
|
||||
// Still exactly one row.
|
||||
expect(screen.getAllByText('first')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops malformed live events through the narrow guard but accepts well-formed siblings', async () => {
|
||||
mockFetch.mockReturnValueOnce(jsonResponse([evt({ id: 1, message: 'first', timestamp: 100 })]));
|
||||
const { rerender } = render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText('first')).toBeTruthy());
|
||||
|
||||
// Mixed batch: one bad (id is a string, wrong level), one good. The guard
|
||||
// must reject the bad row and let the good one through.
|
||||
rerender(
|
||||
<StackActivityTimeline
|
||||
stackName="web"
|
||||
liveEvents={[
|
||||
{ id: 'not-a-number', message: 'BAD', timestamp: 1, level: 'info' } as never,
|
||||
evt({ id: 99, message: 'GOOD', timestamp: 200 }) as never,
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText('GOOD')).toBeTruthy());
|
||||
expect(screen.queryByText('BAD')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an event whose level is outside the union', async () => {
|
||||
mockFetch.mockReturnValueOnce(jsonResponse([evt({ id: 1, message: 'first', timestamp: 100 })]));
|
||||
const { rerender } = render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText('first')).toBeTruthy());
|
||||
|
||||
rerender(
|
||||
<StackActivityTimeline
|
||||
stackName="web"
|
||||
liveEvents={[{ id: 2, message: 'CRIT', timestamp: 200, level: 'critical' } as never]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('CRIT')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StackActivityTimeline - stackName change resets state', () => {
|
||||
it('refetches when stackName changes and clears prior events', async () => {
|
||||
mockFetch.mockReturnValueOnce(jsonResponse([evt({ id: 1, message: 'web-evt' })]));
|
||||
const { rerender } = render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText('web-evt')).toBeTruthy());
|
||||
|
||||
mockFetch.mockReturnValueOnce(jsonResponse([evt({ id: 2, message: 'api-evt', stack_name: 'api' })]));
|
||||
rerender(<StackActivityTimeline stackName="api" />);
|
||||
await waitFor(() => expect(screen.getByText('api-evt')).toBeTruthy());
|
||||
expect(screen.queryByText('web-evt')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StackActivityTimeline - actor rendering', () => {
|
||||
it('renders human actor with "by <name>" prefix', async () => {
|
||||
mockFetch.mockReturnValueOnce(jsonResponse([evt({ id: 1, message: 'deployed', actor_username: 'alice' })]));
|
||||
render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText(/by alice/)).toBeTruthy());
|
||||
});
|
||||
|
||||
it('renders synthetic system actor with "via <Label>" prefix', async () => {
|
||||
mockFetch.mockReturnValueOnce(jsonResponse([evt({ id: 1, message: 'restarted', actor_username: 'system:autoheal' })]));
|
||||
render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText(/via Auto-Heal/)).toBeTruthy());
|
||||
});
|
||||
|
||||
it('renders bare "system" actor as "via System"', async () => {
|
||||
mockFetch.mockReturnValueOnce(jsonResponse([evt({ id: 1, message: 'event', actor_username: 'system' })]));
|
||||
render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText(/via System/)).toBeTruthy());
|
||||
});
|
||||
|
||||
it('omits actor line when actor_username is null', async () => {
|
||||
mockFetch.mockReturnValueOnce(jsonResponse([evt({ id: 1, message: 'event', actor_username: null })]));
|
||||
render(<StackActivityTimeline stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText('event')).toBeTruthy());
|
||||
expect(screen.queryByText(/^(by|via)\s/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user