diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb40bc1..d89b140 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,8 @@ jobs: dotnet-tools.json - run: dotnet restore Indirection.sln - run: dotnet build Indirection.sln -c Release --no-restore + - name: Test admin UI behavior + run: node --test src/test/admin-ui/app.test.cjs - name: Test and enforce line and branch coverage run: >- dotnet test --project src/test/Indirection.Tests -c Release --no-build diff --git a/README.md b/README.md index e4d452a..c291eda 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,25 @@ Indirection optionally loads configuration from `/run/secrets` using ASP.NET Cor Files take precedence over all standard sources, including command-line arguments. Otherwise, standard precedence is preserved: command-line arguments, environment variables, `appsettings.{Environment}.json`, then `appsettings.json` (highest to lowest). Restart the application after rotating secret files. +### Admin UI + +The app includes a tiny, dependency-free browser UI at `/admin` (also `/admin/`). +The static HTML/CSS/JavaScript lives under `src/prod/Indirection/wwwroot/admin` +and is included in normal .NET builds, portable releases, and container images. +No Node runtime, frontend build step, database migration, or extra API endpoint is needed. + +Navigate to `https://your-indirection-host/admin` and enter the same API key +used by the management API. The UI lists, searches, creates, edits, copies, and +deletes short links using the existing `/api/v1/links` endpoints. Codes can +contain nested paths; a code itself cannot be renamed. + +The page and its static assets are publicly served; **the management API is +still protected by `X-Api-Key`**. The browser keeps the key only in page memory, +not cookies or local/session storage; refreshing or disconnecting forgets it. +Use HTTPS in production. `/admin` and `/admin/*` are reserved for the UI, +and therefore should not be used as redirect codes. The UI rejects these +and other known application route collisions. + ### Application behavior SQLite is the only supported provider. `appsettings.json` defaults to `Data Source=urls.db`; relative SQLite paths resolve against the process working directory. An absolute `ConnectionStrings__Urls` avoids ambiguity between the EF CLI and host. Put the database and its sidecar files on persistent, writable storage and back them up. Apply migrations explicitly before starting/upgrading; the application does not migrate at startup. `dotnet ef migrations has-pending-model-changes --project src/prod/Indirection` checks model drift. @@ -66,6 +85,8 @@ dotnet test --project src/test/Indirection.Tests -c Release --no-build \ Coverage includes all handwritten production code, including startup and authentication; only generated EF migration files are excluded. The coverage command and CI enforce at least 96% for both lines and branches. JSON and Cobertura reports are written under `artifacts/coverage`. The separate deployment suite does not contribute to coverage. The explicit SQLite native bundle reference upgrades EF's legacy transitive bundle without suppressing vulnerability warnings. +Admin UI behavior tests use Node's built-in test runner and a small DOM adapter, with no npm packages. Run `node --test src/test/admin-ui/app.test.cjs` with Node 22 or newer. These cover management operations, search/copy, validation, authentication failures, and pending requests during disconnect. Node is needed only for these tests, not to build or run the application. + Deployment verification builds independently and uses only HTTP, with automatic redirects disabled. With the local application still running (or an explicit HTTPS deployment target): ```bash @@ -77,7 +98,7 @@ dotnet test --project src/test/Indirection.DeploymentTests -c Release --no-build The suite checks health and the complete create/read/redirect/update/redirect/delete/404 lifecycle, including mixed casing, nested codes, and warmed-cache updates. Each run uses a unique `synthetic/{guid}/nested` code and cleans up in `finally`, so concurrent runs are isolated. Run this same command after deployment or on a scheduler for synthetics. When neither environment variable is supplied, deployment tests are skipped; supplying only one fails configuration. CI runs only the local suite and never targets a live deployment implicitly. -GitHub Actions runs on pull requests targeting `main` and pushes to `main`. It installs .NET 10, caches NuGet packages using the project/configuration files as the cache key, restores, builds the complete solution in Release, and runs only `Indirection.Tests` with the existing coverage configuration. Both line and branch coverage must reach 96%, exceeding the design's >95% requirement; coverage reports are retained as workflow artifacts. +GitHub Actions runs on pull requests targeting `main` and pushes to `main`. It installs .NET 10, caches NuGet packages using the project/configuration files as the cache key, restores, builds the complete solution in Release, runs the admin UI behavior tests, and runs `Indirection.Tests` with the existing coverage configuration. Both line and branch coverage must reach 96%, exceeding the design's >95% requirement; coverage reports are retained as workflow artifacts. Pushing a version tag matching `v*` runs the same validation before publishing a multi-platform image to GHCR and then a GitHub Release. Release creation requires successful tests and Docker publishing. After these workflow changes are committed and pushed, create the first release from the intended commit: diff --git a/src/prod/Indirection/Program.cs b/src/prod/Indirection/Program.cs index d1b0d40..f99763c 100644 --- a/src/prod/Indirection/Program.cs +++ b/src/prod/Indirection/Program.cs @@ -15,10 +15,17 @@ return; } +// Serve the static admin SPA without changing API-key authorization. +app.UseDefaultFiles(); +app.UseStaticFiles(); +// Select endpoints after static files; otherwise /admin/ matches an endpoint +// before the static-file middleware can serve its default document. +app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapHealthChecks("/health").AllowAnonymous(); +app.MapGet("/admin/{**path}", () => Results.NotFound()).AllowAnonymous(); await app.RunAsync(); public partial class Program; diff --git a/src/prod/Indirection/wwwroot/admin/app.js b/src/prod/Indirection/wwwroot/admin/app.js new file mode 100644 index 0000000..9f7ab32 --- /dev/null +++ b/src/prod/Indirection/wwwroot/admin/app.js @@ -0,0 +1,363 @@ +/* Indirection admin: plain JavaScript, no build step or dependencies. */ +'use strict'; + +const $ = id => document.getElementById(id); +const state = { + apiKey: null, links: [], editing: null, toastTimer: null, session: new AbortController(), + pendingMutation: false, editorGeneration: 0, loadGeneration: 0 +}; +const endpoint = '/api/v1/links'; +const origin = window.location.origin; + +function apiPath(code) { + // Keep nested paths intact while encoding individual segments. + return endpoint + '/' + code.split('/').map(encodeURIComponent).join('/'); +} + +async function request(path, options = {}) { + const { signal } = state.session; + if (!state.apiKey) throw new DOMException('Disconnected.', 'AbortError'); + const headers = { 'X-Api-Key': state.apiKey, ...options.headers }; + const response = await fetch(path, { ...options, headers, cache: 'no-store', signal }); + signal.throwIfAborted(); + if (!response.ok) { + const error = new Error(response.status === 401 + ? 'Invalid API key.' + : 'Request failed (' + response.status + ').'); + error.status = response.status; + throw error; + } + if (response.status === 204) return null; + const result = response.headers.get('content-type')?.includes('json') + ? await response.json() : null; + signal.throwIfAborted(); + return result; +} + +function textNode(tag, className, text) { + const element = document.createElement(tag); + if (className) element.className = className; + element.textContent = text; + return element; +} + +function icon(name) { + const paths = { + link: '', + copy: '', + edit: '' + }; + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '1.8'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.classList.add('icon'); + svg.innerHTML = paths[name]; // Fixed, trusted icon markup only. + return svg; +} + +function publicUrl(code) { + return origin + '/' + code.split('/').map(encodeURIComponent).join('/'); +} + +function shortDestination(destination) { + try { + const url = new URL(destination); + if (url.protocol === 'http:' || url.protocol === 'https:') { + return { top: url.hostname, bottom: url.pathname === '/' + ? (url.search || '—') : url.pathname + url.search }; + } + } catch { /* Custom or relative destinations are supported by the API. */ } + return { top: destination, bottom: 'Custom destination' }; +} + +function showToast(message) { + clearTimeout(state.toastTimer); + $('toast').textContent = message; + $('toast').hidden = false; + state.toastTimer = setTimeout(() => { $('toast').hidden = true; }, 2900); +} + +function showError(id, message) { + $(id).textContent = message; + $(id).hidden = !message; +} + +function showApp() { + $('login').hidden = true; + $('app').hidden = false; + $('headerRight').hidden = false; + $('hostname').textContent = location.hostname; +} + +function showLogin() { + state.session.abort(); + state.session = new AbortController(); + state.loadGeneration++; + state.apiKey = null; + state.links = []; + state.editing = null; + $('app').hidden = true; + $('headerRight').hidden = true; + $('login').hidden = false; + $('apiKey').value = ''; + $('apiKey').type = 'password'; + $('showKey').textContent = 'Show'; + $('showKey').setAttribute('aria-label', 'Show API key'); + $('linkRows').replaceChildren(); + $('search').value = ''; + $('editorForm').reset(); + clearTimeout(state.toastTimer); + $('toast').hidden = true; + if ($('editor').open) $('editor').close(); + $('apiKey').focus(); +} + +async function loadLinks() { + const generation = ++state.loadGeneration; + let response; + try { response = await request(endpoint); } + catch (e) { + if (generation !== state.loadGeneration) throw new DOMException('Superseded request.', 'AbortError'); + throw e; + } + if (generation !== state.loadGeneration) throw new DOMException('Superseded request.', 'AbortError'); + if (!Array.isArray(response)) throw new Error('The server returned an unexpected links response.'); + state.links = response.sort((a, b) => a.code.localeCompare(b.code)); + render(); +} + +function render() { + const query = $('search').value.trim().toLowerCase(); + const filtered = state.links.filter(link => link.code.toLowerCase().includes(query) + || link.destination.toLowerCase().includes(query)); + $('count').textContent = state.links.length; + $('resultCount').textContent = filtered.length === state.links.length + ? state.links.length + (state.links.length === 1 ? ' link' : ' links') + : filtered.length + ' of ' + state.links.length + ' links'; + $('linkRows').replaceChildren(); + + for (const link of filtered) { + const tr = document.createElement('tr'); + const codeCell = document.createElement('td'); + const codeWrap = textNode('div', 'code-cell', ''); + const avatar = textNode('span', 'link-avatar', ''); + avatar.append(icon('link')); + codeWrap.append(avatar); + const codeButton = textNode('button', 'code-name', link.code); + codeButton.type = 'button'; + codeButton.style.cssText = 'border:0;background:none;padding:0;text-align:left'; + codeButton.title = 'Edit ' + link.code; + codeButton.addEventListener('click', () => openEditor(link)); + codeWrap.append(codeButton); + codeCell.append(codeWrap); + + const destinationCell = document.createElement('td'); + const parts = shortDestination(link.destination); + const top = textNode('span', 'domain-text', parts.top); + top.title = link.destination; + const bottom = textNode('span', 'path-text', parts.bottom); + bottom.title = link.destination; + destinationCell.append(top, bottom); + const created = textNode('td', '', link.createdUtc + ? new Date(link.createdUtc).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) + : '—'); + + const actionsCell = document.createElement('td'); + const actions = textNode('div', 'row-actions', ''); + const copy = textNode('button', 'icon-button', ''); + copy.type = 'button'; + copy.title = 'Copy short link'; + copy.setAttribute('aria-label', 'Copy ' + link.code); + copy.append(icon('copy')); + copy.addEventListener('click', () => copyUrl(link.code)); + const edit = textNode('button', 'icon-button', ''); + edit.type = 'button'; + edit.title = 'Edit link'; + edit.setAttribute('aria-label', 'Edit ' + link.code); + edit.append(icon('edit')); + edit.addEventListener('click', () => openEditor(link)); + actions.append(copy, edit); + actionsCell.append(actions); + tr.append(codeCell, destinationCell, created, actionsCell); + $('linkRows').append(tr); + } + + $('emptyState').hidden = filtered.length > 0; + $('linkTable').hidden = filtered.length === 0; + $('emptyTitle').textContent = state.links.length ? 'No matching links' : 'No links yet'; + $('emptySubtitle').textContent = state.links.length + ? 'Try a different search term.' : 'Create your first link to get started.'; + $('emptyCreateBtn').hidden = state.links.length > 0; +} + +async function copyUrl(code) { + try { + await navigator.clipboard.writeText(publicUrl(code)); + showToast('Short link copied to clipboard'); + } catch { showToast('Could not copy. Your browser may require HTTPS.'); } +} + +function openEditor(link = null) { + state.editorGeneration++; + state.editing = link; + $('editorForm').reset(); + showError('editorError', ''); + $('editorEyebrow').textContent = link ? 'EDIT REDIRECT' : 'CREATE REDIRECT'; + $('editorTitle').textContent = link ? 'Edit link' : 'New link'; + $('saveBtn').textContent = link ? 'Save changes' : 'Create link'; + $('saveBtn').disabled = state.pendingMutation; + $('deleteBtn').hidden = !link; + $('deleteBtn').disabled = state.pendingMutation; + $('code').disabled = Boolean(link); + $('code').value = link?.code ?? ''; + $('destination').value = link?.destination ?? ''; + $('codeHelp').textContent = link + ? 'A link code cannot be changed after creation.' + : 'Codes are case-insensitive and may contain nested paths.'; + updatePreview(); + $('editor').showModal(); + (link ? $('destination') : $('code')).focus(); +} + +function updatePreview() { + const code = getCode(); + $('previewUrl').textContent = code ? publicUrl(code) : origin + '/your-code'; +} + +function getCode() { + return state.editing?.code ?? $('code').value.trim().replace(/^\/+|\/+$/g, ''); +} + +function validateCode(code) { + if (!code || code.split('/').some(segment => !segment || segment === '.' || segment === '..')) { + return 'Enter a valid code without empty or dot-only path segments.'; + } + if (/[?#\\]/.test(code)) return 'Codes cannot contain ?, #, or backslashes.'; + const first = code.split('/')[0].toLowerCase(); + if (first === 'api' || first === 'admin' || code.toLowerCase() === 'health') { + return 'This path is reserved for the application.'; + } + if (!state.editing && state.links.some(link => link.code.toLowerCase() === code.toLowerCase())) { + return 'A link with this code already exists. Edit it instead.'; + } + return ''; +} + +function setMutationPending(pending) { + state.pendingMutation = pending; + $('saveBtn').disabled = pending; + $('deleteBtn').disabled = pending; +} + +async function saveLink(event) { + event.preventDefault(); + if (state.pendingMutation) return; + const editorGeneration = state.editorGeneration; + const code = getCode(); + const destination = $('destination').value.trim(); + const error = validateCode(code); + if (error) { showError('editorError', error); return; } + if (!destination) { showError('editorError', 'Enter a destination.'); return; } + setMutationPending(true); + showError('editorError', ''); + try { + await request(apiPath(code), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ destination }) + }); + await loadLinks(); + if (editorGeneration === state.editorGeneration && $('editor').open) { + $('editor').close(); + showToast(state.editing ? 'Link updated' : 'Link created'); + } + } catch (e) { + if (e.name === 'AbortError') return; + if (e.status === 401) { + showLogin(); + showError('loginError', 'Your API key is no longer valid.'); + } else if (editorGeneration === state.editorGeneration && $('editor').open) { + showError('editorError', e.message); + } + } finally { setMutationPending(false); } +} + +async function deleteLink() { + if (state.pendingMutation) return; + const link = state.editing; + if (!link || !confirm('Delete /' + link.code + '?\n\nThis will immediately stop the short link from redirecting.')) return; + const editorGeneration = state.editorGeneration; + setMutationPending(true); + try { + await request(apiPath(link.code), { method: 'DELETE' }); + await loadLinks(); + if (editorGeneration === state.editorGeneration && $('editor').open) { + $('editor').close(); + showToast('Link deleted'); + } + } catch (e) { + if (e.name === 'AbortError') return; + if (e.status === 401) { + showLogin(); + showError('loginError', 'Your API key is no longer valid.'); + } else if (editorGeneration === state.editorGeneration && $('editor').open) { + showError('editorError', e.message); + } + } finally { setMutationPending(false); } +} + +$('loginForm').addEventListener('submit', async event => { + event.preventDefault(); + const apiKey = $('apiKey').value; + showLogin(); + state.apiKey = apiKey; + $('connectBtn').disabled = true; + $('connectBtn').textContent = 'Connecting…'; + showError('loginError', ''); + try { await loadLinks(); showApp(); } + catch (e) { + if (e.name === 'AbortError') return; + state.apiKey = null; + showError('loginError', e.message || 'Unable to connect.'); + } finally { + $('connectBtn').disabled = false; + $('connectBtn').textContent = 'Connect →'; + } +}); +$('showKey').addEventListener('click', () => { + const showing = $('apiKey').type === 'text'; + $('apiKey').type = showing ? 'password' : 'text'; + $('showKey').textContent = showing ? 'Show' : 'Hide'; + $('showKey').setAttribute('aria-label', showing ? 'Show API key' : 'Hide API key'); +}); +$('disconnectBtn').addEventListener('click', showLogin); +$('search').addEventListener('input', render); +$('refreshBtn').addEventListener('click', async () => { + $('refreshBtn').disabled = true; + try { await loadLinks(); showToast('Links refreshed'); } + catch (e) { + if (e.name === 'AbortError') return; + if (e.status === 401) { + showLogin(); + showError('loginError', 'Your API key is no longer valid.'); + } else showToast(e.message); + } finally { $('refreshBtn').disabled = false; } +}); +$('createBtn').addEventListener('click', () => openEditor()); +$('emptyCreateBtn').addEventListener('click', () => openEditor()); +$('closeEditor').addEventListener('click', () => $('editor').close()); +$('cancelEditor').addEventListener('click', () => $('editor').close()); +$('editorForm').addEventListener('submit', saveLink); +$('deleteBtn').addEventListener('click', deleteLink); +$('code').addEventListener('input', updatePreview); +document.addEventListener('keydown', event => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k' && !$('app').hidden) { + event.preventDefault(); + $('search').focus(); + } +}); +$('apiKey').focus(); diff --git a/src/prod/Indirection/wwwroot/admin/index.html b/src/prod/Indirection/wwwroot/admin/index.html new file mode 100644 index 0000000..084a5f1 --- /dev/null +++ b/src/prod/Indirection/wwwroot/admin/index.html @@ -0,0 +1,135 @@ + + + + + + + Indirection · Links + + + + + + + +
+
YOUR WORKSPACE LINK MANAGEMENT
+
+
+

