Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions apps/web/.storybook/main.ts

This file was deleted.

17 changes: 0 additions & 17 deletions apps/web/.storybook/preview.ts

This file was deleted.

17 changes: 17 additions & 0 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,20 @@ pnpm e2e:trace # record a trace; open it with: npx playwright show-trace
The suite includes an automated accessibility pass (`e2e/a11y.spec.ts`) using
`@axe-core/playwright`; pre-existing violations are listed in an allowlist in
that file, each linked to its tracking issue, so new violations fail the run.

## Rate limiting

The TOTP verification limiter (`src/lib/auth/totp-rate-limit.ts`) and the inventory reservation limiter (`src/lib/inventory/reservation-rate-limit.ts`) share a backend choice driven by two environment variables:

| Variable | Purpose |
| -------------------------- | ------------------------------------------- |
| `UPSTASH_REDIS_REST_URL` | REST endpoint of the Upstash Redis database |
| `UPSTASH_REDIS_REST_TOKEN` | REST token for that database |

The pair is all-or-nothing: Upstash is used only when **both** are set. If either is missing, each limiter falls back to an in-memory window held inside the running process. The limiter does not switch off, but its counter is private to that process.

**A deployment that runs more than one instance must set both variables.** Without them every instance keeps its own counter, so the five-attempt TOTP limit and the reservation limit each become that many attempts _per instance_, and the limits stop meaning anything.

## Storybook

This app does not ship Storybook. It was configured but no workflow ever built it, so its config and stories could rot unnoticed while the dependencies were installed on every run. It was removed rather than gated; if component documentation is wanted again, re-add it together with a CI step that runs `storybook build`.
8 changes: 1 addition & 7 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@
"test:visual:update": "playwright test --update-snapshots",
"e2e": "playwright test --config playwright.e2e.config.ts",
"e2e:headed": "playwright test --config playwright.e2e.config.ts --headed",
"e2e:trace": "playwright test --config playwright.e2e.config.ts --trace on",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"e2e:trace": "playwright test --config playwright.e2e.config.ts --trace on"
},
"dependencies": {
"@accensa/sdk": "workspace:^",
Expand Down Expand Up @@ -52,9 +50,6 @@
"@axe-core/playwright": "^4.13.0",
"@next/bundle-analyzer": "^16.3.8",
"@playwright/test": "^1.62.1",
"@storybook/addon-essentials": "^8.6.14",
"@storybook/nextjs": "^8.6.14",
"@storybook/react": "^8.6.14",
"@tailwindcss/postcss": "^4.3.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
Expand All @@ -66,7 +61,6 @@
"eslint": "^9.39.5",
"eslint-config-next": "16.2.10",
"jsdom": "^30.1.1",
"storybook": "^8.6.14",
"tailwindcss": "^4.3.2",
"typescript": "^5.9.3",
"vitest": "^2.1.9"
Expand Down
228 changes: 228 additions & 0 deletions apps/web/src/app/api/merchant/settings/notifications/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { expect, test, vi, describe, beforeEach, afterEach } from 'vitest';
import { GET, PATCH } from './route';

const {
MERCHANT,
clientStub,
mockWithClient,
mockWithMerchantClient,
mockGetMerchantFromRequest,
mockIsAdmin,
mockGetConfig,
mockSaveConfig,
} = vi.hoisted(() => {
const merchant = { id: 7, address: 'GABC' };
const clientStub = { query: vi.fn().mockResolvedValue({ rows: [] }) };
return {
MERCHANT: merchant,
clientStub,
mockWithClient: vi.fn(async (fn: (client: unknown) => Promise<unknown>) => fn(clientStub)),
mockWithMerchantClient: vi.fn(
async (_merchantId: number, fn: (client: unknown) => Promise<unknown>) => fn(clientStub),
),
mockGetMerchantFromRequest: vi.fn(),
mockIsAdmin: vi.fn(),
mockGetConfig: vi.fn(),
mockSaveConfig: vi.fn(),
};
});

vi.mock('@/lib/db', () => ({
withClient: mockWithClient,
withMerchantClient: mockWithMerchantClient,
ensureSchema: vi.fn().mockResolvedValue(undefined),
}));

vi.mock('@/lib/merchants', () => ({
getMerchantFromRequest: mockGetMerchantFromRequest,
}));

vi.mock('@/lib/rbac', () => ({
isAdmin: mockIsAdmin,
}));

// The config store is stubbed; the real Discord URL validator is kept so the
// route's rejection of non-Discord URLs is exercised for real.
vi.mock('@/lib/notifications/chatNotifier', async () => {
const actual = await vi.importActual<typeof import('@/lib/notifications/chatNotifier')>(
'@/lib/notifications/chatNotifier',
);
return {
...actual,
getChatNotifierConfig: mockGetConfig,
saveChatNotifierConfig: mockSaveConfig,
};
});

const URL_ = 'http://localhost/api/merchant/settings/notifications';
const VALID_TOKEN = '123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ_abc-123';
const VALID_WEBHOOK = 'https://discord.com/api/webhooks/123456789012345678/abcDEF_ghi-JKL';

const STORED = {
telegramBotToken: 'SECRET-BOT-TOKEN',
telegramChatId: '-100123',
discordWebhookUrl: 'https://discord.com/api/webhooks/1/SECRET-WEBHOOK-TOKEN',
eventFilter: 'all',
};

// The handlers' inferred return type includes `undefined`; a real call never
// returns it, so narrow once here instead of at every assertion.
async function respond(pending: Promise<Response | undefined>): Promise<Response> {
const res = await pending;
if (!res) throw new Error('handler returned no response');
return res;
}

function patch(body: unknown) {
return respond(
PATCH(
new Request(URL_, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: typeof body === 'string' ? body : JSON.stringify(body),
}),
),
);
}

