diff --git a/.superpowers/sdd/final-fix-4-report.md b/.superpowers/sdd/final-fix-4-report.md new file mode 100644 index 0000000..217b10e --- /dev/null +++ b/.superpowers/sdd/final-fix-4-report.md @@ -0,0 +1,12 @@ +# Final Fix 4 Report + +- Rejected raw server values containing backslashes and malformed HTTP-like prefixes before protocol-less normalization. +- Added unit and Playwright coverage for malformed slash and backslash server inputs. +- Added CustomLinkChannel tests for deferred clipboard success, rejection feedback, and unavailable clipboard feedback. + +Verification passed: + +- `npm test -- --run` (8 files, 48 tests) +- `npm run build` +- `npx playwright test` (11 tests) +- `git diff --check` diff --git a/apps/web/e2e/project-workbench.spec.ts b/apps/web/e2e/project-workbench.spec.ts index 55f6ac3..1b67f8f 100644 --- a/apps/web/e2e/project-workbench.spec.ts +++ b/apps/web/e2e/project-workbench.spec.ts @@ -25,7 +25,17 @@ test('login and project channel workbench flow', async ({ page }) => { await expect(page.getByText('Open external channel')).toBeVisible(); }); -for (const server of ['https://user:secret@example.com', 'http:////example.com', 'http://example..com', 'http://-bad.com']) { +for (const server of [ + 'https://user:secret@example.com', + 'http:////example.com', + 'http//example.com', + 'https//example.com', + 'http:/example.com', + 'https:/example.com', + 'http://example.com\\path', + 'http://example..com', + 'http://-bad.com', +]) { test(`rejects malformed server address: ${server}`, async ({ page }) => { await page.goto('/'); diff --git a/apps/web/src/features/auth/ServerLogin.svelte b/apps/web/src/features/auth/ServerLogin.svelte index 32cc241..147724d 100644 --- a/apps/web/src/features/auth/ServerLogin.svelte +++ b/apps/web/src/features/auth/ServerLogin.svelte @@ -13,7 +13,7 @@ return { error: 'Enter a server address.' }; } - if (hasWhitespace(value) || hasUnsupportedProtocol(value)) { + if (hasWhitespace(value) || value.includes('\\') || hasMalformedHttpProtocol(value) || hasUnsupportedProtocol(value)) { return { error: 'Enter a valid HTTP or HTTPS server address.' }; } @@ -58,6 +58,10 @@ return /^[a-z][a-z\d+.-]*:(?!\d)/i.test(value); } + function hasMalformedHttpProtocol(value: string): boolean { + return /^https?:\/(?!\/)/i.test(value) || /^https?\/{2}(?!\/)/i.test(value); + } + function hasCredentials(value: string): boolean { const authority = value.replace(/^https?:\/\//i, '').split(/[/?#]/, 1)[0]; return authority.includes('@'); diff --git a/apps/web/src/features/auth/ServerLogin.test.ts b/apps/web/src/features/auth/ServerLogin.test.ts index 0343ba7..e8a617c 100644 --- a/apps/web/src/features/auth/ServerLogin.test.ts +++ b/apps/web/src/features/auth/ServerLogin.test.ts @@ -76,6 +76,12 @@ describe('ServerLogin', () => { ['http://', 'Enter a valid HTTP or HTTPS server address.'], ['ftp://example.com', 'Enter a valid HTTP or HTTPS server address.'], ['http:////example.com', 'Enter a valid HTTP or HTTPS server address.'], + ['http//example.com', 'Enter a valid HTTP or HTTPS server address.'], + ['https//example.com', 'Enter a valid HTTP or HTTPS server address.'], + ['http:/example.com', 'Enter a valid HTTP or HTTPS server address.'], + ['https:/example.com', 'Enter a valid HTTP or HTTPS server address.'], + ['http://example.com\\path', 'Enter a valid HTTP or HTTPS server address.'], + ['example.com\\path', 'Enter a valid HTTP or HTTPS server address.'], ['http://example..com', 'Enter a valid HTTP or HTTPS server address.'], ['http://-bad.com', 'Enter a valid HTTP or HTTPS server address.'], ['http://bad-.com', 'Enter a valid HTTP or HTTPS server address.'], diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts b/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts index 8f8e845..e968295 100644 --- a/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts +++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts @@ -1,6 +1,6 @@ import '@testing-library/jest-dom/vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import CustomLinkChannel from './CustomLinkChannel.svelte'; const channel = { @@ -14,6 +14,13 @@ const channel = { }; const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); +function setClipboard(writeText: Clipboard['writeText']) { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText } as Clipboard, + }); +} + afterEach(() => { cleanup(); if (originalClipboard) { @@ -24,6 +31,33 @@ afterEach(() => { }); describe('CustomLinkChannel', () => { + it('shows copy success only after clipboard writing resolves', async () => { + let resolveWrite: (() => void) | undefined; + const writeText = vi.fn( + () => + new Promise((resolve) => { + resolveWrite = resolve; + }), + ); + setClipboard(writeText); + render(CustomLinkChannel, { props: { channel } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' })); + + expect(screen.queryByText('URL copied.')).not.toBeInTheDocument(); + resolveWrite?.(); + await waitFor(() => expect(screen.getByText('URL copied.')).toBeInTheDocument()); + }); + + it('reports a copy failure when clipboard writing rejects', async () => { + setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied'))); + render(CustomLinkChannel, { props: { channel } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' })); + + await waitFor(() => expect(screen.getByText('Unable to copy URL.')).toBeInTheDocument()); + }); + it('reports unavailable clipboard access without showing URL copied', async () => { Reflect.deleteProperty(navigator, 'clipboard'); render(CustomLinkChannel, { props: { channel } });