Links

+

Manage your short URLs and where they lead.

+
+ +
+ +
+
+
+

All links

+ 0 +
+
+ + +
+
+
+ + + +
SHORT LINKDESTINATIONCREATEDActions
+
+ + +
+
Codes are case-insensitive and can include nested paths, like docs/start.
+
+ +
+ +
+ + +
+
+
+ CREATE REDIRECT +

New link

+
+ +
+
+
+

Give this destination a short, memorable address.

+ +
/
+
Letters, numbers, and slashes are supported.
+ + +
The address people will be redirected to.
+
+ SHORT LINK PREVIEW + — +
+ +
+
+ + + + +
+
+
+
+ + + \ No newline at end of file diff --git a/src/prod/Indirection/wwwroot/admin/styles.css b/src/prod/Indirection/wwwroot/admin/styles.css new file mode 100644 index 0000000..0ef984f --- /dev/null +++ b/src/prod/Indirection/wwwroot/admin/styles.css @@ -0,0 +1,10 @@ +:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1c2933;background:#f8f9fa;-webkit-font-smoothing:antialiased}*{box-sizing:border-box}body{margin:0;min-height:100vh}button,input,textarea{font:inherit}button{cursor:pointer}button:disabled{opacity:.55;cursor:wait}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid #c6d8ed;outline-offset:2px}svg.icon{width:17px;height:17px;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}[hidden]{display:none!important}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +.site-header{height:76px;border-bottom:1px solid #e9ecef;background:#fff}.header-inner{max-width:1180px;height:100%;margin:auto;padding:0 28px;display:flex;align-items:center;justify-content:space-between}.brand{display:flex;align-items:center;gap:11px;font-size:18px;font-weight:780;letter-spacing:-.8px}.brand-dot{color:#e0a15d}.brand-symbol{width:33px;height:33px;border-radius:9px;background:#1e2a34;color:#fff;display:grid;place-items:center}.brand-symbol svg{width:19px;height:19px}.header-right{display:flex;align-items:center;gap:21px}.hostname{font-size:12px;color:#8b949e;font-weight:560}.key-button{border:1px solid #e8ebee;border-radius:7px;background:#fff;height:34px;padding:0 12px;display:flex;align-items:center;gap:7px;font-size:12px;font-weight:650;color:#54616b}.key-button .icon{width:14px;height:14px}.key-button:hover{background:#f8f9fa} +.page{max-width:1180px;margin:auto;padding:56px 28px 64px}.eyebrow{display:flex;align-items:center;gap:11px;color:#8b969e;font-size:10px;letter-spacing:1.5px;font-weight:760}.eyebrow-line{height:1px;width:26px;background:#ced5da}.page-heading{display:flex;justify-content:space-between;gap:20px;align-items:center;margin:13px 0 33px}.page-heading h1{font-size:38px;letter-spacing:-1.65px;line-height:1.15;margin:0;font-weight:750}.page-heading p{font-size:14px;color:#85909a;margin:10px 0 0}.button{border-radius:7px;border:1px solid transparent;display:inline-flex;align-items:center;justify-content:center;gap:8px;white-space:nowrap;font-size:13px;font-weight:680;min-height:40px;padding:0 16px;transition:background .15s,border-color .15s}.button .icon{width:16px;height:16px}.button-primary{background:#253643;color:#fff;box-shadow:0 1px 2px #1a2c3618}.button-primary:hover{background:#344c5c}.button-secondary{background:#fff;color:#46535f;border-color:#e0e5e9}.button-secondary:hover{background:#f6f8f9}.button-danger{color:#b54d51;background:#fff;border-color:#efd4d5}.button-danger:hover{background:#fff3f3} +.list-panel{border:1px solid #e7ebee;border-radius:11px;background:#fff;box-shadow:0 6px 20px rgba(27,38,45,.025);overflow:hidden}.list-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:20px 24px}.list-heading{display:flex;align-items:center;gap:11px}.list-heading h2{font-size:15px;letter-spacing:-.25px;margin:0;font-weight:730}.count-pill{font-size:11px;min-width:23px;height:23px;padding:3px 7px;text-align:center;border-radius:7px;background:#f0f2f4;color:#67737c;font-weight:750}.toolbar-actions{display:flex;align-items:center;gap:9px}.search-wrap{width:245px;height:36px;border:1px solid #e7ebee;border-radius:7px;display:flex;align-items:center;gap:9px;padding:0 11px;color:#a4adb5;background:#fff}.search-wrap:focus-within{border-color:#b5c3cc}.search-wrap .icon{width:15px;height:15px}.search-wrap input{border:0;outline:0;min-width:0;flex:1;font-size:12px;color:#2b3640;background:transparent}.search-wrap input::placeholder{color:#a3adb4}.shortcut{font-size:10px;color:#a0a9b1;white-space:nowrap;border:1px solid #edf0f2;border-radius:4px;padding:1px 4px}.icon-button{display:inline-flex;align-items:center;justify-content:center;width:33px;height:33px;border:1px solid transparent;border-radius:7px;background:transparent;color:#84919b}.icon-button:hover{background:#f1f4f6;color:#283945}.refresh{border-color:#e7ebee;width:36px;height:36px}.refresh .icon{width:16px;height:16px} +.table-scroll{overflow-x:auto}table{border-collapse:collapse;width:100%;text-align:left;table-layout:fixed}thead{background:#fafbfc}thead th{height:41px;text-transform:uppercase;font-size:10px;letter-spacing:1.1px;color:#a0a9b2;font-weight:750;border-top:1px solid #eef1f3;border-bottom:1px solid #eef1f3;padding:0 24px}th:nth-child(1){width:29%}th:nth-child(2){width:45%}th:nth-child(3){width:16%}th:nth-child(4){width:10%}tbody tr{border-bottom:1px solid #f0f2f4}tbody tr:hover{background:#fcfdfd}tbody tr:last-child{border-bottom:0}tbody td{padding:17px 24px;font-size:12px;color:#8b959e;vertical-align:middle}tbody td:nth-child(1),tbody td:nth-child(2){overflow:hidden}.code-cell{display:flex;align-items:center;min-width:0;gap:11px}.link-avatar{flex-shrink:0;width:32px;height:32px;background:#f1f5f6;border:1px solid #e9eef0;display:grid;place-items:center;border-radius:8px;color:#5f8290}.link-avatar .icon{width:15px;height:15px}.code-name{min-width:0;font-weight:690;color:#273945;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.code-name:hover{text-decoration:underline;cursor:pointer}.domain-text{display:block;color:#6e7a84;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.6}.path-text{display:block;color:#a1abb3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px}.row-actions{display:flex;align-items:center;justify-content:end;gap:2px}.row-actions button{width:29px;height:29px}.row-actions .icon{width:15px;height:15px}.table-footer{height:54px;border-top:1px solid #edf0f2;display:flex;justify-content:space-between;align-items:center;padding:0 24px;color:#a1abb3;font-size:11px}.table-footer span:first-child{color:#6a7781;font-weight:630}.page-footnote{margin-top:22px;color:#a6afb6;font-size:11px;text-align:center}.page-footnote code{font-family:ui-monospace,Consolas,monospace;background:#eff1f3;padding:3px 5px;border-radius:4px;color:#687782}.empty-state{text-align:center;padding:65px 20px 75px}.empty-art{height:55px;width:55px;display:grid;place-items:center;background:#f5f7f8;border-radius:15px;margin:0 auto 17px;color:#92a0ab}.empty-art svg{width:30px;height:30px;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.empty-state h3{font-size:16px;margin:0 0 7px}.empty-state p{font-size:13px;color:#99a3ac;margin:0 0 20px} +.login{max-width:448px;margin:0 auto;padding:105px 25px 80px}.login-card{border-radius:14px;border:1px solid #e9ecef;background:#fff;box-shadow:0 15px 40px #202e3710;padding:35px}.login-symbol{display:grid;place-items:center;width:44px;height:44px;border-radius:11px;color:#fff;background:#22333f;margin-bottom:29px}.login-symbol svg{width:24px;height:24px;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:2}.login-kicker,.drawer-eyebrow{font-size:10px;letter-spacing:1.4px;font-weight:800;color:#a1abb3}.login h1{font-size:25px;letter-spacing:-1px;margin:12px 0 9px}.login p{color:#88939b;font-size:13px;line-height:1.65;margin:0 0 30px}.login label,.editor-fields label{display:block;font-size:12px;font-weight:700;color:#42505b;margin-bottom:10px}.password-wrap{position:relative}.password-wrap input,.editor-fields input,.editor-fields textarea{width:100%;background:#fff;border:1px solid #dfe5e9;border-radius:7px;padding:12px 13px;font-size:13px;color:#253744;outline:0}.password-wrap input{padding-right:62px}.password-wrap input:focus,.editor-fields input:focus,.editor-fields textarea:focus{border-color:#8eabbe;box-shadow:0 0 0 3px #d9e7ef80}.show-password{position:absolute;right:8px;top:8px;border:0;background:#f4f6f7;font-size:11px;color:#60717d;border-radius:5px;padding:6px 8px}.full-width{width:100%;margin-top:19px;justify-content:space-between;padding:0 16px}.login-note{display:flex;gap:9px;color:#9fa8b1;line-height:1.6;font-size:11px;border-top:1px solid #eef0f2;margin-top:25px;padding-top:21px}.login-note .icon{width:15px;height:15px;flex-shrink:0;margin-top:1px} +.drawer{border:0;padding:0;margin:0 0 0 auto;width:min(100%,460px);height:100dvh;max-height:100dvh;background:white;box-shadow:-15px 0 40px #0c182b14;color:#253744}.drawer::backdrop{background:rgba(19,34,45,.34)}.drawer[open]{animation:slide-in .2s ease-out}@keyframes slide-in{from{transform:translateX(20px);opacity:.8}to{transform:translateX(0);opacity:1}}.drawer-inner{height:100%;display:flex;flex-direction:column}.drawer-header{padding:33px 34px 27px;display:flex;align-items:start;justify-content:space-between;border-bottom:1px solid #edf0f2}.drawer-header h2{font-size:25px;letter-spacing:-.85px;margin:8px 0 0}.drawer-header .icon-button{margin-top:-3px}.editor-form{min-height:0;flex:1;display:flex;flex-direction:column;justify-content:space-between}.editor-fields{padding:28px 34px;overflow:auto}.editor-description{font-size:13px;line-height:1.55;color:#89949e;margin:0 0 31px}.code-field{display:flex;align-items:center;border:1px solid #dfe5e9;border-radius:7px;overflow:hidden}.code-field:focus-within{border-color:#8eabbe;box-shadow:0 0 0 3px #d9e7ef80}.code-field span{display:block;padding:11px 0 11px 13px;color:#84929d;font-size:13px}.code-field input{border:0;box-shadow:none!important;padding:12px 12px 12px 2px}.code-field input:disabled{color:#778692;background:#f9fafb}.field-help{font-size:11px;color:#a1abb3;line-height:1.5;margin-top:8px}.second-label{margin-top:28px}.editor-fields textarea{resize:vertical;line-height:1.5;min-height:92px}.preview-box{background:#f6f8f9;border:1px solid #edf1f3;border-radius:9px;margin-top:29px;padding:18px}.preview-box span{display:block;font-size:10px;letter-spacing:1px;font-weight:800;color:#9ba6af;margin-bottom:10px}.preview-box strong{font-family:ui-monospace,Consolas,monospace;color:#385c6d;font-size:12px;word-break:break-all;font-weight:650}.drawer-footer{border-top:1px solid #edf0f2;padding:20px 26px;display:flex;gap:8px}.footer-spacer{flex:1}.form-error{font-size:12px!important;color:#b44c4f!important;line-height:1.5!important;margin:13px 0 0!important}.toast{position:fixed;bottom:28px;left:50%;transform:translateX(-50%);z-index:10;background:#263b47;color:#fff;border-radius:8px;box-shadow:0 10px 30px #13212c2e;padding:12px 17px;font-size:12px;font-weight:660;white-space:nowrap} +@media(max-width:760px){.site-header{height:65px}.header-inner{padding:0 18px}.hostname{display:none}.page{padding:35px 16px}.page-heading{margin-bottom:25px}.page-heading h1{font-size:32px}.page-heading p{font-size:13px;max-width:260px}.page-heading .button{padding:0 12px}.list-toolbar{padding:16px;align-items:stretch;flex-direction:column}.toolbar-actions{width:100%}.search-wrap{flex:1;width:auto}.shortcut{display:none}.table-scroll{overflow:visible}table,tbody{display:block;width:100%}thead{display:none}tbody tr{display:grid;grid-template-columns:1fr auto;gap:3px 10px;padding:15px 17px}tbody td{padding:0;min-width:0}tbody td:first-child{grid-column:1;grid-row:1}tbody td:nth-child(2){grid-column:1;grid-row:2;margin-left:43px}tbody td:nth-child(3){grid-column:1;grid-row:3;margin-left:43px;font-size:11px}tbody td:nth-child(4){grid-column:2;grid-row:1/span 3;align-self:center}.row-actions{gap:0}.table-footer{padding:0 17px}.table-footer span:nth-child(2){display:none}.page-footnote{line-height:1.7}.drawer-header{padding:26px 23px 23px}.editor-fields{padding:26px 23px}.drawer-footer{padding:17px 14px}.drawer-footer .button{padding:0 11px}.login{padding:54px 17px}.login-card{padding:25px}} +@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}} +.hostname,.eyebrow,.page-heading p,.search-wrap,.search-wrap input::placeholder,.shortcut,thead th,tbody td,.domain-text,.path-text,.table-footer,.page-footnote,.page-footnote code,.empty-state p,.login-kicker,.login p,.login-note,.drawer-eyebrow,.editor-description,.field-help,.code-field span,.preview-box span{color:#626d76} diff --git a/src/test/Indirection.Tests/AdminUiTests.cs b/src/test/Indirection.Tests/AdminUiTests.cs new file mode 100644 index 0000000..ab30b1c --- /dev/null +++ b/src/test/Indirection.Tests/AdminUiTests.cs @@ -0,0 +1,59 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.EntityFrameworkCore; + +namespace Indirection.Tests; + +[TestClass] +public sealed class AdminUiTests +{ + [TestMethod] + public async Task AdminUiIsServedWithoutExposingTheManagementApi() + { + await using var app = new AppFactory(); + using var client = app.CreateApiClient(authenticated: false); + + using var redirect = await client.GetAsync("/admin"); + Assert.AreEqual(HttpStatusCode.MovedPermanently, redirect.StatusCode); + Assert.AreEqual("/admin/", redirect.Headers.Location?.AbsolutePath); + + using var index = await client.GetAsync(redirect.Headers.Location); + Assert.AreEqual(HttpStatusCode.OK, index.StatusCode); + Assert.AreEqual("text/html", index.Content.Headers.ContentType?.MediaType); + StringAssert.Contains(await index.Content.ReadAsStringAsync(), "Indirection · Links"); + + foreach (var path in new[] { "/admin/styles.css", "/admin/app.js" }) + { + using var asset = await client.GetAsync(path); + Assert.AreEqual(HttpStatusCode.OK, asset.StatusCode, path); + } + + using var management = await client.GetAsync("/api/v1/links"); + Assert.AreEqual(HttpStatusCode.Unauthorized, management.StatusCode); + } + + [TestMethod] + public async Task AdminPathsAreReservedEvenWhenRedirectsExist() + { + await using var app = new AppFactory(); + using var client = app.CreateApiClient(); + await app.WithDb(db => db.Database.MigrateAsync()); + + foreach (var code in new[] { "admin", "admin/index.html", "admin/app.js", "admin/missing" }) + { + using var created = await client.PutAsJsonAsync("/api/v1/links/" + code, + new { destination = "https://example.com/reserved" }); + Assert.AreEqual(HttpStatusCode.Created, created.StatusCode); + } + + using var anonymous = app.CreateApiClient(authenticated: false); + using var index = await anonymous.GetAsync("/admin/index.html"); + Assert.AreEqual(HttpStatusCode.OK, index.StatusCode); + using var script = await anonymous.GetAsync("/admin/app.js"); + Assert.AreEqual(HttpStatusCode.OK, script.StatusCode); + using var missing = await anonymous.GetAsync("/admin/missing"); + Assert.AreEqual(HttpStatusCode.NotFound, missing.StatusCode); + using var root = await anonymous.GetAsync("/admin"); + Assert.AreEqual(HttpStatusCode.MovedPermanently, root.StatusCode); + } +} diff --git a/src/test/admin-ui/app.test.cjs b/src/test/admin-ui/app.test.cjs new file mode 100644 index 0000000..6bab002 --- /dev/null +++ b/src/test/admin-ui/app.test.cjs @@ -0,0 +1,264 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { readFileSync } = require('node:fs'); +const { runInNewContext } = require('node:vm'); +const { resolve } = require('node:path'); + +// Exercise the shipped script with a small DOM adapter, without frontend packages. +function createApp(fetch) { + const elements = new Map(); + const element = () => ({ + value: '', hidden: false, open: false, children: [], listeners: {}, style: {}, + classList: { add() {} }, + addEventListener(name, callback) { this.listeners[name] = callback; }, + setAttribute() {}, focus() {}, reset() {}, + append(...children) { this.children.push(...children); }, + replaceChildren(...children) { this.children = children; }, + showModal() { this.open = true; }, close() { this.open = false; } + }); + const get = id => { + if (!elements.has(id)) elements.set(id, element()); + return elements.get(id); + }; + let copied; + const context = { + document: { getElementById: get, createElement: element, createElementNS: element, addEventListener() {} }, + location: { origin: 'https://short.example', hostname: 'short.example' }, + fetch, AbortController, DOMException, URL, + navigator: { clipboard: { async writeText(value) { copied = value; } } }, + confirm: () => true, setTimeout: () => 1, clearTimeout() {} + }; + context.window = { location: context.location }; + runInNewContext(readFileSync(resolve(__dirname, '../../prod/Indirection/wwwroot/admin/app.js'), 'utf8'), context); + return { + get, copied: () => copied, + async event(id, name = 'click') { await get(id).listeners[name]({ preventDefault() {} }); } + }; +} +const json = body => ({ ok: true, status: 200, headers: new Headers({ 'content-type': 'application/json' }), json: async () => body }); +const noContent = { ok: true, status: 204 }; +function deferred() { + let resolve; + const promise = new Promise(r => { resolve = r; }); + return { promise, resolve }; +} + +test('connect, search, copy, create, edit and delete use the existing management API', async () => { + const links = [{ code: 'docs/start', destination: 'https://example.com/guide', createdUtc: '2026-10-29T00:00:00Z' }]; + const calls = []; + const app = createApp(async (path, options) => { + calls.push({ path, ...options }); + assert.equal(options.headers['X-Api-Key'], 'test-key'); + if (!options.method) return json([...links]); + if (options.method === 'PUT') { + const code = decodeURIComponent(path.slice('/api/v1/links/'.length)); + const destination = JSON.parse(options.body).destination; + const existing = links.find(link => link.code === code); + if (existing) existing.destination = destination; + else links.push({ code, destination }); + } else if (options.method === 'DELETE') links.splice(links.findIndex(link => path.endsWith(link.code)), 1); + return noContent; + }); + app.get('apiKey').value = 'test-key'; + await app.event('loginForm', 'submit'); + assert.equal(app.get('app').hidden, false); + assert.equal(app.get('apiKey').value, ''); + assert.equal(app.get('linkRows').children.length, 1); + + app.get('search').value = 'missing'; + await app.event('search', 'input'); + assert.equal(app.get('linkRows').children.length, 0); + app.get('search').value = 'guide'; + await app.event('search', 'input'); + assert.equal(app.get('linkRows').children.length, 1); + const row = app.get('linkRows').children[0]; + await row.children[3].children[0].children[0].listeners.click(); + assert.equal(app.copied(), 'https://short.example/docs/start'); + + await app.event('createBtn'); + app.get('code').value = 'team/hello world'; + app.get('destination').value = 'https://example.com/new'; + await app.event('editorForm', 'submit'); + assert.equal(calls.find(call => call.method === 'PUT').path, '/api/v1/links/team/hello%20world'); + assert.equal(links.length, 2); + await row.children[3].children[0].children[1].listeners.click(); + assert.equal(app.get('code').disabled, true); + app.get('code').value = 'docs/start'; // The DOM adapter does not implement form inputs. + app.get('destination').value = 'https://example.com/updated'; + await app.event('editorForm', 'submit'); + assert.equal(links[0].destination, 'https://example.com/updated'); + await app.event('deleteBtn'); + assert.equal(links.length, 1); + assert.equal(calls.find(call => call.method === 'DELETE').path, '/api/v1/links/docs/start'); +}); + +test('disconnect aborts a refresh and ignores a late response body', async () => { + const body = deferred(); + let calls = 0; + let refreshSignal; + const app = createApp(async (_, options) => { + if (++calls === 1) return json([]); + refreshSignal = options.signal; + return { ...json([]), json: () => body.promise }; + }); + app.get('apiKey').value = 'test-key'; + await app.event('loginForm', 'submit'); + const refresh = app.event('refreshBtn'); + await Promise.resolve(); + await app.event('disconnectBtn'); + assert.equal(refreshSignal.aborted, true); + body.resolve([{ code: 'private', destination: 'https://example.com' }]); + await refresh; + assert.equal(app.get('app').hidden, true); + assert.equal(app.get('login').hidden, false); + assert.equal(app.get('linkRows').children.length, 0); + assert.equal(app.get('toast').hidden, true); +}); + +test('an expired key disconnects and clears rendered data', async () => { + let calls = 0; + const app = createApp(async () => ++calls === 1 + ? json([{ code: 'private', destination: 'https://example.com' }]) + : { ok: false, status: 401 }); + app.get('apiKey').value = 'expired-key'; + await app.event('loginForm', 'submit'); + await app.event('refreshBtn'); + assert.equal(app.get('login').hidden, false); + assert.equal(app.get('linkRows').children.length, 0); + assert.match(app.get('loginError').textContent, /no longer valid/); +}); + +test('a pending login cannot reconnect after disconnect', async () => { + const response = deferred(); + const app = createApp(() => response.promise); + app.get('apiKey').value = 'test-key'; + const login = app.event('loginForm', 'submit'); + await app.event('disconnectBtn'); + response.resolve(json([])); + await login; + assert.equal(app.get('app').hidden, true); + assert.equal(app.get('login').hidden, false); +}); + +test('invalid keys and reserved paths produce errors without writing a link', async () => { + const calls = []; + const app = createApp(async (path, options) => { + calls.push({ path, ...options }); + return options.headers['X-Api-Key'] === 'wrong' ? { ok: false, status: 401 } : json([]); + }); + app.get('apiKey').value = 'wrong'; + await app.event('loginForm', 'submit'); + assert.match(app.get('loginError').textContent, /Invalid API key/); + assert.equal(app.get('app').hidden, true); + app.get('apiKey').value = 'test-key'; + await app.event('loginForm', 'submit'); + await app.event('createBtn'); + for (const code of ['admin', 'ADMIN/nested', 'api/v1/links', 'health', 'a/../b', 'a//b', 'a?b']) { + app.get('code').value = code; + app.get('destination').value = 'https://example.com'; + await app.event('editorForm', 'submit'); + assert.equal(app.get('editorError').hidden, false, code); + } + assert.equal(calls.filter(call => call.method).length, 0); +}); + +async function connect(app) { + app.get('apiKey').value = 'test-key'; + await app.event('loginForm', 'submit'); +} +async function editRow(app, index = 0) { + await app.get('linkRows').children[index].children[3].children[0].children[1].listeners.click(); +} +const existingLink = { code: 'docs/start', destination: 'https://example.com/guide' }; + +for (const staleResponse of ['snapshot', 'unauthorized']) { + test(`a superseded refresh ${staleResponse} cannot undo a completed creation`, async () => { + const oldRefresh = deferred(); + let reads = 0; + const app = createApp(async (_, options) => { + if (options.method === 'PUT') return noContent; + if (++reads === 2) return oldRefresh.promise; + return json(reads === 1 ? [existingLink] : [existingLink, { code: 'new', destination: 'https://example.com/new' }]); + }); + await connect(app); + const refresh = app.event('refreshBtn'); + await app.event('createBtn'); + app.get('code').value = 'new'; + app.get('destination').value = 'https://example.com/new'; + await app.event('editorForm', 'submit'); + assert.equal(app.get('linkRows').children.length, 2); + oldRefresh.resolve(staleResponse === 'snapshot' ? json([existingLink]) : { ok: false, status: 401 }); + await refresh; + assert.equal(app.get('linkRows').children.length, 2); + assert.equal(app.get('app').hidden, false); + assert.equal(app.get('toast').textContent, 'Link created'); + }); +} + +for (const operation of ['save', 'delete']) { + test(`a pending ${operation} prevents competing writes`, async () => { + const mutation = deferred(); + const writes = []; + const app = createApp(async (_, options) => { + if (!options.method) return json([existingLink]); + writes.push(options.method); + return mutation.promise; + }); + await connect(app); + await editRow(app); + const pending = operation === 'save' ? app.event('editorForm', 'submit') : app.event('deleteBtn'); + assert.equal(app.get('saveBtn').disabled, true); + assert.equal(app.get('deleteBtn').disabled, true); + // Invoke handlers directly too: disabled controls alone are insufficient. + await app.event('editorForm', 'submit'); + await app.event('deleteBtn'); + assert.deepEqual(writes, [operation === 'save' ? 'PUT' : 'DELETE']); + mutation.resolve(noContent); + await pending; + assert.equal(app.get('saveBtn').disabled, false); + assert.equal(app.get('deleteBtn').disabled, false); + }); +} + +for (const outcome of ['success', 'failure']) { + test(`a previous save's ${outcome} cannot affect a reopened editor`, async () => { + const mutation = deferred(); + const app = createApp(async (_, options) => options.method ? mutation.promise : json([existingLink])); + await connect(app); + await editRow(app); + const save = app.event('editorForm', 'submit'); + await app.event('cancelEditor'); + await app.event('createBtn'); + app.get('code').value = 'another'; + app.get('destination').value = 'https://example.com/another'; + assert.equal(app.get('saveBtn').disabled, true); + mutation.resolve(outcome === 'success' ? noContent : { ok: false, status: 500 }); + await save; + assert.equal(app.get('editor').open, true); + assert.equal(app.get('code').value, 'another'); + assert.equal(app.get('destination').value, 'https://example.com/another'); + assert.equal(app.get('editorError').hidden, true); + assert.equal(app.get('saveBtn').disabled, false); + }); +} + +test('editing preserves an existing alias exactly in previews and write paths', async () => { + const link = { code: 'docs ', destination: 'https://example.com/original' }; + const writes = []; + const app = createApp(async (path, options) => { + if (!options.method) return json([link]); + writes.push({ path, ...options }); + return noContent; + }); + await connect(app); + await editRow(app); + assert.equal(app.get('previewUrl').textContent, 'https://short.example/docs%20'); + app.get('code').value = 'docs'; // Even a changed disabled input must not rename the alias. + app.get('destination').value = 'https://example.com/updated'; + await app.event('editorForm', 'submit'); + assert.equal(writes[0].path, '/api/v1/links/docs%20'); + assert.equal(JSON.parse(writes[0].body).destination, 'https://example.com/updated'); + await editRow(app); + await app.event('deleteBtn'); + assert.equal(writes[1].path, '/api/v1/links/docs%20'); +});