mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
fix(api-tokens): harden rate limiting and surface list-load errors (#1292)
* fix(api-tokens): scope per-token rate limits to live tokens Forged or token-shaped Authorization headers no longer mint their own rate-limit budget. The key generator now grants a per-token budget only to a real, active token and falls back to per-IP keying for anything else, so a single source cannot evade the global limiter by rotating fake tokens. The validated token is memoized on the request, so authentication reuses it without a second database lookup. Token validation (format, checksum, lookup, revocation, expiry) is now a single shared helper used by the HTTP auth middleware, the WebSocket upgrade handler, and the rate-limit key generator, replacing two near-identical inline copies that could drift apart. The last-used timestamp write is throttled so a busy token no longer writes to the database on every request. * fix(api-tokens): surface token list-load failures with a retry A failed load of the API tokens list was swallowed: a server error rendered the empty "no tokens yet" state with no sign that anything went wrong. The list now shows an error card with a Retry action and raises a toast on any non-ok response or network error, matching the create and revoke flows. Adds a troubleshooting entry for the error. * test(api-tokens): seed tokens via the shared test helper The new hardening and WS-scope suites computed sha256 of a raw token directly, which CodeQL flags as js/insufficient-password-hash (a false positive: these are 256-bit CSPRNG opaque tokens, not passwords). Route token creation through the existing apiTokenTestHelper and read the stored token_hash back from the row, so the suites no longer hash anything themselves. Also removes the duplicated createToken helpers. * fix(api-tokens): key the rate limiter by the same credential auth uses The rate-limit key generator checked the session cookie before the Authorization bearer, while authMiddleware authenticates bearer-over-cookie (bearerToken || cookieToken). A request could send a Bearer API token plus a forged cookie and be keyed by the cookie's (forgeable, rotatable) username, sidestepping the per-token / per-IP keying the limiter applies to API tokens: a valid token would lose its own bucket, and a forged token-shaped bearer would no longer collapse to per-IP. Reorder the generator to mirror auth: process the bearer first (validate the API token and key per-token or fall back to per-IP; otherwise decode the JWT by username/sub), and consult the cookie only when there is no bearer. Regression tests cover a valid and a forged sen_sk_ bearer, each sent with a forged cookie.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Coverage for ApiTokensSection load behavior.
|
||||
*
|
||||
* Locks the fix where a non-ok token-list response was swallowed silently: the
|
||||
* section must surface an error toast and an error state with a retry, rather
|
||||
* than presenting the empty "No API tokens yet" state as if no tokens existed.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Render CapabilityGate's children directly: the capability gate is not under test.
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => ({
|
||||
hasCapability: () => true,
|
||||
activeNode: { id: 1, name: 'local' },
|
||||
activeNodeMeta: { version: '1.0.0', capabilities: ['api-tokens'], fetchedAt: 0 },
|
||||
}),
|
||||
}));
|
||||
|
||||
// The masthead stats hook depends on a provider that is not mounted here.
|
||||
vi.mock('../settings/MastheadStatsContext', () => ({
|
||||
useMastheadStats: () => {},
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { ApiTokensSection } from '../ApiTokensSection';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockedToast = toast as unknown as { error: ReturnType<typeof vi.fn> };
|
||||
|
||||
function tokenRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'ci-pipeline',
|
||||
scope: 'read-only',
|
||||
created_at: 1_700_000_000_000,
|
||||
last_used_at: null,
|
||||
expires_at: null,
|
||||
revoked_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ApiTokensSection load behavior', () => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
mockedToast.error.mockReset();
|
||||
});
|
||||
|
||||
it('surfaces an error toast and an error state (not the empty state) when the list load fails', async () => {
|
||||
mockedFetch.mockResolvedValue({ ok: false, json: async () => ({ error: 'list blew up' }) });
|
||||
|
||||
render(<ApiTokensSection />);
|
||||
|
||||
await waitFor(() => expect(mockedToast.error).toHaveBeenCalledWith('list blew up'));
|
||||
expect(await screen.findByText("Couldn't load API tokens")).toBeInTheDocument();
|
||||
expect(screen.queryByText('No API tokens yet')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the empty state and does not toast on a successful empty load', async () => {
|
||||
mockedFetch.mockResolvedValue({ ok: true, json: async () => [] });
|
||||
|
||||
render(<ApiTokensSection />);
|
||||
|
||||
expect(await screen.findByText('No API tokens yet')).toBeInTheDocument();
|
||||
expect(screen.queryByText("Couldn't load API tokens")).toBeNull();
|
||||
expect(mockedToast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the token list and does not toast on a successful load', async () => {
|
||||
mockedFetch.mockResolvedValue({ ok: true, json: async () => [tokenRow()] });
|
||||
|
||||
render(<ApiTokensSection />);
|
||||
|
||||
expect(await screen.findByText('ci-pipeline')).toBeInTheDocument();
|
||||
expect(mockedToast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recovers via Retry: a failed load then a successful one clears the error state', async () => {
|
||||
mockedFetch
|
||||
.mockResolvedValueOnce({ ok: false, json: async () => ({ error: 'transient' }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => [] });
|
||||
|
||||
render(<ApiTokensSection />);
|
||||
|
||||
const retry = await screen.findByRole('button', { name: /retry/i });
|
||||
fireEvent.click(retry);
|
||||
|
||||
expect(await screen.findByText('No API tokens yet')).toBeInTheDocument();
|
||||
expect(screen.queryByText("Couldn't load API tokens")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user