diff --git a/bootstrap/app.php b/bootstrap/app.php index 04ad23c..3488ce7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -33,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__)) ]); }) ->withExceptions(function (Exceptions $exceptions): void { - // + $exceptions->dontFlash(['api_token']); })->create(); diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index 4315b57..5be342d 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -731,4 +731,43 @@ export const en = { 'ticketForm.saved': 'All changes saved', 'ticketForm.authorRestricted': 'Only project admins can change the author.', 'ticketForm.blocker': 'Blocker', + 'gitConnections.description': + 'Connect your personal Git account to browse repositories, branches, and pull requests you can access.', + 'gitConnections.privacy': + 'Connections belong to your account. Saved tokens stay hidden. Without a matching connection, only repository names are available.', + 'gitConnections.add': 'Add connection', + 'gitConnections.editAction': 'Edit', + 'gitConnections.edit': 'Edit connection', + 'gitConnections.remove': 'Remove', + 'gitConnections.removeTitle': 'Remove connection', + 'gitConnections.saved': 'Saved connections', + 'gitConnections.empty': 'No Git connections yet', + 'gitConnections.emptyDescription': + 'Add a Gitea connection to access your repositories and their Git activity.', + 'gitConnections.unnamedAccount': 'Unnamed remote account', + 'gitConnections.remoteId': 'Remote user ID: {id}', + 'gitConnections.editAccount': 'Edit {account} at {server}', + 'gitConnections.removeAccount': 'Remove {account} at {server}', + 'gitConnections.addDescription': + 'Connect a Gitea account using its server URL and a personal application token.', + 'gitConnections.editDescription': + 'Update the server URL and enter a fresh token to verify this connection.', + 'gitConnections.removeDescription': + 'Remove this personal connection? Repository links remain, but Git activity will require another matching connection.', + 'gitConnections.gitea': 'Gitea', + 'gitConnections.baseUrl': 'Gitea base URL', + 'gitConnections.token': 'Application token', + 'gitConnections.newToken': 'New application token', + 'gitConnections.tokenPlaceholder': 'Paste your Gitea application token', + 'gitConnections.tokenHelp': + 'Your token is verified with Gitea before saving. It is never shown in the saved connection.', + 'gitConnections.verifying': 'Verifying connection…', + 'gitConnections.save': 'Save changes', + 'gitConnections.removing': 'Removing connection…', + 'gitConnections.forbidden': 'Connection access denied', + 'gitConnections.forbiddenDescription': + 'You can only manage connections belonging to your account. Close this dialog and refresh the page.', + 'gitConnections.added': 'Git connection added.', + 'gitConnections.updated': 'Git connection updated.', + 'gitConnections.removed': 'Git connection removed.', } as const; diff --git a/resources/js/pages/settings/GitConnections.vue b/resources/js/pages/settings/GitConnections.vue index 811ed20..7293e6c 100644 --- a/resources/js/pages/settings/GitConnections.vue +++ b/resources/js/pages/settings/GitConnections.vue @@ -1,267 +1,493 @@ diff --git a/tests/Feature/Settings/GitConnectionTest.php b/tests/Feature/Settings/GitConnectionTest.php index c1c051d..34af7c9 100644 --- a/tests/Feature/Settings/GitConnectionTest.php +++ b/tests/Feature/Settings/GitConnectionTest.php @@ -25,11 +25,15 @@ class GitConnectionTest extends TestCase 'base_url' => 'https://git.example.test', ]); + UserGitConnection::factory()->create(); + $this->actingAs($user) ->get(route('git-connections.edit')) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('settings/GitConnections') + ->has('gitConnections', 1) + ->missing('gitConnections.0.api_token') ->where('gitConnections.0.account_login', 'nico') ->where('gitConnections.0.base_url', 'https://git.example.test') ->where('gitConnections.0.provider', GitProviderKey::Gitea->value), @@ -93,7 +97,8 @@ class GitConnectionTest extends TestCase 'api_token' => 'wrong-token', ]) ->assertRedirect(route('git-connections.edit')) - ->assertSessionHasErrors('api_token'); + ->assertSessionHasErrors('api_token') + ->assertSessionMissing('_old_input.api_token'); } /** @@ -181,4 +186,59 @@ class GitConnectionTest extends TestCase 'id' => $gitConnection->id, ]); } + + public function test_empty_personal_connections_are_displayed(): void + { + UserGitConnection::factory()->create(); + + $this->actingAs(User::factory()->create()) + ->get(route('git-connections.edit')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('settings/GitConnections') + ->where('gitConnections', []), + ); + } + + public function test_guests_cannot_access_git_connections(): void + { + $this->get(route('git-connections.edit'))->assertRedirect(route('login')); + } + + public function test_users_cannot_delete_foreign_connections(): void + { + $connection = UserGitConnection::factory()->create(); + + $this->actingAs(User::factory()->create()) + ->delete(route('git-connections.destroy', $connection)) + ->assertForbidden(); + + $this->assertModelExists($connection); + } + + public function test_failed_provider_update_preserves_connection_and_does_not_flash_token(): void + { + Http::preventStrayRequests(); + Http::fake(['https://git.changed.test/api/v1/user' => Http::response([], 503)]); + $user = User::factory()->create(); + $connection = UserGitConnection::factory()->for($user)->create([ + 'base_url' => 'https://git.original.test', + 'api_token' => 'original-secret', + ]); + $original = $connection->fresh()->getAttributes(); + + $this->actingAs($user) + ->from(route('git-connections.edit')) + ->patch(route('git-connections.update', $connection), [ + 'provider' => 'gitea', + 'base_url' => 'https://git.changed.test', + 'api_token' => 'replacement-secret', + ]) + ->assertRedirect(route('git-connections.edit')) + ->assertSessionHasErrors('api_token') + ->assertSessionMissing('_old_input.api_token'); + + $this->assertSame($original, $connection->fresh()->getAttributes()); + $this->assertSame('original-secret', $connection->fresh()->api_token); + } } diff --git a/tests/Feature/Settings/GitConnectionsUiTest.php b/tests/Feature/Settings/GitConnectionsUiTest.php new file mode 100644 index 0000000..4ff1fcd --- /dev/null +++ b/tests/Feature/Settings/GitConnectionsUiTest.php @@ -0,0 +1,18 @@ +setTimeout(60); + $process->run(); + $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput()); + $this->assertStringContainsString('Git connections rendering checks passed.', $process->getOutput()); + } +} diff --git a/tests/Fixtures/git-connections-browser.mjs b/tests/Fixtures/git-connections-browser.mjs new file mode 100644 index 0000000..adef9f0 --- /dev/null +++ b/tests/Fixtures/git-connections-browser.mjs @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; + +const { chromium } = await import( + process.env.PLAYWRIGHT_MODULE ?? 'playwright' +); +const browser = await chromium.launch({ channel: 'chrome', headless: true }); +const base = 'http://127.0.0.1:4186'; +const context = await browser.newContext({ + viewport: { width: 1440, height: 1000 }, +}); +const page = await context.newPage(); +page.setDefaultTimeout(10000); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +const dialog = () => page.getByRole('dialog'); +const button = (name) => dialog().getByRole('button', { name, exact: true }); +const state = (value) => + dialog() + .locator(`[data-form-state="${value}"]`) + .waitFor({ state: 'visible' }); +const setResult = (value) => + context.addCookies([{ name: 'git_test_result', value, url: base }]); +const add = () => + page.getByRole('button', { name: 'Add connection', exact: true }).click(); +const edit = () => + page + .getByRole('button', { + name: 'Edit alex at https://git.example.test', + exact: true, + }) + .click(); +const visit = async (query = '?reset') => { + await page.goto(`${base}/settings/git-connections${query}`); + await page + .getByRole('heading', { name: 'Git connections', exact: true }) + .waitFor(); +}; +const noOverflow = async () => + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + true, + ); +const fill = async (token = 'test-secret') => { + await dialog() + .getByLabel('Gitea base URL', { exact: true }) + .fill('https://git.example.test'); + await page.locator('#git-token').fill(token); +}; +const closed = () => dialog().waitFor({ state: 'hidden' }); +try { + for (const width of [1440, 390, 320]) { + await page.setViewportSize({ width, height: 1000 }); + for (const theme of ['light', 'dark']) { + await page.emulateMedia({ colorScheme: theme }); + await visit(); + await noOverflow(); + assert.equal( + await page + .locator('[data-slot="card"]') + .first() + .evaluate( + (element) => + element.getBoundingClientRect().right <= + window.innerWidth, + ), + true, + `Connection list clips at ${width}px in ${theme}`, + ); + if (width === 390) { + await page.screenshot({ + path: `/private/tmp/KAN-38-${theme}-list.png`, + fullPage: true, + }); + } + await add(); + await dialog().waitFor(); + await noOverflow(); + assert.equal( + await page.locator('#git-token').getAttribute('type'), + 'password', + ); + assert.equal( + await page.locator('#git-token').getAttribute('autocomplete'), + 'new-password', + ); + await page.keyboard.press('Tab'); + assert.equal( + await dialog().evaluate((element) => + element.contains(document.activeElement), + ), + true, + ); + await dialog().evaluate((element) => + Promise.all( + element + .getAnimations() + .map((animation) => animation.finished), + ), + ); + if (width === 390) { + await page.screenshot({ + path: `/private/tmp/KAN-38-${theme}-dialog.png`, + fullPage: true, + }); + } + await page.keyboard.press('Escape'); + await closed(); + assert.equal( + await page + .getByRole('button', { + name: 'Add connection', + exact: true, + }) + .evaluate((element) => document.activeElement === element), + true, + ); + await edit(); + assert.equal( + await page.locator('#git-base-url').inputValue(), + 'https://git.example.test', + ); + assert.equal(await page.locator('#git-token').inputValue(), ''); + await noOverflow(); + await button('Cancel').click(); + await closed(); + } + } + await visit('?empty'); + await page.getByText('No Git connections yet', { exact: true }).waitFor(); + await add(); + await fill(); + await setResult('validation'); + await button('Add connection').click(); + await state('validation'); + assert.equal( + await page.locator('#git-token').getAttribute('aria-invalid'), + 'true', + ); + assert.equal(await page.locator('#git-token').inputValue(), 'test-secret'); + await page + .getByText( + 'Gitea could not authenticate with this base URL and token.', + { exact: true }, + ) + .waitFor(); + assert.doesNotMatch( + await page.evaluate(() => JSON.stringify(history.state)), + /test-secret/, + ); + await page.route('**/settings/git-connections', (route) => + route.abort('failed'), + ); + await button('Add connection').click(); + await state('network'); + await page.unroute('**/settings/git-connections'); + await setResult('server'); + await button('Try again').click(); + await state('server'); + assert.equal(await page.locator('#git-token').inputValue(), 'test-secret'); + await setResult('success'); + const submitted = page.waitForRequest( + (request) => + request.method() === 'POST' && + request.url().endsWith('/settings/git-connections'), + ); + await button('Try again').click(); + assert.deepEqual((await submitted).postDataJSON(), { + provider: 'gitea', + base_url: 'https://git.example.test', + api_token: 'test-secret', + }); + await closed(); + await page.getByText('Git connection added.', { exact: true }).waitFor(); + await add(); + assert.equal(await page.locator('#git-token').inputValue(), ''); + await button('Cancel').click(); + await closed(); + + await visit(); + await edit(); + await page.locator('#git-token').fill('updated-secret'); + const patch = page.waitForRequest( + (request) => request.method() === 'PATCH', + ); + await button('Save changes').click(); + assert.equal((await patch).postDataJSON().api_token, 'updated-secret'); + await closed(); + await page.getByText('Git connection updated.', { exact: true }).waitFor(); + await edit(); + assert.equal(await page.locator('#git-token').inputValue(), ''); + await page.locator('#git-token').fill('discard-me'); + page.once('dialog', (confirmation) => confirmation.dismiss()); + await button('Cancel').click(); + assert.equal(await dialog().isVisible(), true); + page.once('dialog', (confirmation) => confirmation.accept()); + await button('Cancel').click(); + await closed(); + await edit(); + assert.equal(await page.locator('#git-token').inputValue(), ''); + await button('Cancel').click(); + await closed(); + + await page + .getByRole('button', { + name: 'Remove alex at https://git.example.test', + exact: true, + }) + .click(); + await setResult('forbidden'); + await button('Remove connection').click(); + await state('server'); + await page.getByText('Connection access denied', { exact: true }).waitFor(); + assert.equal(await button('Try again').count(), 0); + await button('Cancel').click(); + await closed(); + await page + .getByRole('button', { + name: 'Remove alex at https://git.example.test', + exact: true, + }) + .click(); + await setResult('success'); + const deletion = page.waitForRequest( + (request) => request.method() === 'DELETE', + ); + await button('Remove connection').click(); + assert.equal( + new URL((await deletion).url()).pathname, + '/settings/git-connections/1', + ); + await closed(); + await page.getByText('No Git connections yet', { exact: true }).waitFor(); + assert.deepEqual(errors, []); + console.log( + 'Git connections browser checks passed: create, edit, remove, token privacy, provider errors, retries, permissions, discard, keyboard and both themes at 320/390/1440px.', + ); +} finally { + await browser.close(); +} diff --git a/tests/Fixtures/git-connections-preview.mjs b/tests/Fixtures/git-connections-preview.mjs new file mode 100644 index 0000000..b4d0c63 --- /dev/null +++ b/tests/Fixtures/git-connections-preview.mjs @@ -0,0 +1,16 @@ +import { createApp, h } from 'vue'; +import { createInertiaApp } from '@inertiajs/vue3'; +import '../../resources/css/app.css'; +import GitConnections from '../../resources/js/pages/settings/GitConnections.vue'; +import { createAppearance } from '../../resources/js/composables/useAppearance'; +import { createLocalization } from '../../resources/js/lib/localization'; + +createInertiaApp({ + resolve: () => GitConnections, + setup: ({ el, App, props, plugin }) => + createApp({ render: () => h(App, props) }) + .use(plugin) + .use(createLocalization()) + .use(createAppearance()) + .mount(el), +}); diff --git a/tests/Fixtures/git-connections.mjs b/tests/Fixtures/git-connections.mjs new file mode 100644 index 0000000..39ec4a4 --- /dev/null +++ b/tests/Fixtures/git-connections.mjs @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import vue from '@vitejs/plugin-vue'; +import tailwind from '@tailwindcss/vite'; +import { createServer } from 'vite'; +import { createSSRApp, h } from 'vue'; +import { renderToString } from 'vue/server-renderer'; +import { createInertiaApp } from '@inertiajs/vue3'; + +const serve = process.argv.includes('--serve'); +const server = await createServer({ + configFile: false, + cacheDir: 'node_modules/.vite/git-connections-fixture', + plugins: [vue(), ...(serve ? [tailwind()] : [])], + resolve: { + alias: { + '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), + }, + }, + server: serve + ? { host: '127.0.0.1', port: 4186, strictPort: true, hmr: false } + : { middlewareMode: true, hmr: false, watch: null }, + appType: 'custom', +}); +const connection = { + id: 1, + user_id: 1, + provider: 'gitea', + provider_label: 'Gitea', + base_url: 'https://git.example.test', + provider_user_id: '42', + account_login: 'alex', + account_name: 'Alex Morgan', + created_at: null, + updated_at: null, +}; +function makePage(extra = {}) { + return { + component: 'GitConnections', + url: '/settings/git-connections', + version: null, + clearHistory: false, + encryptHistory: false, + props: { + name: 'KanbanGit', + errors: {}, + sidebarOpen: true, + appearance: 'system', + status: null, + gitConnections: [connection], + auth: { + user: { + id: 1, + name: 'Alex Morgan', + email: 'alex@example.test', + avatar: null, + avatar_color: '#10B981', + avatar_is_generated: true, + }, + permissions: { + globalAdmin: false, + projectCreate: false, + canManageTeams: false, + }, + }, + navigation: { project: null }, + ...extra, + }, + }; +} +if (serve) { + let connections = [connection]; + server.middlewares.use(async (request, response, next) => { + if (!request.url.startsWith('/settings')) { + return next(); + } + const url = new URL(request.url, 'http://localhost'); + const mutation = request.method !== 'GET'; + const result = + request.headers.cookie?.match(/git_test_result=(\w+)/)?.[1] ?? + 'success'; + let extra = {}; + if (!mutation && url.searchParams.has('reset')) { + connections = [connection]; + } + if (mutation && ['server', 'forbidden'].includes(result)) { + response.statusCode = result === 'forbidden' ? 403 : 503; + response.end('Simulated request failure'); + return; + } + if (mutation) { + let body = ''; + for await (const chunk of request) { + body += chunk; + } + const data = body ? JSON.parse(body) : {}; + if (result === 'validation') { + extra.errors = { + api_token: + 'Gitea could not authenticate with this base URL and token.', + }; + } else if (request.method === 'DELETE') { + connections = connections.filter( + (item) => + item.id !== Number(url.pathname.split('/').at(-1)), + ); + extra.status = 'alex @ https://git.example.test was removed.'; + } else if (request.method === 'PATCH') { + connections = connections.map((item) => + item.id === Number(url.pathname.split('/').at(-1)) + ? { ...item, base_url: data.base_url } + : item, + ); + extra.status = 'Git connection was updated.'; + } else { + connections.push({ + ...connection, + id: 2, + account_login: 'new-account', + base_url: data.base_url, + }); + extra.status = 'Git connection was added.'; + } + } + const page = makePage({ + gitConnections: url.searchParams.has('empty') ? [] : connections, + ...extra, + }); + response.setHeader('Vary', 'X-Inertia'); + if (request.headers['x-inertia']) { + response.setHeader('X-Inertia', 'true'); + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(page)); + return; + } + const escaped = JSON.stringify(page) + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<'); + response.setHeader('Content-Type', 'text/html'); + response.end( + await server.transformIndexHtml( + request.url, + '
', + ), + ); + }); + await server.listen(); + console.log('Git connections fixture ready.'); +} else { + try { + const { createLocalization } = await server.ssrLoadModule( + '/resources/js/lib/localization.ts', + ); + const { createAppearance } = await server.ssrLoadModule( + '/resources/js/composables/useAppearance.ts', + ); + const { en } = await server.ssrLoadModule( + '/resources/js/locales/en.ts', + ); + const component = ( + await server.ssrLoadModule( + '/resources/js/pages/settings/GitConnections.vue', + ) + ).default; + const render = async (extra = {}, locale = createLocalization()) => + ( + await createInertiaApp({ + page: makePage(extra), + resolve: () => component, + render: renderToString, + setup: ({ App, props, plugin }) => + createSSRApp({ render: () => h(App, props) }) + .use(plugin) + .use(locale) + .use(createAppearance()), + }) + ).body; + const html = await render(); + assert.match(html, /Gitea/); + assert.match(html, /https:\/\/git.example.test/); + assert.match(html, /Edit alex at https:\/\/git.example.test/); + assert.doesNotMatch(html, /name="api_token"/); + assert.match( + await render({ gitConnections: [] }), + /No Git connections yet/, + ); + assert.match( + await render( + {}, + createLocalization( + { locale: 'de' }, + { + de: { + ...en, + 'gitConnections.add': 'Verbindung hinzufügen', + }, + }, + ), + ), + /Verbindung hinzufügen/, + ); + console.log('Git connections rendering checks passed.'); + } finally { + await server.close(); + } +}