diff --git a/backend/src/lib/db-rls-lock.test.js b/backend/src/lib/db-rls-lock.test.js new file mode 100644 index 00000000..4b0e2034 --- /dev/null +++ b/backend/src/lib/db-rls-lock.test.js @@ -0,0 +1,258 @@ +/** + * Integration tests for Row-Level Security Advisory Locks (Issue #1470) + * + * These tests verify distributed concurrency control and locking mechanisms + * for the Database Row-Level Security Service. + * + * Prerequisites: a real PostgreSQL instance with RLS applied. + * Skip gracefully when DATABASE_URL is not set so CI doesn't fail in + * environments without a database. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import pg from "pg"; +import crypto from "crypto"; +import { + acquireAdvisoryLock, + releaseAdvisoryLock, + withAdvisoryLock, + withMerchantContextAndLock, + isAdvisoryLockHeld, + getLockStats, +} from "./db-rls.js"; + +const { Pool } = pg; + +const DB_URL = process.env.DATABASE_URL; + +describe.skipIf(!DB_URL || process.env.CI)("RLS Advisory Locks — distributed concurrency control", () => { + let pool; + let merchantId; + + beforeAll(async () => { + pool = new Pool({ connectionString: DB_URL, max: 2 }); + merchantId = crypto.randomUUID(); + }); + + afterAll(async () => { + await pool.end(); + }); + + describe("Basic lock operations", () => { + it("acquires and releases an advisory lock", async () => { + const acquired = await acquireAdvisoryLock(merchantId, "test-resource"); + expect(acquired).toBe(true); + + const held = await isAdvisoryLockHeld(merchantId, "test-resource"); + expect(held).toBe(true); + + const released = await releaseAdvisoryLock(merchantId, "test-resource"); + expect(released).toBe(true); + + const heldAfterRelease = await isAdvisoryLockHeld(merchantId, "test-resource"); + expect(heldAfterRelease).toBe(false); + }); + + it("fails to acquire lock when already held by another session", async () => { + const client1 = await pool.connect(); + const client2 = await pool.connect(); + + try { + // Acquire lock in session 1 + await client1.query("SELECT pg_advisory_lock($1::bigint)", [ + Math.abs(merchantId.split("").reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) & a, 0)), + ]); + + // Try to acquire in session 2 with timeout + const acquired = await acquireAdvisoryLock(merchantId, "test-resource", { timeout: 1000 }); + expect(acquired).toBe(false); + } finally { + await client1.query("SELECT pg_advisory_unlock_all()"); + await client2.query("SELECT pg_advisory_unlock_all()"); + client1.release(); + client2.release(); + } + }); + + it("handles lock acquisition timeout", async () => { + const client = await pool.connect(); + + try { + // Acquire lock and hold it + await client.query("SELECT pg_advisory_lock($1::bigint)", [ + Math.abs(merchantId.split("").reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) & a, 0)), + ]); + + // Try to acquire with short timeout + const acquired = await acquireAdvisoryLock(merchantId, "test-resource", { timeout: 100 }); + expect(acquired).toBe(false); + } finally { + await client.query("SELECT pg_advisory_unlock_all()"); + client.release(); + } + }); + }); + + describe("withAdvisoryLock pattern", () => { + it("executes callback while holding lock", async () => { + let executionOrder = []; + + await withAdvisoryLock(merchantId, "test-resource", async () => { + executionOrder.push("inside"); + expect(await isAdvisoryLockHeld(merchantId, "test-resource")).toBe(true); + }); + + executionOrder.push("after"); + expect(executionOrder).toEqual(["inside", "after"]); + expect(await isAdvisoryLockHeld(merchantId, "test-resource")).toBe(false); + }); + + it("releases lock even if callback throws", async () => { + await expect( + withAdvisoryLock(merchantId, "test-resource", async () => { + throw new Error("Test error"); + }), + ).rejects.toThrow("Test error"); + + expect(await isAdvisoryLockHeld(merchantId, "test-resource")).toBe(false); + }); + + it("throws when lock acquisition fails", async () => { + const client = await pool.connect(); + + try { + // Hold the lock + await client.query("SELECT pg_advisory_lock($1::bigint)", [ + Math.abs(merchantId.split("").reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) & a, 0)), + ]); + + await expect( + withAdvisoryLock(merchantId, "test-resource", async () => {}, { timeout: 100 }), + ).rejects.toThrow("Failed to acquire advisory lock"); + } finally { + await client.query("SELECT pg_advisory_unlock_all()"); + client.release(); + } + }); + }); + + describe("withMerchantContextAndLock pattern", () => { + it("combines RLS context with advisory lock", async () => { + let contextSet = false; + let lockHeld = false; + + await withMerchantContextAndLock(merchantId, "test-resource", async (client) => { + contextSet = true; + lockHeld = await isAdvisoryLockHeld(merchantId, "test-resource"); + + // Verify merchant context is set + const { rows } = await client.query("SELECT current_setting('app.current_merchant_id', true) as merchant_id"); + expect(rows[0].merchant_id).toBe(merchantId); + }); + + expect(contextSet).toBe(true); + expect(lockHeld).toBe(true); + }); + + it("releases both lock and transaction on error", async () => { + await expect( + withMerchantContextAndLock(merchantId, "test-resource", async () => { + throw new Error("Test error"); + }), + ).rejects.toThrow("Test error"); + + expect(await isAdvisoryLockHeld(merchantId, "test-resource")).toBe(false); + }); + }); + + describe("Lock statistics and monitoring", () => { + it("returns lock statistics", async () => { + const stats = await getLockStats(); + + expect(stats).toHaveProperty("advisoryLocks"); + expect(stats).toHaveProperty("relationLocks"); + expect(stats).toHaveProperty("transactionLocks"); + + expect(typeof stats.advisoryLocks).toBe("number"); + expect(typeof stats.relationLocks).toBe("number"); + expect(typeof stats.transactionLocks).toBe("number"); + }); + + it("tracks advisory locks in statistics", async () => { + const statsBefore = await getLockStats(); + + await acquireAdvisoryLock(merchantId, "test-resource"); + const statsDuring = await getLockStats(); + + await releaseAdvisoryLock(merchantId, "test-resource"); + const statsAfter = await getLockStats(); + + expect(statsDuring.advisoryLocks).toBeGreaterThanOrEqual(statsBefore.advisoryLocks); + expect(statsAfter.advisoryLocks).toBeLessThanOrEqual(statsDuring.advisoryLocks); + }); + }); + + describe("Concurrent access scenarios", () => { + it("serializes concurrent operations on same resource", async () => { + const results = []; + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + const operation = async (id) => { + await withAdvisoryLock(merchantId, "test-resource", async () => { + results.push(id); + await delay(100); + }); + }; + + // Run operations concurrently + await Promise.all([operation(1), operation(2), operation(3)]); + + // Operations should complete sequentially + expect(results).toEqual([1, 2, 3]); + }); + + it("allows concurrent operations on different resources", async () => { + const results = []; + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + const operation = async (id, resource) => { + await withAdvisoryLock(merchantId, resource, async () => { + results.push(id); + await delay(100); + }); + }; + + // Run operations on different resources concurrently + await Promise.all([ + operation(1, "resource-a"), + operation(2, "resource-b"), + operation(3, "resource-c"), + ]); + + // Operations should complete in any order (not serialized) + expect(results.length).toBe(3); + }); + }); + + describe("Edge cases and error handling", () => { + it("handles lock release when lock not held", async () => { + const released = await releaseAdvisoryLock(merchantId, "nonexistent-resource"); + expect(released).toBe(false); + }); + + it("generates consistent lock keys for same inputs", async () => { + const key1 = merchantId + ":test"; + const key2 = merchantId + ":test"; + + // Generate keys using the same algorithm + const hash1 = Math.abs(key1.split("").reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) & a, 0)); + const hash2 = Math.abs(key2.split("").reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) & a, 0)); + + expect(hash1).toBe(hash2); + }); + + it("handles null or undefined merchant IDs gracefully", async () => { + const acquired = await acquireAdvisoryLock(null, "test-resource"); + expect(typeof acquired).toBe("boolean"); + }); + }); +}); diff --git a/backend/src/lib/db-rls.js b/backend/src/lib/db-rls.js index 5e5ce890..e0e633cc 100644 --- a/backend/src/lib/db-rls.js +++ b/backend/src/lib/db-rls.js @@ -133,3 +133,242 @@ export function sanitizeMerchantId(merchantId) { const sanitized = trimmed.replace(/[^a-zA-Z0-9_\-:.@]/g, ""); return sanitized.length > 0 ? sanitized : null; } + +/** + * Distributed advisory lock management for Row-Level Security + * + * PostgreSQL advisory locks provide distributed concurrency control across + * multiple application instances. Locks are automatically released when the + * session ends, providing safety against orphaned locks. + */ + +const LOCK_TIMEOUT_MS = Number.parseInt( + process.env.RLS_LOCK_TIMEOUT_MS || "5000", + 10, +); + +/** + * Acquire an advisory lock for a specific merchant resource. + * + * @param {string} merchantId - The merchant ID to lock + * @param {string} resourceType - Type of resource (e.g., 'payment', 'merchant') + * @param {Object} options - Lock options + * @param {number} options.timeout - Lock acquisition timeout in milliseconds + * @returns {Promise} - True if lock was acquired, false otherwise + */ +export async function acquireAdvisoryLock( + merchantId, + resourceType, + { timeout = LOCK_TIMEOUT_MS } = {}, +) { + const lockKey = generateLockKey(merchantId, resourceType); + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + try { + const { rows } = await pool.query( + "SELECT pg_try_advisory_lock($1::bigint) as acquired", + [lockKey], + ); + + if (rows[0].acquired) { + logger.info( + { merchantId, resourceType, lockKey }, + "Advisory lock acquired", + ); + return true; + } + + // Wait before retrying (exponential backoff) + const elapsed = Date.now() - startTime; + const backoffMs = Math.min(100 * Math.pow(2, Math.floor(elapsed / 1000)), 1000); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } catch (err) { + logger.error( + { err: err.message, merchantId, resourceType }, + "Failed to acquire advisory lock", + ); + return false; + } + } + + logger.warn( + { merchantId, resourceType, timeout }, + "Advisory lock acquisition timed out", + ); + return false; +} + +/** + * Release an advisory lock for a specific merchant resource. + * + * @param {string} merchantId - The merchant ID to unlock + * @param {string} resourceType - Type of resource + * @returns {Promise} - True if lock was released, false otherwise + */ +export async function releaseAdvisoryLock(merchantId, resourceType) { + const lockKey = generateLockKey(merchantId, resourceType); + + try { + const { rows } = await pool.query( + "SELECT pg_advisory_unlock($1::bigint) as released", + [lockKey], + ); + + if (rows[0].released) { + logger.info( + { merchantId, resourceType, lockKey }, + "Advisory lock released", + ); + return true; + } + + logger.warn( + { merchantId, resourceType, lockKey }, + "Advisory lock was not held by this session", + ); + return false; + } catch (err) { + logger.error( + { err: err.message, merchantId, resourceType }, + "Failed to release advisory lock", + ); + return false; + } +} + +/** + * Execute a callback with an advisory lock held. + * + * This provides distributed concurrency control for critical sections + * that need to be serialized across multiple application instances. + * + * @param {string} merchantId - The merchant ID to lock + * @param {string} resourceType - Type of resource + * @param {Function} callback - Function to execute while holding the lock + * @param {Object} options - Lock options + * @returns {Promise<*>} - Result of the callback + */ +export async function withAdvisoryLock( + merchantId, + resourceType, + callback, + options = {}, +) { + const acquired = await acquireAdvisoryLock(merchantId, resourceType, options); + + if (!acquired) { + throw new Error( + `Failed to acquire advisory lock for merchant ${merchantId} on ${resourceType}`, + ); + } + + try { + return await callback(); + } finally { + await releaseAdvisoryLock(merchantId, resourceType); + } +} + +/** + * Execute a callback with both merchant context and advisory lock. + * + * This combines RLS enforcement with distributed concurrency control, + * ensuring both security and serialization across instances. + * + * @param {string} merchantId - The merchant ID + * @param {string} resourceType - Type of resource for locking + * @param {Function} callback - Function to execute + * @param {Object} options - Lock options + * @returns {Promise<*>} - Result of the callback + */ +export async function withMerchantContextAndLock( + merchantId, + resourceType, + callback, + options = {}, +) { + return withAdvisoryLock(merchantId, resourceType, async () => { + return withMerchantContext(merchantId, callback); + }, options); +} + +/** + * Check if an advisory lock is currently held. + * + * @param {string} merchantId - The merchant ID + * @param {string} resourceType - Type of resource + * @returns {Promise} - True if lock is held, false otherwise + */ +export async function isAdvisoryLockHeld(merchantId, resourceType) { + const lockKey = generateLockKey(merchantId, resourceType); + + try { + const { rows } = await pool.query( + "SELECT pg_advisory_lock_is_held($1::bigint) as held", + [lockKey], + ); + + return rows[0].held; + } catch (err) { + logger.error( + { err: err.message, merchantId, resourceType }, + "Failed to check advisory lock status", + ); + return false; + } +} + +/** + * Generate a consistent lock key from merchant ID and resource type. + * + * Uses hash function to convert string identifiers to numeric lock keys + * for PostgreSQL advisory locks. + * + * @param {string} merchantId - The merchant ID + * @param {string} resourceType - Type of resource + * @returns {number} - Numeric lock key + */ +function generateLockKey(merchantId, resourceType) { + const combined = `${merchantId}:${resourceType}`; + let hash = 0; + + for (let i = 0; i < combined.length; i++) { + const char = combined.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + + return Math.abs(hash); +} + +/** + * Get current advisory lock statistics for monitoring. + * + * @returns {Promise} - Lock statistics + */ +export async function getLockStats() { + try { + const { rows } = await pool.query(` + SELECT + COUNT(*) FILTER (WHERE locktype = 'advisory') as advisory_locks, + COUNT(*) FILTER (WHERE locktype = 'relation') as relation_locks, + COUNT(*) FILTER (WHERE locktype = 'transactionid') as transaction_locks + FROM pg_locks + WHERE pid = pg_backend_pid() + `); + + return { + advisoryLocks: Number.parseInt(rows[0].advisory_locks || "0", 10), + relationLocks: Number.parseInt(rows[0].relation_locks || "0", 10), + transactionLocks: Number.parseInt(rows[0].transaction_locks || "0", 10), + }; + } catch (err) { + logger.error({ err: err.message }, "Failed to get lock stats"); + return { + advisoryLocks: 0, + relationLocks: 0, + transactionLocks: 0, + }; + } +} diff --git a/backend/src/lib/rls.test.js b/backend/src/lib/rls.test.js index 0215571e..464c5c94 100644 --- a/backend/src/lib/rls.test.js +++ b/backend/src/lib/rls.test.js @@ -1,5 +1,5 @@ /** - * Integration tests for Row Level Security (Issue #223) + * Integration and stress tests for Row Level Security (Issue #223, #1471) * * These tests verify that database-level RLS policies prevent Merchant A * from reading or modifying Merchant B's data. @@ -29,6 +29,14 @@ async function setMerchantSession(client, merchantId) { await client.query("SET LOCAL app.current_merchant_id = $1", [merchantId]); } +// Helper: measure query execution time +async function measureQueryTime(fn) { + const start = Date.now(); + const result = await fn(); + const duration = Date.now() - start; + return { result, duration }; +} + describe.skipIf(!DB_URL || process.env.CI)("RLS — cross-merchant data isolation", () => { let pool; let merchantAId; @@ -156,4 +164,264 @@ describe.skipIf(!DB_URL || process.env.CI)("RLS — cross-merchant data isolatio client.release(); } }); + + describe("Transaction isolation and concurrent access", () => { + it("handles concurrent read operations from different merchants", async () => { + const clientA = await pool.connect(); + const clientB = await pool.connect(); + + try { + await clientA.query("BEGIN"); + await clientB.query("BEGIN"); + + await setMerchantSession(clientA, merchantAId); + await setMerchantSession(clientB, merchantBId); + + const [resultA, resultB] = await Promise.all([ + clientA.query("SELECT id FROM payments WHERE merchant_id = $1", [merchantAId]), + clientB.query("SELECT id FROM payments WHERE merchant_id = $1", [merchantBId]), + ]); + + expect(resultA.rows.map((r) => r.id)).toContain(paymentAId); + expect(resultB.rows.map((r) => r.id)).toContain(paymentBId); + + // Verify no cross-contamination + expect(resultA.rows.map((r) => r.id)).not.toContain(paymentBId); + expect(resultB.rows.map((r) => r.id)).not.toContain(paymentAId); + } finally { + await clientA.query("ROLLBACK"); + await clientB.query("ROLLBACK"); + clientA.release(); + clientB.release(); + } + }); + + it("prevents concurrent write conflicts on same merchant data", async () => { + const clientA = await pool.connect(); + const clientB = await pool.connect(); + + try { + await clientA.query("BEGIN"); + await clientB.query("BEGIN"); + + await setMerchantSession(clientA, merchantAId); + await setMerchantSession(clientB, merchantAId); + + // First update succeeds + await clientA.query( + "UPDATE payments SET amount = amount + 1 WHERE id = $1", + [paymentAId], + ); + + // Second update should still work (same merchant context) + const { rowCount } = await clientB.query( + "UPDATE payments SET amount = amount + 1 WHERE id = $1", + [paymentAId], + ); + + expect(rowCount).toBe(1); + } finally { + await clientA.query("ROLLBACK"); + await clientB.query("ROLLBACK"); + clientA.release(); + clientB.release(); + } + }); + }); + + describe("Performance and stress tests", () => { + it("handles bulk read operations efficiently", async () => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await setMerchantSession(client, merchantAId); + + const { result, duration } = await measureQueryTime(() => + client.query("SELECT id FROM payments WHERE merchant_id = $1", [merchantAId]), + ); + + // Should complete within 1 second for a simple query + expect(duration).toBeLessThan(1000); + expect(result.rows.length).toBeGreaterThan(0); + } finally { + await client.query("ROLLBACK"); + client.release(); + } + }); + + it("handles sequential RLS context switches without degradation", async () => { + const client = await pool.connect(); + const iterations = 10; + const durations = []; + + try { + for (let i = 0; i < iterations; i++) { + await client.query("BEGIN"); + + // Switch context multiple times + await setMerchantSession(client, merchantAId); + await client.query("SELECT 1"); + + await setMerchantSession(client, merchantBId); + await client.query("SELECT 1"); + + await setMerchantSession(client, merchantAId); + const { result, duration } = await measureQueryTime(() => + client.query("SELECT id FROM payments WHERE merchant_id = $1", [merchantAId]), + ); + + durations.push(duration); + await client.query("ROLLBACK"); + } + + // Average duration should not degrade significantly + const avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length; + expect(avgDuration).toBeLessThan(500); + } finally { + client.release(); + } + }); + + it("handles complex queries with joins under RLS", async () => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await setMerchantSession(client, merchantAId); + + const { result, duration } = await measureQueryTime(() => + client.query( + `SELECT p.id, p.amount, m.business_name + FROM payments p + JOIN merchants m ON p.merchant_id = m.id + WHERE p.merchant_id = $1`, + [merchantAId], + ), + ); + + expect(duration).toBeLessThan(1000); + expect(result.rows.length).toBeGreaterThan(0); + expect(result.rows[0].business_name).toBe("Merchant A"); + } finally { + await client.query("ROLLBACK"); + client.release(); + } + }); + }); + + describe("Edge cases and security validation", () => { + it("handles NULL merchant context gracefully", async () => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + // Set NULL merchant context + await client.query("SET LOCAL app.current_merchant_id = NULL"); + + const { rows } = await client.query("SELECT id FROM payments"); + // Should return empty or filtered based on RLS policy + expect(rows).toBeDefined(); + } finally { + await client.query("ROLLBACK"); + client.release(); + } + }); + + it("prevents SQL injection through merchant context", async () => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + // Attempt to inject SQL through merchant context + const maliciousId = "'; DROP TABLE payments; --"; + + await setMerchantSession(client, maliciousId); + + const { rows } = await client.query("SELECT id FROM payments WHERE merchant_id = $1", [maliciousId]); + // Should not match any rows and not execute the injected SQL + expect(rows).toHaveLength(0); + } finally { + await client.query("ROLLBACK"); + client.release(); + } + }); + + it("handles large result sets under RLS", async () => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await setMerchantSession(client, merchantAId); + + // Query with a wide filter to test performance + const { result, duration } = await measureQueryTime(() => + client.query("SELECT * FROM payments WHERE merchant_id = $1 LIMIT 1000", [merchantAId]), + ); + + expect(duration).toBeLessThan(2000); + expect(result.rows).toBeDefined(); + } finally { + await client.query("ROLLBACK"); + client.release(); + } + }); + + it("validates RLS policy on aggregate queries", async () => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await setMerchantSession(client, merchantAId); + + const { rows } = await client.query( + "SELECT COUNT(*) as count, SUM(amount) as total FROM payments WHERE merchant_id = $1", + [merchantAId], + ); + + expect(rows[0].count).toBeGreaterThan(0); + expect(rows[0].total).toBeGreaterThan(0); + } finally { + await client.query("ROLLBACK"); + client.release(); + } + }); + }); + + describe("Error handling and recovery", () => { + it("handles connection errors gracefully", async () => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await setMerchantSession(client, merchantAId); + + // Simulate a connection error by using invalid query + await expect( + client.query("SELECT * FROM nonexistent_table"), + ).rejects.toThrow(); + + // Connection should still be usable after error + await setMerchantSession(client, merchantAId); + const { rows } = await client.query("SELECT id FROM payments WHERE merchant_id = $1", [merchantAId]); + expect(rows).toBeDefined(); + } finally { + await client.query("ROLLBACK"); + client.release(); + } + }); + + it("maintains RLS context after query errors", async () => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await setMerchantSession(client, merchantAId); + + // Execute invalid query + await expect( + client.query("SELECT * FROM nonexistent_table"), + ).rejects.toThrow(); + + // RLS context should still be active + const { rows } = await client.query("SELECT id FROM payments WHERE merchant_id = $1", [merchantAId]); + expect(rows.map((r) => r.id)).toContain(paymentAId); + } finally { + await client.query("ROLLBACK"); + client.release(); + } + }); + }); }); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 6a4c2e43..79e0f4c1 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -7,6 +7,19 @@ to { transform: translateX(200%); } } +/* Screen reader only utility for WCAG 2.1 AA accessibility */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + * { box-sizing: border-box; } diff --git a/frontend/src/components/CommandPalette.test.tsx b/frontend/src/components/CommandPalette.test.tsx index b9c91dbc..1a5a7403 100644 --- a/frontend/src/components/CommandPalette.test.tsx +++ b/frontend/src/components/CommandPalette.test.tsx @@ -365,4 +365,66 @@ describe("CommandPalette", () => { await openPalette(); expect(baseElement.querySelector('[role="dialog"]')).toMatchSnapshot(); }); + + describe("WCAG 2.1 AA Accessibility", () => { + it("has proper ARIA attributes for screen readers", async () => { + render(); + await openPalette(); + + const dialog = screen.getByRole("dialog", { name: "Command palette" }); + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(dialog).toHaveAttribute("aria-describedby", "command-description"); + + const description = screen.getByText(/Command palette dialog/i); + expect(description).toHaveClass("sr-only"); + + const input = screen.getByRole("combobox", { name: "Search commands" }); + expect(input).toHaveAttribute("aria-expanded", "true"); + expect(input).toHaveAttribute("aria-controls", "command-list"); + expect(input).toHaveAttribute("aria-autocomplete", "list"); + }); + + it("stores and restores previous focus on open/close", async () => { + const { container } = render( +
+ + +
+ ); + + const outsideButton = screen.getByTestId("outside-button"); + outsideButton.focus(); + expect(outsideButton).toHaveFocus(); + + await openPalette(); + const input = screen.getByRole("combobox", { name: "Search commands" }); + expect(input).toHaveFocus(); + + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + + expect(outsideButton).toHaveFocus(); + }); + + it("focuses input when dialog opens", async () => { + render(); + await openPalette(); + + const input = screen.getByRole("combobox", { name: "Search commands" }); + expect(input).toHaveFocus(); + }); + + it("has focusable elements properly identified for trap", async () => { + render(); + await openPalette(); + + const dialog = screen.getByRole("dialog"); + const focusableElements = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + // Should have at least the input and potentially other focusable elements + expect(focusableElements.length).toBeGreaterThan(0); + }); + }); }); diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index 9f90d042..e02013a2 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -157,6 +157,8 @@ export default function CommandPalette() { const [executingId, setExecutingId] = useState(null); const inputRef = useRef(null); const listRef = useRef(null); + const dialogRef = useRef(null); + const previousActiveElementRef = useRef(null); const router = useRouter(); const apiKey = useMerchantApiKey(); const { toggleTheme } = useThemeActions(); @@ -182,7 +184,14 @@ export default function CommandPalette() { setActiveIndex(0); setView("commands"); setExecutingId(null); + // Store the previously focused element to restore focus on close + previousActiveElementRef.current = document.activeElement as HTMLElement; requestAnimationFrame(() => inputRef.current?.focus()); + } else { + // Restore focus to the previously focused element when dialog closes + if (previousActiveElementRef.current) { + previousActiveElementRef.current.focus(); + } } }, [open]); @@ -196,6 +205,41 @@ export default function CommandPalette() { activeItem?.scrollIntoView({ block: "nearest" }); }, [activeIndex, filtered.length]); + // WCAG 2.1 AA Focus Trap: Prevent focus from leaving the dialog + useEffect(() => { + if (!open || !dialogRef.current) return; + + const handleTabKey = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + + const focusableElements = dialogRef.current?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + if (!focusableElements || focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (e.shiftKey) { + // Shift+Tab: if focus is on first element, move to last + if (document.activeElement === firstElement) { + e.preventDefault(); + lastElement.focus(); + } + } else { + // Tab: if focus is on last element, move to first + if (document.activeElement === lastElement) { + e.preventDefault(); + firstElement.focus(); + } + } + }; + + document.addEventListener("keydown", handleTabKey); + return () => document.removeEventListener("keydown", handleTabKey); + }, [open]); + const select = useCallback( async (command: PaletteCommand) => { // Guard against re-entrancy: ignore new selections while an async @@ -339,9 +383,11 @@ export default function CommandPalette() { onClick={() => setOpen(false)} >
e.stopPropagation()} onKeyDown={handlePaletteKeydown} @@ -350,6 +396,11 @@ export default function CommandPalette() { setView("commands")} /> ) : ( <> + {/* Hidden description for screen readers */} + + Command palette dialog. Use arrow keys to navigate, Enter to select, Escape to close. Tab cycles through focusable elements. + +