async function errorOf(res: Response) {
expect(res.status).toBe(400);
return ((await res.json()) as { error: string }).error;
}

describe('/api/merchant/settings/notifications', () => {
const originalDbUrl = process.env.DATABASE_URL;

beforeEach(() => {
vi.clearAllMocks();
process.env.DATABASE_URL = 'postgres://test';
mockIsAdmin.mockReturnValue(true);
mockGetMerchantFromRequest.mockResolvedValue(MERCHANT);
mockGetConfig.mockResolvedValue(STORED);
mockSaveConfig.mockImplementation(async (_client: unknown, _id: number, update: object) => ({
...STORED,
...update,
}));
});

afterEach(() => {
if (originalDbUrl === undefined) delete process.env.DATABASE_URL;
else process.env.DATABASE_URL = originalDbUrl;
});

describe('GET', () => {
test('returns the public projection and strips secret material', async () => {
const res = await respond(GET(new Request(URL_)));
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({
telegramConfigured: true,
telegramChatId: '-100123',
discordConfigured: true,
eventFilter: 'all',
});
const raw = JSON.stringify(body);
expect(raw).not.toContain('SECRET-BOT-TOKEN');
expect(raw).not.toContain('SECRET-WEBHOOK-TOKEN');
expect(body).not.toHaveProperty('telegramBotToken');
expect(body).not.toHaveProperty('discordWebhookUrl');
expect(mockGetConfig).toHaveBeenCalledWith(clientStub, MERCHANT.id);
});

test('returns 500 when DATABASE_URL is not configured', async () => {
delete process.env.DATABASE_URL;
const res = await respond(GET(new Request(URL_)));
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: 'DATABASE_URL is not configured' });
expect(mockGetConfig).not.toHaveBeenCalled();
});
});

describe('PATCH', () => {
test('returns 500 when DATABASE_URL is not configured', async () => {
delete process.env.DATABASE_URL;
const res = await patch({ eventFilter: 'all' });
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: 'DATABASE_URL is not configured' });
expect(mockSaveConfig).not.toHaveBeenCalled();
});

test('rejects a non-JSON body', async () => {
expect(await errorOf(await patch('not json {'))).toBe('Request body must be JSON');
expect(mockSaveConfig).not.toHaveBeenCalled();
});

test('rejects a JSON body that is not an object', async () => {
expect(await errorOf(await patch(['a']))).toBe('Request body must be an object');
expect(await errorOf(await patch('null'))).toBe('Request body must be an object');
expect(await errorOf(await patch('"text"'))).toBe('Request body must be an object');
expect(mockSaveConfig).not.toHaveBeenCalled();
});

test('rejects an unrecognised key with "Unknown notification setting"', async () => {
expect(await errorOf(await patch({ eventFilter: 'all', slackWebhook: 'x' }))).toBe(
'Unknown notification setting',
);
expect(mockSaveConfig).not.toHaveBeenCalled();
});

test('rejects a malformed event filter with "Invalid event filter"', async () => {
expect(await errorOf(await patch({ eventFilter: 'everything' }))).toBe(
'Invalid event filter',
);
expect(mockSaveConfig).not.toHaveBeenCalled();
});

test('rejects a bad Telegram bot token with "Telegram bot token is invalid"', async () => {
expect(await errorOf(await patch({ telegramBotToken: 'abc' }))).toBe(
'Telegram bot token is invalid',
);
expect(await errorOf(await patch({ telegramBotToken: 12345 }))).toBe(
'Telegram bot token is invalid',
);
expect(mockSaveConfig).not.toHaveBeenCalled();
});

test('rejects a bad Telegram chat ID with "Telegram chat ID is invalid"', async () => {
expect(await errorOf(await patch({ telegramChatId: 'not a chat' }))).toBe(
'Telegram chat ID is invalid',
);
expect(await errorOf(await patch({ telegramChatId: 42 }))).toBe(
'Telegram chat ID is invalid',
);
expect(mockSaveConfig).not.toHaveBeenCalled();
});

test('rejects a non-Discord webhook URL with the official-webhook message', async () => {
expect(await errorOf(await patch({ discordWebhookUrl: 'https://evil.example/hook' }))).toBe(
'Discord webhook URL must be an official Discord HTTPS webhook',
);
expect(mockSaveConfig).not.toHaveBeenCalled();
});

test('persists a valid update and echoes the stored public settings back', async () => {
const res = await patch({
telegramBotToken: VALID_TOKEN,
telegramChatId: '@accensa_ops',
discordWebhookUrl: VALID_WEBHOOK,
eventFilter: 'disputes',
});
expect(res.status).toBe(200);
expect(mockSaveConfig).toHaveBeenCalledWith(clientStub, MERCHANT.id, {
telegramBotToken: VALID_TOKEN,
telegramChatId: '@accensa_ops',
discordWebhookUrl: VALID_WEBHOOK,
eventFilter: 'disputes',
});
const body = await res.json();
expect(body).toEqual({
telegramConfigured: true,
telegramChatId: '@accensa_ops',
discordConfigured: true,
eventFilter: 'disputes',
});
expect(JSON.stringify(body)).not.toContain(VALID_TOKEN);
expect(JSON.stringify(body)).not.toContain('SECRET');
});
});
});
33 changes: 0 additions & 33 deletions apps/web/src/components/data-table.stories.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion apps/web/src/components/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface DataTableColumn<T> {
}

/**
* The dashboard payments table, extracted far enough to document in Storybook
* The dashboard payments table, extracted far enough to document in isolation
* without dragging in fetch, auth, or refunds.
*
* Empty and loading states are first-class: those are the views a merchant
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "node --test tests/*.test.mjs",
"lint": "pnpm --filter web lint && pnpm --filter @accensa/sdk lint",
"typecheck": "pnpm --filter web typecheck && pnpm --filter @accensa/sdk typecheck",
"lint": "pnpm --filter web lint && pnpm --filter @accensa/sdk lint && pnpm --filter @accensa/shared lint",
"typecheck": "pnpm --filter web typecheck && pnpm --filter @accensa/sdk typecheck && pnpm --filter @accensa/shared typecheck",
"changeset": "changeset"
},
"devDependencies": {
Expand Down
16 changes: 0 additions & 16 deletions packages/shared/src/components/badge.stories.tsx

This file was deleted.

14 changes: 0 additions & 14 deletions packages/shared/src/components/cta-button.stories.tsx

This file was deleted.

Loading
Loading