diff --git a/.gitignore b/.gitignore index 40932ff86..cb03e2fc3 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,10 @@ cx # Public regression coverage for the locale runtime ships with the implementation. !frontend/src/lib/stores/i18n-utils.spec.js +!frontend/src/lib/api/integrations.spec.js +!frontend/src/lib/features/items/zammadPanelContext.spec.js +!frontend/src/lib/features/items/ZammadLinkResolver.spec.js +!internal/repository/zammad_repository_test.go # Output of the go coverage tool, specifically when used with LiteIDE *.out diff --git a/docs/zammad-integration.md b/docs/zammad-integration.md new file mode 100644 index 000000000..6108fdea6 --- /dev/null +++ b/docs/zammad-integration.md @@ -0,0 +1,121 @@ +# Zammad integration + +Windshift can create a Zammad ticket from an item and keep the remote ticket state visible on the item. +An administrator owns each connection and explicitly grants it to selected workspaces or to all workspaces. + +## API and authentication + +The routes in this integration use the application API prefix `/api` and the same authenticated session or bearer-token authentication as the rest of the application. +Administration routes require a system administrator. +Workspace and item routes require the caller to have view or edit permission for the referenced workspace or item. + +Admin connection endpoints are `GET|POST /api/admin/zammad-connections`, `GET|PUT|DELETE /api/admin/zammad-connections/{id}`, and `POST /api/admin/zammad-connections/{id}/test`. +System-owned OAuth starts through the shared provider endpoint `POST /api/admin/integration-providers/{id}/oauth/start`. +They list, create, inspect, update, delete, test, and authorize connections without returning any token or client secret. +The OAuth callback uses the shared system-provider route `GET /api/integrations/oauth/system/zammad/callback`. +The explicit administrator retry is `POST /api/admin/zammad-ticket-links/{linkId}/retry-create`. +The administrator-only disaster-recovery action is `POST /api/admin/zammad-ticket-links/{linkId}/detach-local`. +`POST /api/admin/zammad-ticket-links/refresh` queues an asynchronous synchronization of every complete ticket link on enabled, authorized connections. +Concurrent system-wide refresh requests are coalesced into the already queued or running job. + +Workspace and item routes are `GET /api/workspaces/{workspaceId}/zammad-connections`, `GET /api/workspaces/{workspaceId}/zammad-connections/{id}/metadata`, and `GET /api/workspaces/{workspaceId}/zammad-connections/{id}/owners?group_id={groupId}`. +Ticket routes are `GET /api/items/{id}/zammad-links`, `GET /api/zammad-ticket-links/resolve/{correlationKey}`, `POST /api/items/{id}/zammad-tickets`, `POST /api/items/{id}/zammad-ticket-links`, `PUT /api/zammad-ticket-links/{linkId}`, `DELETE /api/zammad-ticket-links/{linkId}`, and `POST /api/zammad-ticket-links/{linkId}/refresh`. + +Create-ticket requests contain `connection_id` and may contain `group_id`. +Link-existing requests contain `connection_id` and `ticket_number`. +Link updates accept optional `state_id`, `group_id`, and `owner_id`, with at least one required. +The create and link responses contain the link ID, ticket identity and URL when known, group and owner snapshot, synchronization state, last remote status, timestamps, and a sanitized error when applicable. + +## OAuth callback setup + +Register this exact public callback URL in the Zammad OAuth application, replacing only the hostname with the public HTTPS hostname of the Windshift installation: + +`https:///api/integrations/oauth/system/zammad/callback` + +Do not append a slash, query string, or fragment. +The URL configured in Zammad must exactly match the URL generated by Windshift, including any configured public path prefix. +Create the connection with `auth_method: "oauth"`, the OAuth client ID, client secret, and a Zammad base URL without `/api/v1`. +Start OAuth from the admin endpoint and complete authorization in Zammad. +The callback intentionally does not require a session because its one-time, short-lived state is bound to the initiating administrator and atomically consumed. +Access and refresh tokens are stored encrypted and never returned by the API. + +If the callback fails, the administration UI receives an error result and the connection remains unavailable until authorization succeeds. +Changing OAuth client configuration invalidates an in-flight attempt. + +## Zammad service account and permissions + +Use a dedicated active Zammad user with the `ticket.agent` role for the integration, rather than a human administrator account. +Give it `read`, `create`, and `change` group access only for the allowed group(s). +This lets Windshift read the required ticket metadata and owners, create tickets, and change state, group, and owner within that boundary. +Do not grant administration, user-management, group-management, delete-ticket, or unrestricted access to other groups. +Zammad role and group permissions must enforce the same boundary as `allowed_groups` in the Windshift connection. +The default group must be active and included in that allowed set. +Store every allowed group as an ID and name pair, for example `{"id":7,"name":"Support"}`. +Windshift persists this small catalog and does not call Zammad's administrator-only `GET /api/v1/groups` endpoint during ticket creation, linking, updating, or synchronization. +The legacy `allowed_group_ids` request field is accepted only as an upgrade bridge; new clients should send `allowed_groups`. +Because the least-privilege service account cannot list all groups, connection tests report the persisted group catalog as unverified and validate only the ticket-state endpoint used at runtime. +During an upgrade, all legacy group IDs remain authorized for existing links, but only a group whose stored name is known can be selected for new ticket creation. +An administrator must confirm and store the `ID:name` pair before creating tickets in a legacy non-default group. + +Create an active text ticket object attribute named `windshift_item_key`, or configure another valid correlation field name. +Windshift writes a deterministic correlation key to this field and uses it to find an already-created ticket safely. +The field is the idempotency boundary for create retries and must be readable by the service agent. + +For a permission-checked return link from Zammad to the current Windshift item, configure this link template on the same attribute: + +```text +https://windshift.example/zammad/#{ticket.windshift_item_key} +``` + +Replace the origin and optional path prefix with the browser-visible Windshift base URL. +If the connection uses a different correlation field, replace `windshift_item_key` in the placeholder too. +The resolver accepts the correlation value whether Zammad leaves it literal or percent-encodes it in the generated URL. +The target keeps working after an item moves because Windshift resolves the stored correlation key to the current workspace and item. +Opening the link requires a Windshift session and `item.view` permission; missing and unauthorized targets are both reported as unavailable. + +## Create, link, update, and unlink behavior + +Creating a ticket first persists a pending link and searches Zammad by the correlation key. +If the ticket already exists, Windshift links it instead of creating a duplicate. +The item title and description are sent to Zammad, with the initial article created as an internal note. +Ticket titles are limited to 200 characters and descriptions to 20,000 characters before transmission. +After a successful create, the link becomes `linked` and the remote ticket snapshot is stored. + +If a create request times out, returns a rate-limit or server error, or its response is otherwise ambiguous, the link becomes `creation_uncertain`. +Automatic retries are search-only and never issue another create request while the outcome is unknown. +An administrator must verify in Zammad whether the ticket exists and call the admin retry endpoint explicitly only if it does not. +That retry searches by correlation key before permitting a new create. + +Linking an existing ticket verifies that it exists, belongs to a configured allowed group, and is not already associated with another correlation key. +Updating a link sends only the requested state, group, and owner changes to Zammad. +The selected group must be in the persisted allowlist, the selected state must be active, and the selected owner must be able to change tickets in the resulting group. +Changing the group without selecting an owner resets the ticket to Zammad's unassigned owner. +Unlinking clears the remote correlation field only when it still equals this Windshift link's exact key, removes the local association and visible item link, and never deletes the remote ticket. +If the remote result is ambiguous, the local link remains so the operation can be retried safely. +Deleting a connection is rejected while ticket links still exist, so each link must be safely unlinked before its correlation context can be removed. + +If Zammad is permanently unavailable or its credentials can no longer be recovered, a system administrator can use the explicit `detach-local` action. +It removes the typed Zammad link and the generic item link without contacting Zammad, records a distinct audit event, and allows the Windshift item or connection to be removed afterward. +This is a recovery operation, not a normal unlink: the correlation value remains on the remote ticket and must be cleared manually in Zammad before that ticket is linked again. + +## Refresh, backoff, and recovery + +`POST /api/zammad-ticket-links/{linkId}/refresh` performs an immediate remote read and updates the stored snapshot. +A background scheduler also polls linked tickets approximately every two minutes after the previous run completes. +Successful refreshes and persisted synchronization failures publish an item event so an open item panel reloads its Zammad snapshot without a page refresh. +Failed synchronization records a sanitized error and schedules another attempt after a retry delay. +Fair ordering by the most recent attempt prevents repeatedly failing links from starving healthy links. +Remote response bodies and credentials are never exposed to clients. +If a ticket is moved outside the connection's persisted allowed groups, synchronization records an error and does not update the local snapshot or apply a completion transition. + +An OAuth `invalid_grant` response while refreshing an expired access token marks the connection as requiring reauthorization. +The connection is not ready while `reauthorization_required` is true. +Start OAuth again from the admin endpoint after correcting the Zammad OAuth application or grant. +For API-token connections, rotate the token through the admin update endpoint and rerun the connection test. + +Disabling a connection stops scheduled synchronization. +A missing or unauthorized remote ticket does not mutate the Windshift item. +Optional closed-state mapping can transition the Windshift item through its normal workflow, subject to the original administrator's item-edit permission. + +The outbound client requires HTTPS, rejects credentials/query/fragment components in the base URL, enforces the shared public-address SSRF policy, checks redirects, limits responses, and applies a 30-second timeout. +The integration does not synchronize attachments, comments, or arbitrary ticket fields. diff --git a/frontend/src/lib/api/index.js b/frontend/src/lib/api/index.js index 9ce5f9e7f..854f89b68 100644 --- a/frontend/src/lib/api/index.js +++ b/frontend/src/lib/api/index.js @@ -57,6 +57,8 @@ import { itemIntegrationLinks, todoistSync, userIntegrations, + zammadConnections, + zammadTickets, } from './integrations.js'; // Domain imports import { items } from './items.js'; @@ -409,6 +411,10 @@ export const api = { // Item Integration Links itemIntegrationLinks, + // Zammad ticket integration + zammadConnections, + zammadTickets, + // OAuth Clients (admin only) — third-party apps registered against the // generic OAuth 2.0 server (/api/oauth/authorize + /api/oauth/token) oauthClients, diff --git a/frontend/src/lib/api/integrations.js b/frontend/src/lib/api/integrations.js index 9dd1192a9..d03f9ee68 100644 --- a/frontend/src/lib/api/integrations.js +++ b/frontend/src/lib/api/integrations.js @@ -49,3 +49,51 @@ export const itemIntegrationLinks = { `/items/${itemId}/integration-search?q=${encodeURIComponent(query)}&provider_id=${providerId}` ), }; + +// Zammad system connections and item ticket links +export const zammadConnections = { + getAll: () => fetchAPI('/admin/zammad-connections'), + get: (id) => fetchAPI(`/admin/zammad-connections/${id}`), + create: (data) => + fetchAPI('/admin/zammad-connections', { method: 'POST', body: JSON.stringify(data) }), + update: (id, data) => + fetchAPI(`/admin/zammad-connections/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + delete: (id) => fetchAPI(`/admin/zammad-connections/${id}`, { method: 'DELETE' }), + test: (id) => fetchAPI(`/admin/zammad-connections/${id}/test`, { method: 'POST' }), + refreshAllTickets: () => fetchAPI('/admin/zammad-ticket-links/refresh', { method: 'POST' }), + startOAuth: (id) => + fetchAPI(`/admin/integration-providers/${id}/oauth/start`, { method: 'POST' }), + forWorkspace: (workspaceId) => fetchAPI(`/workspaces/${workspaceId}/zammad-connections`), + metadata: (workspaceId, id) => + fetchAPI(`/workspaces/${workspaceId}/zammad-connections/${id}/metadata`), + owners: (workspaceId, id, groupId) => + fetchAPI( + `/workspaces/${workspaceId}/zammad-connections/${id}/owners?group_id=${encodeURIComponent(groupId)}` + ), +}; + +export const zammadTickets = { + resolve: (correlationKey) => + fetchAPI(`/zammad-ticket-links/resolve/${encodeURIComponent(correlationKey)}`), + forItem: (itemId) => fetchAPI(`/items/${itemId}/zammad-links`), + create: (itemId, data) => + fetchAPI(`/items/${itemId}/zammad-tickets`, { + method: 'POST', + body: JSON.stringify(data), + }), + link: (itemId, data) => + fetchAPI(`/items/${itemId}/zammad-ticket-links`, { + method: 'POST', + body: JSON.stringify(data), + }), + update: (linkId, data) => + fetchAPI(`/zammad-ticket-links/${linkId}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + delete: (linkId) => fetchAPI(`/zammad-ticket-links/${linkId}`, { method: 'DELETE' }), + refresh: (linkId) => fetchAPI(`/zammad-ticket-links/${linkId}/refresh`, { method: 'POST' }), +}; diff --git a/frontend/src/lib/api/integrations.spec.js b/frontend/src/lib/api/integrations.spec.js new file mode 100644 index 000000000..4004432bd --- /dev/null +++ b/frontend/src/lib/api/integrations.spec.js @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { zammadConnections, zammadTickets } from './integrations.js'; + +describe('zammadConnections.startOAuth', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('starts the system connection OAuth flow with an empty POST body', async () => { + const authURL = 'https://zammad.example.test/oauth/authorize?state=state-value'; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ auth_url: authURL }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(zammadConnections.startOAuth('connection-id')).resolves.toEqual({ + auth_url: authURL, + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/admin/integration-providers/connection-id/oauth/start'); + expect(options.method).toBe('POST'); + expect(options.body).toBeUndefined(); + }); +}); + +describe('zammadConnections.refreshAllTickets', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('starts a system-wide ticket refresh', async () => { + const result = { started: true }; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(result), { + status: 202, + headers: { 'content-type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(zammadConnections.refreshAllTickets()).resolves.toEqual(result); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/admin/zammad-ticket-links/refresh'); + expect(options.method).toBe('POST'); + expect(options.body).toBeUndefined(); + }); +}); + +describe('Zammad ticket-link API', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('resolves a Zammad correlation key to its current item', async () => { + const destination = { workspace_id: 7, item_id: 42 }; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(destination), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(zammadTickets.resolve('windshift:provider:TST-42')).resolves.toEqual(destination); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][0]).toBe( + '/api/zammad-ticket-links/resolve/windshift%3Aprovider%3ATST-42' + ); + }); + + it('loads assignable owners for the selected group', async () => { + const owners = [ + { id: 1, name: 'Not assigned' }, + { id: 7, name: 'Ada Lovelace' }, + ]; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(owners), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(zammadConnections.owners(23, 'connection-id', 42)).resolves.toEqual(owners); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][0]).toBe( + '/api/workspaces/23/zammad-connections/connection-id/owners?group_id=42' + ); + }); + + it('links an existing ticket by number', async () => { + const response = { id: 'link-id', ticket_number: '12345', sync_state: 'linked' }; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(response), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await expect( + zammadTickets.link(99, { connection_id: 'connection-id', ticket_number: '12345' }) + ).resolves.toEqual(response); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/items/99/zammad-ticket-links'); + expect(options).toMatchObject({ + method: 'POST', + body: JSON.stringify({ connection_id: 'connection-id', ticket_number: '12345' }), + }); + }); + + it('updates only the selected ticket fields', async () => { + const response = { id: 'link-id', group_id: 42, owner_id: 7 }; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(response), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(zammadTickets.update('link-id', { group_id: 42, owner_id: 7 })).resolves.toEqual( + response + ); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/zammad-ticket-links/link-id'); + expect(options).toMatchObject({ + method: 'PUT', + body: JSON.stringify({ group_id: 42, owner_id: 7 }), + }); + }); + + it('removes only the Zammad ticket assignment', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetchMock); + + await expect(zammadTickets.delete('link-id')).resolves.toBeNull(); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/zammad-ticket-links/link-id'); + expect(options.method).toBe('DELETE'); + }); +}); diff --git a/frontend/src/lib/composables/useItemEventStream.svelte.js b/frontend/src/lib/composables/useItemEventStream.svelte.js index 0e02f86ec..37582b2ba 100644 --- a/frontend/src/lib/composables/useItemEventStream.svelte.js +++ b/frontend/src/lib/composables/useItemEventStream.svelte.js @@ -9,7 +9,7 @@ const DEBOUNCE_MS = 250; * initial load. Polling remains the fallback while disconnected. * * @param {() => (number|string|null|undefined)} getItemId - * @param {{ onReconcile?: Function, onItem?: Function, onChildren?: Function, onComment?: Function, onLinks?: Function, onDeleted?: Function }} handlers + * @param {{ onReconcile?: Function, onItem?: Function, onChildren?: Function, onComment?: Function, onLinks?: Function, onZammad?: Function, onDeleted?: Function }} handlers * @returns {{ readonly connected: boolean }} */ export function useItemEventStream(getItemId, handlers = {}) { @@ -37,6 +37,7 @@ export function useItemEventStream(getItemId, handlers = {}) { if (kinds.has('children')) handlers.onChildren?.(); if (kinds.has('comment')) handlers.onComment?.(); if (kinds.has('links')) handlers.onLinks?.(); + if (kinds.has('zammad')) handlers.onZammad?.(); if (kinds.has('deleted')) handlers.onDeleted?.(); }; const schedule = (...kinds) => { @@ -69,6 +70,7 @@ export function useItemEventStream(getItemId, handlers = {}) { handlers.onDeleted?.(); }); es.addEventListener('link', () => schedule('links')); + es.addEventListener('zammad', () => schedule('zammad')); // The browser auto-reconnects (honoring the server's retry hint). Until it // does, mark disconnected so the components' pollers resume as the fallback. es.onerror = () => { diff --git a/frontend/src/lib/features/items/ItemDetail.svelte b/frontend/src/lib/features/items/ItemDetail.svelte index ecf991b97..9cce1e993 100644 --- a/frontend/src/lib/features/items/ItemDetail.svelte +++ b/frontend/src/lib/features/items/ItemDetail.svelte @@ -86,6 +86,7 @@ import NativeSelect from '../../components/NativeSelect.svelte'; } window.dispatchEvent(new CustomEvent('item-comments-changed', { detail: { itemId } })); window.dispatchEvent(new CustomEvent('item-scm-links-changed', { detail: { itemId } })); + window.dispatchEvent(new CustomEvent('item-zammad-links-changed', { detail: { itemId } })); }, onItem: () => itemDetailStore.refreshCurrentItem().catch((err) => console.error('SSE item refresh failed:', err)), onChildren: () => itemDetailStore.loadChildItems().catch((err) => console.error('SSE children refresh failed:', err)), @@ -96,6 +97,7 @@ import NativeSelect from '../../components/NativeSelect.svelte'; itemDetailStore.loadLinks().catch((err) => console.error('SSE links refresh failed:', err)); window.dispatchEvent(new CustomEvent('item-scm-links-changed', { detail: { itemId } })); }, + onZammad: () => window.dispatchEvent(new CustomEvent('item-zammad-links-changed', { detail: { itemId } })), // The viewed item was deleted (its own topic published `deleted`). This is // authoritative — mark it gone so the view closes instead of refetching // (which would 404) and showing stale data. diff --git a/frontend/src/lib/features/items/ItemDetailSidebar.svelte b/frontend/src/lib/features/items/ItemDetailSidebar.svelte index 58c3d6031..8afa9076a 100644 --- a/frontend/src/lib/features/items/ItemDetailSidebar.svelte +++ b/frontend/src/lib/features/items/ItemDetailSidebar.svelte @@ -19,6 +19,7 @@ import { errorToast } from '../../stores/toasts.svelte.js'; import ItemSCMLinks from './ItemSCMLinks.svelte'; import ItemIntegrationLinks from './ItemIntegrationLinks.svelte'; + import ZammadItemPanel from './ZammadItemPanel.svelte'; import AddSCMLinkModal from '../../dialogs/AddSCMLinkModal.svelte'; import AddIntegrationLinkModal from '../../dialogs/AddIntegrationLinkModal.svelte'; import CreateBranchModal from '../../dialogs/CreateBranchModal.svelte'; @@ -1501,6 +1502,10 @@ {/if} + {#if item?.id && item?.workspace_id} + + {/if} + {#if item?.id} + import { useEventListener } from 'runed'; + import { + TicketCheck, + Plus, + RefreshCw, + Loader2, + ExternalLink, + AlertTriangle, + Edit2, + Trash2, + } from '@lucide/svelte'; + import { api } from '../../api.js'; + import Button from '../../components/Button.svelte'; + import Text from '../../components/Text.svelte'; + import Modal from '../../dialogs/Modal.svelte'; + import ModalHeader from '../../dialogs/ModalHeader.svelte'; + import NativeSelect from '../../components/NativeSelect.svelte'; + import FormField from '../../components/FormField.svelte'; + import Input from '../../components/Input.svelte'; + import { t } from '../../stores/i18n.svelte.js'; + import { successToast, errorToast } from '../../stores/toasts.svelte.js'; + import { confirm } from '../../composables/useConfirm.js'; + import { safeHref } from '../../utils/sanitize'; + import { + isCurrentZammadMetadataRequest, + isCurrentZammadPanelContext, + isUsableZammadGroup, + } from './zammadPanelContext.js'; + + let { itemId, workspaceId, canEdit = false } = $props(); + + let connections = $state([]); + let links = $state([]); + let metadata = $state({ groups: [], states: [] }); + let editMetadata = $state({ groups: [], states: [] }); + let editOwners = $state([]); + let loading = $state(true); + let loadingMetadata = $state(false); + let loadingEditMetadata = $state(false); + let loadingEditOwners = $state(false); + let creating = $state(false); + let linking = $state(false); + let savingEdit = $state(false); + let refreshingId = $state(null); + let removingId = $state(null); + let showCreate = $state(false); + let showEdit = $state(false); + let dialogMode = $state('create'); + let selectedConnectionId = $state(''); + let selectedGroupId = $state(''); + let ticketNumber = $state(''); + let editingLink = $state(null); + let selectedEditStateId = $state(''); + let selectedEditGroupId = $state(''); + let selectedEditOwnerId = $state('1'); + let initialEdit = $state({ stateId: '', groupId: '', ownerId: '1' }); + let error = $state(''); + let formError = $state(''); + let editError = $state(''); + let loadVersion = 0; + let contextVersion = 0; + let metadataVersion = 0; + let editVersion = 0; + let ownersVersion = 0; + + let usableConnections = $derived(connections.filter(isConnectionUsable)); + let unavailableConnections = $derived(connections.filter((connection) => !isConnectionUsable(connection))); + let editGroups = $derived(editMetadata.groups.filter(isUsableZammadGroup)); + let editStates = $derived(editMetadata.states.filter((state) => state.active !== false)); + let editOwnerOptions = $derived([ + { value: '1', label: t('zammad.unassignedOwner') }, + ...editOwners + .filter((owner) => Number(owner.id) !== 1) + .map((owner) => ({ value: String(owner.id), label: owner.name })), + ]); + let editPayloadAvailable = $derived(Object.keys(editPayload()).length > 0); + + $effect(() => { + const currentItemId = itemId; + const currentWorkspaceId = workspaceId; + const currentVersion = ++contextVersion; + resetContext(); + void load(currentItemId, currentWorkspaceId, currentVersion); + }); + + useEventListener(() => window, 'item-zammad-links-changed', (/** @type {CustomEvent<{itemId?: number|string}>} */ event) => { + const id = event?.detail?.itemId; + if (id == null || String(id) !== String(itemId)) return; + void load(itemId, workspaceId, contextVersion); + }); + + function isCurrentContext(version, currentItemId = itemId, currentWorkspaceId = workspaceId) { + return isCurrentZammadPanelContext( + version, + contextVersion, + currentItemId, + itemId, + currentWorkspaceId, + workspaceId, + ); + } + + function resetContext() { + loadVersion += 1; + metadataVersion += 1; + editVersion += 1; + ownersVersion += 1; + connections = []; + links = []; + metadata = { groups: [], states: [] }; + editMetadata = { groups: [], states: [] }; + editOwners = []; + loading = Boolean(itemId && workspaceId); + loadingMetadata = false; + loadingEditMetadata = false; + loadingEditOwners = false; + creating = false; + linking = false; + savingEdit = false; + refreshingId = null; + removingId = null; + showCreate = false; + showEdit = false; + dialogMode = 'create'; + selectedConnectionId = ''; + selectedGroupId = ''; + ticketNumber = ''; + editingLink = null; + selectedEditStateId = ''; + selectedEditGroupId = ''; + selectedEditOwnerId = '1'; + initialEdit = { stateId: '', groupId: '', ownerId: '1' }; + error = ''; + formError = ''; + editError = ''; + } + + function isConnectionUsable(connection) { + if (connection.ready === false || connection.reauthorization_required === true) return false; + return connection.auth_method !== 'oauth' || connection.oauth_connected === true; + } + + function usableGroups(connection, loadedMetadata) { + const allowedIds = (connection?.allowed_groups || []).map((group) => group.id); + return loadedMetadata.groups.filter((group) => { + if (!isUsableZammadGroup(group)) return false; + if (allowedIds.length > 0) return allowedIds.includes(group.id); + return group.id === connection?.default_group_id || group.name === connection?.default_group_name; + }); + } + + function selectedConnection() { + return usableConnections.find((connection) => connection.id === selectedConnectionId); + } + + function replaceLink(updated) { + links = [updated, ...links.filter((entry) => entry.id !== updated.id)]; + } + + function selectedEditConnection() { + return usableConnections.find((connection) => connection.id === editingLink?.connection_id); + } + + function syncActionHint(link) { + if (link.ticket_id) return ''; + if (link.sync_state === 'sync_failed') return t('zammad.syncFailedNoTicket'); + if (link.sync_state === 'creation_uncertain') return t('zammad.creationUncertainNoTicket'); + return t('zammad.ticketCreationInProgress'); + } + + async function load(currentItemId = itemId, currentWorkspaceId = workspaceId, version = contextVersion) { + const currentVersion = ++loadVersion; + if (!currentItemId || !currentWorkspaceId) { + connections = []; + links = []; + loading = false; + error = ''; + return; + } + + loading = true; + error = ''; + try { + const [loadedConnections, loadedLinks] = await Promise.all([ + api.zammadConnections.forWorkspace(currentWorkspaceId), + api.zammadTickets.forItem(currentItemId), + ]); + if (currentVersion !== loadVersion || !isCurrentContext(version, currentItemId, currentWorkspaceId)) return; + connections = loadedConnections; + links = loadedLinks; + } catch (err) { + if (currentVersion !== loadVersion || !isCurrentContext(version, currentItemId, currentWorkspaceId)) return; + console.error('Failed to load Zammad links:', err); + error = t('zammad.loadLinksFailed'); + } finally { + if (currentVersion === loadVersion && isCurrentContext(version, currentItemId, currentWorkspaceId)) loading = false; + } + } + + async function openCreateDialog() { + dialogMode = 'create'; + formError = ''; + ticketNumber = ''; + selectedConnectionId = usableConnections[0]?.id || ''; + selectedGroupId = ''; + showCreate = true; + await loadMetadata(contextVersion); + } + + function openLinkDialog() { + metadataVersion += 1; + loadingMetadata = false; + metadata = { groups: [], states: [] }; + dialogMode = 'link'; + formError = ''; + ticketNumber = ''; + selectedConnectionId = usableConnections[0]?.id || ''; + selectedGroupId = ''; + showCreate = true; + } + + function closeCreateDialog() { + if (creating || linking) return; + metadataVersion += 1; + loadingMetadata = false; + showCreate = false; + formError = ''; + } + + async function handleCreateConnectionChange() { + formError = ''; + if (dialogMode === 'create') await loadMetadata(contextVersion); + } + + async function loadMetadata(version = contextVersion) { + const requestVersion = ++metadataVersion; + const connectionId = selectedConnectionId; + const connection = selectedConnection(); + if (!connection) return; + const isCurrentRequest = () => + isCurrentContext(version) && + isCurrentZammadMetadataRequest( + requestVersion, + metadataVersion, + connectionId, + selectedConnectionId, + showCreate, + dialogMode, + ); + loadingMetadata = true; + try { + const loadedMetadata = await api.zammadConnections.metadata(workspaceId, connection.id); + if (!isCurrentRequest()) return; + const groups = usableGroups(connection, loadedMetadata); + metadata = { ...loadedMetadata, groups }; + const defaultGroup = metadata.groups.find( + (group) => group.id === connection.default_group_id || group.name === connection.default_group_name, + ); + selectedGroupId = String(defaultGroup?.id || metadata.groups[0]?.id || ''); + } catch (err) { + if (!isCurrentRequest()) return; + console.error('Failed to load Zammad metadata:', err); + formError = t('zammad.loadMetadataFailed'); + metadata = { groups: [], states: [] }; + selectedGroupId = ''; + } finally { + if (isCurrentRequest()) { + loadingMetadata = false; + } + } + } + + async function createTicket() { + const version = contextVersion; + const currentItemId = itemId; + const group = metadata.groups.find((entry) => entry.id === Number(selectedGroupId)); + if (!selectedConnectionId || !group) return; + creating = true; + formError = ''; + try { + const link = await api.zammadTickets.create(currentItemId, { + connection_id: selectedConnectionId, + group_id: group.id, + }); + if (!isCurrentContext(version, currentItemId)) return; + replaceLink(link); + showCreate = false; + successToast(link.sync_state === 'linked' ? t('zammad.ticketCreated') : t('zammad.ticketCreationStarted')); + } catch (err) { + if (!isCurrentContext(version, currentItemId)) return; + console.error('Failed to create Zammad ticket:', err); + formError = err.message || t('zammad.ticketCreateFailed'); + errorToast(t('zammad.ticketCreateFailed')); + await load(currentItemId, workspaceId, version); + } finally { + if (isCurrentContext(version, currentItemId)) creating = false; + } + } + + async function linkExistingTicket() { + const version = contextVersion; + const currentItemId = itemId; + const trimmedTicketNumber = ticketNumber.trim(); + if (!selectedConnectionId || !trimmedTicketNumber) return; + linking = true; + formError = ''; + try { + const link = await api.zammadTickets.link(currentItemId, { + connection_id: selectedConnectionId, + ticket_number: trimmedTicketNumber, + }); + if (!isCurrentContext(version, currentItemId)) return; + replaceLink(link); + showCreate = false; + successToast(t('zammad.ticketLinked')); + } catch (err) { + if (!isCurrentContext(version, currentItemId)) return; + console.error('Failed to link existing Zammad ticket:', err); + formError = err.message || t('zammad.ticketLinkFailed'); + errorToast(t('zammad.ticketLinkFailed')); + } finally { + if (isCurrentContext(version, currentItemId)) linking = false; + } + } + + async function refresh(link) { + if (!link.ticket_id) return; + const version = contextVersion; + const currentItemId = itemId; + refreshingId = link.id; + try { + const updated = await api.zammadTickets.refresh(link.id); + if (!isCurrentContext(version, currentItemId)) return; + replaceLink(updated); + successToast(t('zammad.ticketRefreshed')); + } catch (err) { + if (!isCurrentContext(version, currentItemId)) return; + console.error('Failed to refresh Zammad ticket:', err); + errorToast(t('zammad.ticketRefreshFailed')); + await load(currentItemId, workspaceId, version); + } finally { + if (isCurrentContext(version, currentItemId)) refreshingId = null; + } + } + + async function openEditDialog(link) { + const version = ++editVersion; + const currentItemId = itemId; + const currentWorkspaceId = workspaceId; + const connection = usableConnections.find((entry) => entry.id === link.connection_id); + if (!connection) { + errorToast(t('zammad.connectionUnavailable')); + return; + } + + editingLink = link; + editError = ''; + editMetadata = { groups: [], states: [] }; + editOwners = []; + selectedEditStateId = String(link.last_status_id || ''); + selectedEditGroupId = String(link.group_id || connection.default_group_id || ''); + selectedEditOwnerId = String(link.owner_id || 1); + showEdit = true; + loadingEditMetadata = true; + try { + const loadedMetadata = await api.zammadConnections.metadata(currentWorkspaceId, connection.id); + if (version !== editVersion || !isCurrentContext(contextVersion, currentItemId, currentWorkspaceId) || !showEdit || editingLink?.id !== link.id) return; + editMetadata = { ...loadedMetadata, groups: usableGroups(connection, loadedMetadata) }; + if (!editGroups.some((group) => String(group.id) === selectedEditGroupId)) { + selectedEditGroupId = String(editGroups[0]?.id || ''); + } + await loadEditOwners(); + if (version !== editVersion || !isCurrentContext(contextVersion, currentItemId, currentWorkspaceId) || !showEdit || editingLink?.id !== link.id) return; + initialEdit = { + stateId: selectedEditStateId, + groupId: selectedEditGroupId, + ownerId: selectedEditOwnerId, + }; + } catch (err) { + if (version !== editVersion || !isCurrentContext(contextVersion, currentItemId, currentWorkspaceId)) return; + console.error('Failed to load Zammad ticket metadata:', err); + editError = t('zammad.loadMetadataFailed'); + } finally { + if (version === editVersion && isCurrentContext(contextVersion, currentItemId, currentWorkspaceId)) loadingEditMetadata = false; + } + } + + function closeEditDialog() { + if (savingEdit) return; + showEdit = false; + editError = ''; + editingLink = null; + } + + async function loadEditOwners() { + const version = editVersion; + const requestVersion = ++ownersVersion; + const currentItemId = itemId; + const currentWorkspaceId = workspaceId; + const connection = selectedEditConnection(); + if (!connection || !selectedEditGroupId) return; + loadingEditOwners = true; + try { + const loadedOwners = await api.zammadConnections.owners( + currentWorkspaceId, + connection.id, + Number(selectedEditGroupId), + ); + if (requestVersion !== ownersVersion || version !== editVersion || !isCurrentContext(contextVersion, currentItemId, currentWorkspaceId) || !showEdit) return; + editOwners = loadedOwners; + if (!editOwners.some((owner) => String(owner.id) === selectedEditOwnerId)) { + selectedEditOwnerId = '1'; + } + } catch (err) { + if (requestVersion !== ownersVersion || version !== editVersion || !isCurrentContext(contextVersion, currentItemId, currentWorkspaceId)) return; + console.error('Failed to load Zammad owners:', err); + editError = t('zammad.loadOwnersFailed'); + editOwners = []; + selectedEditOwnerId = '1'; + } finally { + if (requestVersion === ownersVersion && version === editVersion && isCurrentContext(contextVersion, currentItemId, currentWorkspaceId)) loadingEditOwners = false; + } + } + + async function changeEditGroup() { + editError = ''; + selectedEditOwnerId = '1'; + await loadEditOwners(); + } + + function editPayload() { + const payload = {}; + if (selectedEditStateId && selectedEditStateId !== initialEdit.stateId) { + payload.state_id = Number(selectedEditStateId); + } + if (selectedEditGroupId && selectedEditGroupId !== initialEdit.groupId) { + payload.group_id = Number(selectedEditGroupId); + } + if (selectedEditOwnerId && selectedEditOwnerId !== initialEdit.ownerId) { + payload.owner_id = Number(selectedEditOwnerId); + } + return payload; + } + + async function saveEdit() { + const context = contextVersion; + const version = editVersion; + const currentItemId = itemId; + const payload = editPayload(); + if (!editingLink || Object.keys(payload).length === 0) return; + savingEdit = true; + editError = ''; + try { + const updated = await api.zammadTickets.update(editingLink.id, payload); + if (version !== editVersion || !isCurrentContext(context, currentItemId)) return; + replaceLink(updated); + showEdit = false; + successToast(t('zammad.ticketUpdated')); + } catch (err) { + if (version !== editVersion || !isCurrentContext(context, currentItemId)) return; + console.error('Failed to update Zammad ticket:', err); + editError = err.message || t('zammad.ticketUpdateFailed'); + errorToast(t('zammad.ticketUpdateFailed')); + } finally { + if (version === editVersion && isCurrentContext(context, currentItemId)) savingEdit = false; + } + } + + async function removeLink(link) { + const version = contextVersion; + const currentItemId = itemId; + const currentWorkspaceId = workspaceId; + const accepted = await confirm({ + title: t('zammad.removeTicketLink'), + message: t('zammad.removeTicketLinkConfirm', { number: link.ticket_number }), + confirmText: t('zammad.removeTicketLink'), + cancelText: t('common.cancel'), + variant: 'danger', + }); + if (!accepted) return; + if (!isCurrentContext(version, currentItemId)) return; + + removingId = link.id; + try { + await api.zammadTickets.delete(link.id); + if (!isCurrentContext(version, currentItemId, currentWorkspaceId)) return; + successToast(t('zammad.ticketLinkRemoved')); + await load(currentItemId, currentWorkspaceId, version); + } catch (err) { + if (!isCurrentContext(version, currentItemId, currentWorkspaceId)) return; + console.error('Failed to remove Zammad ticket link:', err); + errorToast(t('zammad.ticketLinkRemoveFailed')); + await load(currentItemId, currentWorkspaceId, version); + } finally { + if (isCurrentContext(version, currentItemId, currentWorkspaceId)) removingId = null; + } + } + + +{#if loading || connections.length > 0 || links.length > 0} +
+
+
+
+ + {t('zammad.tickets')} +
+ {#if usableConnections.length > 0 && canEdit} +
+ + + +
+ {/if} +
+ + {#if loading} +
+ {:else if error} +

{error}

+ {:else} + {#if unavailableConnections.length > 0} +

{t('zammad.connectionUnavailable')}

+ {/if} + {#if links.length === 0} +

{t('zammad.noTickets')}

+ {:else} +
+ {#each links as link} + {@const linkConnectionUsable = usableConnections.some((connection) => connection.id === link.connection_id)} +
+
+
+ {#if link.ticket_url} + + {t('zammad.ticketNumber', { number: link.ticket_number })} + + {:else if link.ticket_number} + {t('zammad.ticketNumber', { number: link.ticket_number })} + {:else} + {t(`zammad.syncState.${link.sync_state}`)} + {/if} +
+
{link.connection_name}
+
{t('zammad.status')}: {link.last_status_name || t('zammad.unknown')}
+
{t('zammad.group')}: {link.group_name || t('zammad.unknown')}
+
{t('zammad.owner')}: {link.owner_name || t('zammad.unassignedOwner')}
+
{link.last_synced_at ? t('zammad.lastSynced', { time: new Date(link.last_synced_at).toLocaleString() }) : t('zammad.notSynced')}
+
+
+ {#if canEdit} +
+ {#if linkConnectionUsable && link.ticket_id && link.sync_state !== 'creating'} + + + {/if} + +
+ {/if} +
+ {#if !link.ticket_id && link.sync_state !== 'linked'} +

{syncActionHint(link)}

+ {/if} + {#if link.last_error} +
+ + {link.last_error} +
+ {/if} +
+ {/each} +
+ {/if} + {/if} +
+{/if} + + + +
+

+ {dialogMode === 'create' ? t('zammad.createTicketConfirm') : t('zammad.linkExistingTicketConfirm')} +

+ {#if formError} +

{formError}

+ {/if} + + ({ value: connection.id, label: connection.name }))} + onchange={handleCreateConnectionChange} + /> + + {#if dialogMode === 'create'} + + {#if loadingMetadata} + + {:else} + ({ value: String(group.id), label: group.name }))} + disabled={metadata.groups.length === 0} + /> + {/if} + + {:else} + + + + {/if} +
+ + {#if dialogMode === 'create'} + + {:else} + + {/if} +
+
+
+ + + +
+ {#if editError} +

{editError}

+ {/if} + {#if loadingEditMetadata} +
+ {:else} + + ({ value: String(state.id), label: state.name }))} + placeholder={t('zammad.leaveUnchanged')} + disabled={savingEdit || editStates.length === 0} + /> + + + ({ value: String(group.id), label: group.name }))} + disabled={savingEdit || editGroups.length === 0} + onchange={changeEditGroup} + /> + + + {#if loadingEditOwners} + + {:else} + + {/if} + + {/if} +
+ + +
+
+
diff --git a/frontend/src/lib/features/items/ZammadLinkResolver.spec.js b/frontend/src/lib/features/items/ZammadLinkResolver.spec.js new file mode 100644 index 000000000..1b1487cac --- /dev/null +++ b/frontend/src/lib/features/items/ZammadLinkResolver.spec.js @@ -0,0 +1,40 @@ +/** @vitest-environment jsdom */ + +import '@testing-library/jest-dom/vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ZammadLinkResolver from './ZammadLinkResolver.svelte'; + +const mocks = vi.hoisted(() => ({ resolve: vi.fn(), navigate: vi.fn() })); + +vi.mock('../../api.js', () => ({ api: { zammadTickets: { resolve: mocks.resolve } } })); +vi.mock('../../router.js', () => ({ navigate: mocks.navigate })); +vi.mock('../../stores/i18n.svelte.js', () => ({ t: (key) => key })); + +describe('ZammadLinkResolver', () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(cleanup); + + it('replaces the resolver route with the current item destination', async () => { + mocks.resolve.mockResolvedValue({ workspace_id: 7, item_id: 42 }); + + render(ZammadLinkResolver, { correlationKey: 'windshift%3Aprovider%3ATST-42' }); + + await waitFor(() => + expect(mocks.navigate).toHaveBeenCalledWith('/workspaces/7/items/42', { replace: true }) + ); + expect(mocks.resolve).toHaveBeenCalledWith('windshift:provider:TST-42'); + }); + + it('keeps a retry action when the link cannot be resolved', async () => { + mocks.resolve.mockRejectedValueOnce(new Error('not found')); + mocks.resolve.mockResolvedValueOnce({ workspace_id: 7, item_id: 42 }); + + render(ZammadLinkResolver, { correlationKey: 'windshift:provider:TST-42' }); + + const retry = await screen.findByRole('button', { name: 'common.retry' }); + expect(screen.getByText('zammad.returnLinkFailed')).toBeInTheDocument(); + await fireEvent.click(retry); + await waitFor(() => expect(mocks.navigate).toHaveBeenCalledOnce()); + }); +}); diff --git a/frontend/src/lib/features/items/ZammadLinkResolver.svelte b/frontend/src/lib/features/items/ZammadLinkResolver.svelte new file mode 100644 index 000000000..d2e3bcb66 --- /dev/null +++ b/frontend/src/lib/features/items/ZammadLinkResolver.svelte @@ -0,0 +1,55 @@ + + +
+
+ {#if failed} +

{t('zammad.returnLinkFailed')}

+ + {:else} + +

{t('zammad.returnLinkResolving')}

+ {/if} +
+
diff --git a/frontend/src/lib/features/items/zammadPanelContext.js b/frontend/src/lib/features/items/zammadPanelContext.js new file mode 100644 index 000000000..b838c09b8 --- /dev/null +++ b/frontend/src/lib/features/items/zammadPanelContext.js @@ -0,0 +1,34 @@ +export function isCurrentZammadPanelContext( + requestVersion, + currentVersion, + requestItemId, + itemId, + requestWorkspaceId, + workspaceId +) { + return ( + requestVersion === currentVersion && + requestItemId === itemId && + requestWorkspaceId === workspaceId + ); +} + +export function isCurrentZammadMetadataRequest( + requestVersion, + currentVersion, + requestConnectionId, + selectedConnectionId, + showCreate, + dialogMode +) { + return ( + requestVersion === currentVersion && + requestConnectionId === selectedConnectionId && + showCreate && + dialogMode === 'create' + ); +} + +export function isUsableZammadGroup(group) { + return group?.active !== false && Boolean(group?.name?.trim()); +} diff --git a/frontend/src/lib/features/items/zammadPanelContext.spec.js b/frontend/src/lib/features/items/zammadPanelContext.spec.js new file mode 100644 index 000000000..53a614344 --- /dev/null +++ b/frontend/src/lib/features/items/zammadPanelContext.spec.js @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { + isCurrentZammadMetadataRequest, + isCurrentZammadPanelContext, + isUsableZammadGroup, +} from './zammadPanelContext.js'; + +describe('Zammad item panel context guards', () => { + it('rejects a response from the previous item after a prop change', () => { + expect( + isCurrentZammadPanelContext(1, 2, 'old-item', 'new-item', 'workspace', 'workspace') + ).toBe(false); + }); + + it('rejects a response from the previous workspace even when the item id is reused', () => { + expect( + isCurrentZammadPanelContext(3, 3, 'item', 'item', 'old-workspace', 'new-workspace') + ).toBe(false); + }); + + it('accepts a response for the current item and workspace', () => { + expect(isCurrentZammadPanelContext(4, 4, 'item', 'item', 'workspace', 'workspace')).toBe(true); + }); +}); + +describe('Zammad metadata request guards', () => { + it('rejects create metadata after switching to the link dialog', () => { + expect(isCurrentZammadMetadataRequest(1, 1, 'connection', 'connection', true, 'link')).toBe( + false + ); + }); + + it('rejects create metadata after the dialog closes', () => { + expect(isCurrentZammadMetadataRequest(2, 2, 'connection', 'connection', false, 'create')).toBe( + false + ); + }); + + it('accepts metadata only for the active create dialog and connection', () => { + expect(isCurrentZammadMetadataRequest(3, 3, 'connection', 'connection', true, 'create')).toBe( + true + ); + }); +}); + +describe('Zammad group choices', () => { + it('offers only active groups with a verified persisted name', () => { + expect(isUsableZammadGroup({ id: 2, name: 'Windshift', active: true })).toBe(true); + expect(isUsableZammadGroup({ id: 3, name: ' ', active: true })).toBe(false); + expect(isUsableZammadGroup({ id: 4, name: 'Legacy', active: false })).toBe(false); + }); +}); diff --git a/frontend/src/lib/locales/ar/index.js b/frontend/src/lib/locales/ar/index.js index 1c7c2014b..5e5d49015 100644 --- a/frontend/src/lib/locales/ar/index.js +++ b/frontend/src/lib/locales/ar/index.js @@ -23,6 +23,7 @@ import teams from './teams.js'; import supplemental from './supplemental.js'; import adminOperations from './adminOperations.js'; import dashboard from './dashboard.js'; +import zammad from './zammad.js'; export default createLocale({ common, @@ -44,4 +45,5 @@ export default createLocale({ supplemental, adminOperations, dashboard, + zammad, }); diff --git a/frontend/src/lib/locales/ar/zammad.js b/frontend/src/lib/locales/ar/zammad.js new file mode 100644 index 000000000..7f3f1c0c6 --- /dev/null +++ b/frontend/src/lib/locales/ar/zammad.js @@ -0,0 +1,24 @@ +export default { zammad: { + tab: 'Zammad', connections: 'اتصالات Zammad', connectionsDescription: 'إعداد اتصالات تذاكر Zammad آمنة ومقيّدة بمساحات العمل.', + addConnection: 'إضافة اتصال', editConnection: 'تعديل الاتصال', noConnections: 'لا توجد اتصالات Zammad مضبوطة', loadConnectionsFailed: 'تعذر تحميل اتصالات Zammad', + connectionCreated: 'تم إنشاء اتصال Zammad', connectionUpdated: 'تم تحديث اتصال Zammad', saveConnectionFailed: 'تعذر حفظ اتصال Zammad', + connectionDeleted: 'تم حذف اتصال Zammad', deleteConnection: 'حذف اتصال Zammad', deleteConnectionConfirm: 'هل تريد حذف «{name}»؟ يجب أولًا إلغاء ربط التذاكر الحالية.', + deleteConnectionFailed: 'تعذر حذف اتصال Zammad', testConnection: 'اختبار الاتصال', connectionTestSucceeded: 'نجح اتصال Zammad', connectionTestFailed: 'فشل اتصال Zammad', + refreshAllTickets: 'مزامنة جميع التذاكر', refreshAllTicketsStarted: 'بدأت مزامنة Zammad على مستوى النظام', refreshAllTicketsAlreadyRunning: 'مزامنة Zammad على مستوى النظام قيد التشغيل بالفعل', refreshAllTicketsFailed: 'تعذرت مزامنة تذاكر Zammad على مستوى النظام', + metadataSummary: 'تم تكوين {groups} مجموعات وتتوفر {states} حالات تذاكر', slug: 'المعرّف المختصر', baseUrl: 'الرابط الأساسي', apiToken: 'رمز API', + apiTokenDescription: 'استخدم رمز API من Zammad لهذا الاتصال على مستوى النظام.', apiTokenAvailable: 'رمز API متاح', apiTokenMissing: 'رمز API مفقود', + authMethod: 'طريقة المصادقة', oauth: 'OAuth', oauthDescription: 'فوّض هذا الاتصال باستخدام OAuth من Zammad.', oauthClientId: 'معرّف عميل OAuth', oauthClientSecret: 'سر عميل OAuth', + secretStored: 'مخزّن بأمان - اتركه فارغًا للاحتفاظ به', oauthPending: 'تفويض OAuth قيد الانتظار', oauthConnectedStatus: 'OAuth متصل', oauthReauthorizationRequired: 'إعادة تفويض OAuth مطلوبة', + connectOAuth: 'الاتصال بـ Zammad', reauthorizeOAuth: 'التفويض مرة أخرى', oauthConnected: 'تم اتصال OAuth مع Zammad', oauthConnectionFailed: 'فشل تفويض OAuth لـ Zammad', oauthAuthorizationFailed: 'تعذر بدء تفويض OAuth لـ Zammad', + correlationFieldNotVerified: 'تمنع أذونات حساب الخدمة المقيّدة التحقق التلقائي من حقل الربط. تأكد من وجوده في Zammad قبل إنشاء التذاكر.', + defaultCustomer: 'عميل التذكرة الافتراضي', defaultGroup: 'المجموعة الافتراضية', allowedGroups: 'مجموعات التذاكر المسموح بها', allowedGroupIds: 'معرفات مجموعات Zammad المسموح بها', correlationField: 'حقل الربط', + correlationFieldHint: 'أنشئ هذا الحقل النصي في تذاكر Zammad حتى تعثر المحاولات المتكررة على التذكرة المنشأة.', completionStatus: 'حالة الإكمال في Windshift', + noAutomaticCompletion: 'عدم الإكمال تلقائيا', closedStates: 'حالات Zammad المغلقة', closedStateIds: 'معرّفات حالات Zammad المغلقة', + allWorkspaces: 'السماح بكل مساحات العمل', allowedWorkspaces: 'مساحات العمل المسموحة', tickets: 'تذاكر Zammad', noTickets: 'لا توجد تذكرة Zammad مرتبطة بعد', + createTicket: 'إنشاء تذكرة Zammad', createTicketConfirm: 'سيتم إرسال عنوان العنصر ووصفه إلى اتصال Zammad هذا كملاحظة داخلية. أكد المجموعة الهدف.', connection: 'الاتصال', group: 'المجموعة', status: 'الحالة', owner: 'المسؤول', unassignedOwner: 'غير مكلّف', unknown: 'غير معروف', notSynced: 'لم تتم المزامنة بعد', leaveUnchanged: 'عدم التغيير', + connectionUnavailable: 'يتطلب اتصال Zammad واحد أو أكثر تفويضًا، ولا يمكن استخدامه الآن لإجراءات التذاكر.', + loadLinksFailed: 'تعذر تحميل روابط تذاكر Zammad', loadMetadataFailed: 'تعذر تحميل مجموعات Zammad وحالاتها', loadOwnersFailed: 'تعذر تحميل مسؤولي Zammad', ticketCreated: 'تم إنشاء تذكرة Zammad', + ticketCreationStarted: 'إنشاء تذكرة Zammad قيد التنفيذ بالفعل', ticketCreationInProgress: 'يجري إنشاء التذكرة. تصبح التحديثات متاحة بعد ربط التذكرة.', ticketCreateFailed: 'تعذر إنشاء تذكرة Zammad', linkExistingTicket: 'ربط تذكرة موجودة', linkExistingTicketConfirm: 'اربط تذكرة Zammad موجودة باستخدام رقم التذكرة.', ticketNumberLabel: 'رقم التذكرة', ticketNumberPlaceholder: 'مثال: 12345', ticketLinked: 'تم ربط تذكرة Zammad', ticketLinkFailed: 'تعذر ربط تذكرة Zammad', editTicket: 'تعديل تذكرة Zammad', ticketUpdated: 'تم تحديث تذكرة Zammad', ticketUpdateFailed: 'تعذر تحديث تذكرة Zammad', ticketRefreshed: 'تم تحديث حالة تذكرة Zammad', + ticketRefreshFailed: 'تعذر تحديث تذكرة Zammad', retrySync: 'إعادة محاولة المزامنة', refreshTicket: 'تحديث حالة التذكرة', syncFailedNoTicket: 'فشلت المزامنة. تصبح التحديثات متاحة بعد ربط تذكرة Zammad.', creationUncertainNoTicket: 'نتيجة إنشاء التذكرة غير مؤكدة. تحقق من Zammad واربط التذكرة الموجودة بالرقم إذا لزم الأمر.', removeTicketLink: 'إزالة التعيين', removeTicketLinkConfirm: 'ستتم إزالة التعيين إلى Zammad رقم {number} وحقل الربط في Windshift. ستبقى تذكرة Zammad نفسها موجودة.', ticketLinkRemoved: 'تمت إزالة تعيين تذكرة Zammad', ticketLinkRemoveFailed: 'تعذر إزالة تعيين تذكرة Zammad', ticketNumber: 'Zammad رقم {number}', lastSynced: 'تمت المزامنة: {time}', returnLinkResolving: 'جار فتح عنصر Windshift المرتبط...', returnLinkFailed: 'تعذر فتح عنصر Windshift المرتبط. ربما لم يعد موجودًا أو ليست لديك صلاحية الوصول إليه.', + syncState: { pending: 'إنشاء التذكرة معلق', creating: 'جار إنشاء التذكرة', linked: 'التذكرة مرتبطة', sync_failed: 'فشلت المزامنة', creation_uncertain: 'نتيجة الإنشاء غير مؤكدة؛ المحاولات الجديدة تبحث فقط عن التذكرة الموجودة' }, +} }; diff --git a/frontend/src/lib/locales/de/index.js b/frontend/src/lib/locales/de/index.js index 42e03a734..168b07341 100644 --- a/frontend/src/lib/locales/de/index.js +++ b/frontend/src/lib/locales/de/index.js @@ -25,6 +25,7 @@ import quality from './quality.js'; import review from './review.js'; import adminOperations from './adminOperations.js'; import dashboard from './dashboard.js'; +import zammad from './zammad.js'; export default createLocale({ admin, @@ -48,4 +49,5 @@ export default createLocale({ review, adminOperations, dashboard, + zammad, }); diff --git a/frontend/src/lib/locales/de/zammad.js b/frontend/src/lib/locales/de/zammad.js new file mode 100644 index 000000000..09c20f44d --- /dev/null +++ b/frontend/src/lib/locales/de/zammad.js @@ -0,0 +1,109 @@ +export default { zammad: { + tab: 'Zammad', + connections: 'Zammad-Verbindungen', + connectionsDescription: 'Sichere und auf Arbeitsbereiche begrenzte Zammad-Ticket-Verbindungen konfigurieren.', + addConnection: 'Verbindung hinzufügen', + editConnection: 'Verbindung bearbeiten', + noConnections: 'Keine Zammad-Verbindungen konfiguriert', + loadConnectionsFailed: 'Zammad-Verbindungen konnten nicht geladen werden', + connectionCreated: 'Zammad-Verbindung erstellt', + connectionUpdated: 'Zammad-Verbindung aktualisiert', + saveConnectionFailed: 'Zammad-Verbindung konnte nicht gespeichert werden', + connectionDeleted: 'Zammad-Verbindung gelöscht', + deleteConnection: 'Zammad-Verbindung löschen', + deleteConnectionConfirm: '„{name}“ löschen? Bestehende Ticket-Verknüpfungen müssen vorher einzeln entfernt werden.', + deleteConnectionFailed: 'Zammad-Verbindung konnte nicht gelöscht werden', + testConnection: 'Verbindung testen', + connectionTestSucceeded: 'Zammad-Verbindung erfolgreich getestet', + connectionTestFailed: 'Zammad-Verbindung fehlgeschlagen', + refreshAllTickets: 'Alle Tickets synchronisieren', + refreshAllTicketsStarted: 'Die systemweite Zammad-Synchronisierung wurde gestartet', + refreshAllTicketsAlreadyRunning: 'Die systemweite Zammad-Synchronisierung läuft bereits', + refreshAllTicketsFailed: 'Die Zammad-Tickets konnten nicht systemweit synchronisiert werden', + metadataSummary: '{groups} konfigurierte Gruppen und {states} verfügbare Ticketstatus', + slug: 'Kurzname', + baseUrl: 'Basis-URL', + apiToken: 'API-Token', + apiTokenDescription: 'Für diese systemweite Verbindung einen Zammad-API-Token verwenden.', + apiTokenAvailable: 'API-Token vorhanden', + apiTokenMissing: 'API-Token fehlt', + authMethod: 'Anmeldemethode', + oauth: 'OAuth', + oauthDescription: 'Diese Verbindung per Zammad OAuth autorisieren.', + oauthClientId: 'OAuth-Client-ID', + oauthClientSecret: 'OAuth-Client-Secret', + secretStored: 'Sicher gespeichert - leer lassen, um ihn beizubehalten', + oauthPending: 'OAuth-Autorisierung ausstehend', + oauthConnectedStatus: 'OAuth verbunden', + oauthReauthorizationRequired: 'OAuth-Neuautorisierung erforderlich', + connectOAuth: 'Mit Zammad verbinden', + reauthorizeOAuth: 'Erneut autorisieren', + oauthConnected: 'Zammad OAuth verbunden', + oauthConnectionFailed: 'Zammad-OAuth-Autorisierung fehlgeschlagen', + oauthAuthorizationFailed: 'Zammad-OAuth-Autorisierung konnte nicht gestartet werden', + correlationFieldNotVerified: 'Eingeschränkte Servicekonto-Berechtigungen verhindern die automatische Prüfung des Korrelationsfelds. Vor der Ticketerstellung dessen Existenz in Zammad prüfen.', + groupCatalogNotVerified: 'Eingeschränkte Servicekonto-Berechtigungen verhindern die automatische Prüfung der konfigurierten Gruppen. Umbenannte oder entfernte Gruppen vor Änderungen der Gruppenrichtlinie in Zammad prüfen.', + defaultCustomer: 'Standardkunde für Tickets', + defaultGroup: 'Standardgruppe', + allowedGroups: 'Erlaubte Ticketgruppen', + unverifiedGroup: 'Nicht verifizierte Gruppe (ID {id})', + allowedGroupIds: 'Erlaubte Gruppen (ID:Name)', + correlationField: 'Korrelationsfeld', + correlationFieldHint: 'Dieses Textfeld in Zammad-Tickets anlegen, damit Wiederholungen ein bereits erstelltes Ticket finden.', + completionStatus: 'Abschlussstatus in Windshift', + noAutomaticCompletion: 'Nicht automatisch abschließen', + closedStates: 'Geschlossene Zammad-Status', + closedStateIds: 'IDs geschlossener Zammad-Status', + allWorkspaces: 'Alle Arbeitsbereiche erlauben', + allowedWorkspaces: 'Erlaubte Arbeitsbereiche', + tickets: 'Zammad-Tickets', + noTickets: 'Noch kein Zammad-Ticket verknüpft', + createTicket: 'Zammad-Ticket erstellen', + createTicketConfirm: 'Titel und Beschreibung werden als interne Notiz an diese Zammad-Verbindung gesendet. Zielgruppe bestätigen.', + connection: 'Verbindung', + group: 'Gruppe', + status: 'Status', + owner: 'Verantwortlich', + unassignedOwner: 'Nicht zugewiesen', + unknown: 'Unbekannt', + notSynced: 'Noch nicht synchronisiert', + leaveUnchanged: 'Unverändert lassen', + connectionUnavailable: 'Eine oder mehrere Zammad-Verbindungen müssen erst autorisiert werden und stehen derzeit nicht für Ticketaktionen zur Verfügung.', + loadLinksFailed: 'Zammad-Ticket-Verknüpfungen konnten nicht geladen werden', + loadMetadataFailed: 'Konfigurierte Zammad-Gruppen und -Status konnten nicht geladen werden', + loadOwnersFailed: 'Zammad-Verantwortliche konnten nicht geladen werden', + ticketCreated: 'Zammad-Ticket erstellt', + ticketCreationStarted: 'Die Erstellung des Zammad-Tickets läuft bereits', + ticketCreationInProgress: 'Die Ticketerstellung läuft. Eine Aktualisierung ist erst möglich, sobald ein Ticket verknüpft ist.', + ticketCreateFailed: 'Zammad-Ticket konnte nicht erstellt werden', + linkExistingTicket: 'Bestehendes Ticket zuordnen', + linkExistingTicketConfirm: 'Ordne ein bereits vorhandenes Zammad-Ticket anhand seiner Ticketnummer zu.', + ticketNumberLabel: 'Ticketnummer', + ticketNumberPlaceholder: 'Zum Beispiel 12345', + ticketLinked: 'Zammad-Ticket zugeordnet', + ticketLinkFailed: 'Zammad-Ticket konnte nicht zugeordnet werden', + editTicket: 'Zammad-Ticket bearbeiten', + ticketUpdated: 'Zammad-Ticket aktualisiert', + ticketUpdateFailed: 'Zammad-Ticket konnte nicht aktualisiert werden', + ticketRefreshed: 'Zammad-Ticketstatus aktualisiert', + ticketRefreshFailed: 'Zammad-Ticket konnte nicht aktualisiert werden', + retrySync: 'Synchronisation wiederholen', + refreshTicket: 'Ticketstatus aktualisieren', + syncFailedNoTicket: 'Die Synchronisation ist fehlgeschlagen. Eine Aktualisierung ist erst verfügbar, wenn ein Zammad-Ticket verknüpft wurde.', + creationUncertainNoTicket: 'Der Ausgang der Ticketerstellung ist unklar. Prüfe Zammad und ordne das bestehende Ticket bei Bedarf über die Ticketnummer zu.', + removeTicketLink: 'Zuordnung entfernen', + removeTicketLinkConfirm: 'Die Zuordnung zu Zammad #{number} und das Windshift-Korrelationsfeld werden entfernt. Das Zammad-Ticket selbst bleibt bestehen.', + ticketLinkRemoved: 'Zammad-Ticketzuordnung entfernt', + ticketLinkRemoveFailed: 'Zammad-Ticketzuordnung konnte nicht entfernt werden', + ticketNumber: 'Zammad #{number}', + lastSynced: 'Synchronisiert: {time}', + returnLinkResolving: 'Der verknüpfte Windshift-Eintrag wird geöffnet...', + returnLinkFailed: 'Der verknüpfte Windshift-Eintrag konnte nicht geöffnet werden. Er existiert möglicherweise nicht mehr oder du hast keinen Zugriff.', + syncState: { + pending: 'Ticketerstellung ausstehend', + creating: 'Ticket wird erstellt', + linked: 'Ticket verknüpft', + sync_failed: 'Synchronisation fehlgeschlagen', + creation_uncertain: 'Erstellung unklar; Wiederholungen suchen nur nach dem vorhandenen Ticket', + }, +} }; diff --git a/frontend/src/lib/locales/en/index.js b/frontend/src/lib/locales/en/index.js index 822c9f5b3..8fa5c4fd0 100644 --- a/frontend/src/lib/locales/en/index.js +++ b/frontend/src/lib/locales/en/index.js @@ -23,6 +23,7 @@ import pages from './pages.js'; import adminOperations from './adminOperations.js'; import agents from './agents.js'; import dashboard from './dashboard.js'; +import zammad from './zammad.js'; export default createLocale({ common, @@ -44,4 +45,5 @@ export default createLocale({ adminOperations, agents, dashboard, + zammad, }); diff --git a/frontend/src/lib/locales/en/zammad.js b/frontend/src/lib/locales/en/zammad.js new file mode 100644 index 000000000..1fe537001 --- /dev/null +++ b/frontend/src/lib/locales/en/zammad.js @@ -0,0 +1,109 @@ +export default { zammad: { + tab: 'Zammad', + connections: 'Zammad connections', + connectionsDescription: 'Configure secure, workspace-scoped Zammad ticket connections.', + addConnection: 'Add connection', + editConnection: 'Edit connection', + noConnections: 'No Zammad connections configured', + loadConnectionsFailed: 'Failed to load Zammad connections', + connectionCreated: 'Zammad connection created', + connectionUpdated: 'Zammad connection updated', + saveConnectionFailed: 'Failed to save Zammad connection', + connectionDeleted: 'Zammad connection deleted', + deleteConnection: 'Delete Zammad connection', + deleteConnectionConfirm: 'Delete “{name}”? Existing ticket links must be unlinked first.', + deleteConnectionFailed: 'Failed to delete Zammad connection', + testConnection: 'Test connection', + connectionTestSucceeded: 'Zammad connection succeeded', + connectionTestFailed: 'Zammad connection failed', + refreshAllTickets: 'Sync all tickets', + refreshAllTicketsStarted: 'System-wide Zammad synchronization started', + refreshAllTicketsAlreadyRunning: 'System-wide Zammad synchronization is already running', + refreshAllTicketsFailed: 'Failed to sync Zammad tickets system-wide', + metadataSummary: '{groups} configured groups and {states} ticket states available', + slug: 'Slug', + baseUrl: 'Base URL', + apiToken: 'API token', + apiTokenDescription: 'Use a Zammad API token for this system connection.', + apiTokenAvailable: 'API token available', + apiTokenMissing: 'API token missing', + authMethod: 'Authentication method', + oauth: 'OAuth', + oauthDescription: 'Authorize this connection with Zammad OAuth.', + oauthClientId: 'OAuth client ID', + oauthClientSecret: 'OAuth client secret', + secretStored: 'Stored securely - leave blank to keep unchanged', + oauthPending: 'OAuth authorization pending', + oauthConnectedStatus: 'OAuth connected', + oauthReauthorizationRequired: 'OAuth reauthorization required', + connectOAuth: 'Connect to Zammad', + reauthorizeOAuth: 'Authorize again', + oauthConnected: 'Zammad OAuth connected', + oauthConnectionFailed: 'Zammad OAuth authorization failed', + oauthAuthorizationFailed: 'Could not start Zammad OAuth authorization', + correlationFieldNotVerified: 'Restricted service-account permissions prevent automatic verification of the correlation field. Verify that it exists in Zammad before creating tickets.', + groupCatalogNotVerified: 'Restricted service-account permissions prevent automatic verification of configured groups. Confirm renamed or removed groups in Zammad before changing the group policy.', + defaultCustomer: 'Default ticket customer', + defaultGroup: 'Default group', + allowedGroups: 'Allowed ticket groups', + unverifiedGroup: 'Unverified group (ID {id})', + allowedGroupIds: 'Allowed groups (ID:name)', + correlationField: 'Correlation field', + correlationFieldHint: 'Create this text field on Zammad tickets so retries can find an already-created ticket.', + completionStatus: 'Windshift completion status', + noAutomaticCompletion: 'Do not complete automatically', + closedStates: 'Closed Zammad states', + closedStateIds: 'Closed Zammad state IDs', + allWorkspaces: 'Allow all workspaces', + allowedWorkspaces: 'Allowed workspaces', + tickets: 'Zammad tickets', + noTickets: 'No Zammad ticket linked yet', + createTicket: 'Create Zammad ticket', + createTicketConfirm: 'The item title and description will be sent to this Zammad connection as an internal note. Confirm the destination group.', + connection: 'Connection', + group: 'Group', + status: 'Status', + owner: 'Owner', + unassignedOwner: 'Not assigned', + unknown: 'Unknown', + notSynced: 'Not synced yet', + leaveUnchanged: 'Leave unchanged', + connectionUnavailable: 'One or more Zammad connections need authorization and cannot be used for ticket actions yet.', + loadLinksFailed: 'Failed to load Zammad ticket links', + loadMetadataFailed: 'Failed to load configured Zammad groups and states', + loadOwnersFailed: 'Failed to load Zammad owners', + ticketCreated: 'Zammad ticket created', + ticketCreationStarted: 'Zammad ticket creation is already in progress', + ticketCreationInProgress: 'Ticket creation is in progress. Refresh is available once a ticket is linked.', + ticketCreateFailed: 'Failed to create Zammad ticket', + linkExistingTicket: 'Link existing ticket', + linkExistingTicketConfirm: 'Link an existing Zammad ticket by its ticket number.', + ticketNumberLabel: 'Ticket number', + ticketNumberPlaceholder: 'For example, 12345', + ticketLinked: 'Zammad ticket linked', + ticketLinkFailed: 'Failed to link Zammad ticket', + editTicket: 'Edit Zammad ticket', + ticketUpdated: 'Zammad ticket updated', + ticketUpdateFailed: 'Failed to update Zammad ticket', + ticketRefreshed: 'Zammad ticket status refreshed', + ticketRefreshFailed: 'Failed to refresh Zammad ticket', + retrySync: 'Retry synchronization', + refreshTicket: 'Refresh ticket status', + syncFailedNoTicket: 'Synchronization failed. Refresh is available after a Zammad ticket has been linked.', + creationUncertainNoTicket: 'Ticket creation has an uncertain outcome. Check Zammad and link the existing ticket by number if needed.', + removeTicketLink: 'Remove assignment', + removeTicketLinkConfirm: 'This removes the assignment to Zammad #{number} and the Windshift correlation field. The Zammad ticket itself remains in place.', + ticketLinkRemoved: 'Zammad ticket assignment removed', + ticketLinkRemoveFailed: 'Failed to remove Zammad ticket assignment', + ticketNumber: 'Zammad #{number}', + lastSynced: 'Synced {time}', + returnLinkResolving: 'Opening the linked Windshift item...', + returnLinkFailed: 'The linked Windshift item could not be opened. It may no longer exist or you may not have access.', + syncState: { + pending: 'Ticket creation pending', + creating: 'Creating ticket', + linked: 'Ticket linked', + sync_failed: 'Synchronization failed', + creation_uncertain: 'Creation outcome uncertain; retries only search for the existing ticket', + }, +} }; diff --git a/frontend/src/lib/locales/es/index.js b/frontend/src/lib/locales/es/index.js index 830d69f4d..878921d96 100644 --- a/frontend/src/lib/locales/es/index.js +++ b/frontend/src/lib/locales/es/index.js @@ -24,6 +24,7 @@ import quality from './quality.js'; import review from './review.js'; import adminOperations from './adminOperations.js'; import dashboard from './dashboard.js'; +import zammad from './zammad.js'; export default createLocale({ common, @@ -47,4 +48,5 @@ export default createLocale({ review, adminOperations, dashboard, + zammad, }); diff --git a/frontend/src/lib/locales/es/zammad.js b/frontend/src/lib/locales/es/zammad.js new file mode 100644 index 000000000..f41383811 --- /dev/null +++ b/frontend/src/lib/locales/es/zammad.js @@ -0,0 +1,30 @@ +export default { zammad: { + tab: 'Zammad', connections: 'Conexiones de Zammad', + connectionsDescription: 'Configura conexiones seguras de tickets de Zammad limitadas por espacio de trabajo.', + addConnection: 'Añadir conexión', editConnection: 'Editar conexión', noConnections: 'No hay conexiones de Zammad configuradas', + loadConnectionsFailed: 'No se pudieron cargar las conexiones de Zammad', connectionCreated: 'Conexión de Zammad creada', + connectionUpdated: 'Conexión de Zammad actualizada', saveConnectionFailed: 'No se pudo guardar la conexión de Zammad', + connectionDeleted: 'Conexión de Zammad eliminada', deleteConnection: 'Eliminar conexión de Zammad', + deleteConnectionConfirm: '¿Eliminar «{name}»? Primero deben desvincularse los tickets existentes.', deleteConnectionFailed: 'No se pudo eliminar la conexión de Zammad', + testConnection: 'Probar conexión', connectionTestSucceeded: 'La conexión de Zammad funciona', connectionTestFailed: 'La conexión de Zammad falló', + refreshAllTickets: 'Sincronizar todos los tickets', refreshAllTicketsStarted: 'Se inició la sincronización de Zammad en todo el sistema', refreshAllTicketsAlreadyRunning: 'La sincronización de Zammad en todo el sistema ya está en curso', refreshAllTicketsFailed: 'No se pudieron sincronizar los tickets de Zammad en todo el sistema', + metadataSummary: '{groups} grupos configurados y {states} estados de ticket disponibles', slug: 'Identificador', baseUrl: 'URL base', apiToken: 'Token de API', + apiTokenDescription: 'Usa un token de API de Zammad para esta conexión del sistema.', apiTokenAvailable: 'Token de API disponible', apiTokenMissing: 'Falta el token de API', + authMethod: 'Método de autenticación', oauth: 'OAuth', oauthDescription: 'Autoriza esta conexión con OAuth de Zammad.', oauthClientId: 'ID de cliente OAuth', oauthClientSecret: 'Secreto de cliente OAuth', + secretStored: 'Guardado de forma segura - déjalo vacío para conservarlo', oauthPending: 'Autorización de OAuth pendiente', oauthConnectedStatus: 'OAuth conectado', oauthReauthorizationRequired: 'Se requiere volver a autorizar OAuth', + connectOAuth: 'Conectar con Zammad', reauthorizeOAuth: 'Autorizar de nuevo', oauthConnected: 'OAuth de Zammad conectado', oauthConnectionFailed: 'Falló la autorización OAuth de Zammad', oauthAuthorizationFailed: 'No se pudo iniciar la autorización OAuth de Zammad', + correlationFieldNotVerified: 'Los permisos restringidos de la cuenta de servicio impiden verificar automáticamente el campo de correlación. Comprueba que exista en Zammad antes de crear tickets.', + defaultCustomer: 'Cliente predeterminado del ticket', defaultGroup: 'Grupo predeterminado', allowedGroups: 'Grupos de tickets permitidos', allowedGroupIds: 'IDs de grupos de Zammad permitidos', correlationField: 'Campo de correlación', + correlationFieldHint: 'Crea este campo de texto en los tickets de Zammad para que los reintentos encuentren un ticket ya creado.', + completionStatus: 'Estado de finalización en Windshift', noAutomaticCompletion: 'No finalizar automáticamente', closedStates: 'Estados cerrados de Zammad', + closedStateIds: 'IDs de estados cerrados de Zammad', allWorkspaces: 'Permitir todos los espacios', allowedWorkspaces: 'Espacios permitidos', + tickets: 'Tickets de Zammad', noTickets: 'Aún no hay ningún ticket de Zammad enlazado', createTicket: 'Crear ticket de Zammad', + createTicketConfirm: 'El título y la descripción se enviarán a esta conexión de Zammad como nota interna. Confirma el grupo de destino.', connection: 'Conexión', group: 'Grupo', status: 'Estado', owner: 'Responsable', unassignedOwner: 'Sin asignar', unknown: 'Desconocido', notSynced: 'Aún no sincronizado', leaveUnchanged: 'Dejar sin cambios', + connectionUnavailable: 'Una o más conexiones de Zammad necesitan autorización y no se pueden usar todavía para acciones de tickets.', + loadLinksFailed: 'No se pudieron cargar los enlaces de tickets de Zammad', loadMetadataFailed: 'No se pudieron cargar los grupos y estados de Zammad', loadOwnersFailed: 'No se pudieron cargar los responsables de Zammad', + ticketCreated: 'Ticket de Zammad creado', ticketCreationStarted: 'La creación del ticket de Zammad ya está en curso', ticketCreationInProgress: 'La creación del ticket está en curso. La actualización estará disponible cuando se enlace un ticket.', + ticketCreateFailed: 'No se pudo crear el ticket de Zammad', ticketRefreshed: 'Estado del ticket de Zammad actualizado', + linkExistingTicket: 'Enlazar ticket existente', linkExistingTicketConfirm: 'Enlaza un ticket de Zammad existente mediante su número.', ticketNumberLabel: 'Número de ticket', ticketNumberPlaceholder: 'Por ejemplo, 12345', ticketLinked: 'Ticket de Zammad enlazado', ticketLinkFailed: 'No se pudo enlazar el ticket de Zammad', editTicket: 'Editar ticket de Zammad', ticketUpdated: 'Ticket de Zammad actualizado', ticketUpdateFailed: 'No se pudo actualizar el ticket de Zammad', + ticketRefreshFailed: 'No se pudo actualizar el ticket de Zammad', retrySync: 'Reintentar sincronización', refreshTicket: 'Actualizar estado del ticket', syncFailedNoTicket: 'La sincronización falló. La actualización estará disponible cuando se haya enlazado un ticket de Zammad.', creationUncertainNoTicket: 'El resultado de la creación del ticket es incierto. Comprueba Zammad y enlaza el ticket existente por número si es necesario.', removeTicketLink: 'Eliminar asignación', removeTicketLinkConfirm: 'Se eliminarán la asignación a Zammad n.º {number} y el campo de correlación de Windshift. El ticket de Zammad seguirá existiendo.', ticketLinkRemoved: 'Asignación de ticket de Zammad eliminada', ticketLinkRemoveFailed: 'No se pudo eliminar la asignación de ticket de Zammad', ticketNumber: 'Zammad n.º {number}', + lastSynced: 'Sincronizado: {time}', returnLinkResolving: 'Abriendo el elemento de Windshift enlazado...', returnLinkFailed: 'No se pudo abrir el elemento de Windshift enlazado. Puede que ya no exista o que no tengas acceso.', syncState: { pending: 'Creación pendiente', creating: 'Creando ticket', linked: 'Ticket enlazado', sync_failed: 'Sincronización fallida', creation_uncertain: 'Resultado de creación incierto; los reintentos solo buscan el ticket existente' }, +} }; diff --git a/frontend/src/lib/locales/pt-BR/index.js b/frontend/src/lib/locales/pt-BR/index.js index cae9c440f..4418b11dc 100644 --- a/frontend/src/lib/locales/pt-BR/index.js +++ b/frontend/src/lib/locales/pt-BR/index.js @@ -23,6 +23,7 @@ import supplemental from './supplemental.js'; import quality from './quality.js'; import adminOperations from './adminOperations.js'; import dashboard from './dashboard.js'; +import zammad from './zammad.js'; export default createLocale({ common, @@ -45,4 +46,5 @@ export default createLocale({ quality, adminOperations, dashboard, + zammad, }); diff --git a/frontend/src/lib/locales/pt-BR/zammad.js b/frontend/src/lib/locales/pt-BR/zammad.js new file mode 100644 index 000000000..c6ccab747 --- /dev/null +++ b/frontend/src/lib/locales/pt-BR/zammad.js @@ -0,0 +1,30 @@ +export default { zammad: { + tab: 'Zammad', connections: 'Conexões do Zammad', + connectionsDescription: 'Configure conexões seguras de tickets do Zammad limitadas por espaço de trabalho.', + addConnection: 'Adicionar conexão', editConnection: 'Editar conexão', noConnections: 'Nenhuma conexão do Zammad configurada', + loadConnectionsFailed: 'Não foi possível carregar as conexões do Zammad', connectionCreated: 'Conexão do Zammad criada', + connectionUpdated: 'Conexão do Zammad atualizada', saveConnectionFailed: 'Não foi possível salvar a conexão do Zammad', + connectionDeleted: 'Conexão do Zammad excluída', deleteConnection: 'Excluir conexão do Zammad', + deleteConnectionConfirm: 'Excluir “{name}”? Os vínculos de tickets existentes devem ser removidos primeiro.', deleteConnectionFailed: 'Não foi possível excluir a conexão do Zammad', + testConnection: 'Testar conexão', connectionTestSucceeded: 'Conexão do Zammad bem-sucedida', connectionTestFailed: 'Falha na conexão do Zammad', + refreshAllTickets: 'Sincronizar todos os tickets', refreshAllTicketsStarted: 'A sincronização do Zammad em todo o sistema foi iniciada', refreshAllTicketsAlreadyRunning: 'A sincronização do Zammad em todo o sistema já está em execução', refreshAllTicketsFailed: 'Não foi possível sincronizar os tickets do Zammad em todo o sistema', + metadataSummary: '{groups} grupos configurados e {states} estados de ticket disponíveis', slug: 'Identificador', baseUrl: 'URL base', apiToken: 'Token da API', + apiTokenDescription: 'Use um token da API do Zammad para esta conexão do sistema.', apiTokenAvailable: 'Token da API disponível', apiTokenMissing: 'Token da API ausente', + authMethod: 'Método de autenticação', oauth: 'OAuth', oauthDescription: 'Autorize esta conexão com OAuth do Zammad.', oauthClientId: 'ID do cliente OAuth', oauthClientSecret: 'Segredo do cliente OAuth', + secretStored: 'Armazenado com segurança - deixe em branco para manter', oauthPending: 'Autorização OAuth pendente', oauthConnectedStatus: 'OAuth conectado', oauthReauthorizationRequired: 'É necessária nova autorização OAuth', + connectOAuth: 'Conectar ao Zammad', reauthorizeOAuth: 'Autorizar novamente', oauthConnected: 'OAuth do Zammad conectado', oauthConnectionFailed: 'Falha na autorização OAuth do Zammad', oauthAuthorizationFailed: 'Não foi possível iniciar a autorização OAuth do Zammad', + correlationFieldNotVerified: 'Permissões restritas da conta de serviço impedem a verificação automática do campo de correlação. Confirme que ele existe no Zammad antes de criar tickets.', + defaultCustomer: 'Cliente padrão do ticket', defaultGroup: 'Grupo padrão', allowedGroups: 'Grupos de tickets permitidos', allowedGroupIds: 'IDs de grupos Zammad permitidos', correlationField: 'Campo de correlação', + correlationFieldHint: 'Crie este campo de texto nos tickets do Zammad para que novas tentativas encontrem um ticket já criado.', + completionStatus: 'Status de conclusão no Windshift', noAutomaticCompletion: 'Não concluir automaticamente', closedStates: 'Estados fechados do Zammad', + closedStateIds: 'IDs de estados fechados do Zammad', allWorkspaces: 'Permitir todos os espaços', allowedWorkspaces: 'Espaços permitidos', + tickets: 'Tickets do Zammad', noTickets: 'Nenhum ticket do Zammad vinculado ainda', createTicket: 'Criar ticket no Zammad', + createTicketConfirm: 'O título e a descrição serão enviados a esta conexão Zammad como nota interna. Confirme o grupo de destino.', connection: 'Conexão', group: 'Grupo', status: 'Status', owner: 'Responsável', unassignedOwner: 'Não atribuído', unknown: 'Desconhecido', notSynced: 'Ainda não sincronizado', leaveUnchanged: 'Manter inalterado', + connectionUnavailable: 'Uma ou mais conexões do Zammad precisam de autorização e ainda não podem ser usadas para ações de tickets.', + loadLinksFailed: 'Não foi possível carregar os vínculos de tickets do Zammad', loadMetadataFailed: 'Não foi possível carregar grupos e estados do Zammad', loadOwnersFailed: 'Não foi possível carregar os responsáveis do Zammad', + ticketCreated: 'Ticket do Zammad criado', ticketCreationStarted: 'A criação do ticket do Zammad já está em andamento', ticketCreationInProgress: 'A criação do ticket está em andamento. A atualização estará disponível quando um ticket for vinculado.', + ticketCreateFailed: 'Não foi possível criar o ticket do Zammad', ticketRefreshed: 'Status do ticket do Zammad atualizado', + linkExistingTicket: 'Vincular ticket existente', linkExistingTicketConfirm: 'Vincule um ticket existente do Zammad pelo número do ticket.', ticketNumberLabel: 'Número do ticket', ticketNumberPlaceholder: 'Por exemplo, 12345', ticketLinked: 'Ticket do Zammad vinculado', ticketLinkFailed: 'Não foi possível vincular o ticket do Zammad', editTicket: 'Editar ticket do Zammad', ticketUpdated: 'Ticket do Zammad atualizado', ticketUpdateFailed: 'Não foi possível atualizar o ticket do Zammad', + ticketRefreshFailed: 'Não foi possível atualizar o ticket do Zammad', retrySync: 'Tentar sincronização novamente', refreshTicket: 'Atualizar status do ticket', syncFailedNoTicket: 'A sincronização falhou. A atualização estará disponível após vincular um ticket do Zammad.', creationUncertainNoTicket: 'O resultado da criação do ticket é incerto. Verifique o Zammad e vincule o ticket existente pelo número se necessário.', removeTicketLink: 'Remover atribuição', removeTicketLinkConfirm: 'A atribuição ao Zammad nº {number} e o campo de correlação do Windshift serão removidos. O ticket do Zammad continuará existindo.', ticketLinkRemoved: 'Atribuição de ticket do Zammad removida', ticketLinkRemoveFailed: 'Não foi possível remover a atribuição de ticket do Zammad', ticketNumber: 'Zammad nº {number}', + lastSynced: 'Sincronizado: {time}', returnLinkResolving: 'Abrindo o item vinculado no Windshift...', returnLinkFailed: 'Não foi possível abrir o item vinculado no Windshift. Talvez ele não exista mais ou você não tenha acesso.', syncState: { pending: 'Criação pendente', creating: 'Criando ticket', linked: 'Ticket vinculado', sync_failed: 'Falha na sincronização', creation_uncertain: 'Resultado da criação incerto; novas tentativas apenas procuram o ticket existente' }, +} }; diff --git a/frontend/src/lib/locales/ru/index.js b/frontend/src/lib/locales/ru/index.js index 899aec36a..b896e299d 100644 --- a/frontend/src/lib/locales/ru/index.js +++ b/frontend/src/lib/locales/ru/index.js @@ -22,6 +22,7 @@ import pages from './pages.js'; import adminOperations from './adminOperations.js'; import agents from './agents.js'; import dashboard from './dashboard.js'; +import zammad from './zammad.js'; export default createLocale({ common, @@ -43,4 +44,5 @@ export default createLocale({ adminOperations, agents, dashboard, + zammad, }); diff --git a/frontend/src/lib/locales/ru/zammad.js b/frontend/src/lib/locales/ru/zammad.js new file mode 100644 index 000000000..eaa4e2939 --- /dev/null +++ b/frontend/src/lib/locales/ru/zammad.js @@ -0,0 +1,109 @@ +export default { zammad: { + tab: 'Zammad', + connections: 'Подключения Zammad', + connectionsDescription: 'Настройте безопасные подключения к заявкам Zammad с ограничением по рабочим пространствам.', + addConnection: 'Добавить подключение', + editConnection: 'Изменить подключение', + noConnections: 'Подключения Zammad не настроены', + loadConnectionsFailed: 'Не удалось загрузить подключения Zammad', + connectionCreated: 'Подключение Zammad создано', + connectionUpdated: 'Подключение Zammad обновлено', + saveConnectionFailed: 'Не удалось сохранить подключение Zammad', + connectionDeleted: 'Подключение Zammad удалено', + deleteConnection: 'Удалить подключение Zammad', + deleteConnectionConfirm: 'Удалить «{name}»? Сначала необходимо удалить существующие связи с заявками.', + deleteConnectionFailed: 'Не удалось удалить подключение Zammad', + testConnection: 'Проверить подключение', + connectionTestSucceeded: 'Подключение Zammad работает', + connectionTestFailed: 'Проверка подключения Zammad завершилась ошибкой', + refreshAllTickets: 'Синхронизировать все заявки', + refreshAllTicketsStarted: 'Запущена общесистемная синхронизация Zammad', + refreshAllTicketsAlreadyRunning: 'Общесистемная синхронизация Zammad уже выполняется', + refreshAllTicketsFailed: 'Не удалось синхронизировать заявки Zammad во всей системе', + metadataSummary: 'Настроено групп: {groups}, доступно состояний заявок: {states}', + slug: 'Краткое имя', + baseUrl: 'Базовый URL', + apiToken: 'Токен API', + apiTokenDescription: 'Использовать токен API Zammad для этого системного подключения.', + apiTokenAvailable: 'Токен API сохранён', + apiTokenMissing: 'Токен API отсутствует', + authMethod: 'Способ аутентификации', + oauth: 'OAuth', + oauthDescription: 'Авторизуйте это подключение через OAuth Zammad.', + oauthClientId: 'ID клиента OAuth', + oauthClientSecret: 'Секрет клиента OAuth', + secretStored: 'Надёжно сохранён - оставьте поле пустым, чтобы не изменять', + oauthPending: 'Ожидается авторизация OAuth', + oauthConnectedStatus: 'OAuth подключён', + oauthReauthorizationRequired: 'Требуется повторная авторизация OAuth', + connectOAuth: 'Подключить Zammad', + reauthorizeOAuth: 'Авторизовать повторно', + oauthConnected: 'OAuth Zammad подключён', + oauthConnectionFailed: 'Не удалось авторизоваться через OAuth Zammad', + oauthAuthorizationFailed: 'Не удалось запустить авторизацию OAuth Zammad', + correlationFieldNotVerified: 'Ограниченные права сервисной учётной записи не позволяют автоматически проверить поле корреляции. Перед созданием заявок убедитесь, что оно существует в Zammad.', + groupCatalogNotVerified: 'Ограниченные права сервисной учётной записи не позволяют автоматически проверить настроенные группы. Перед изменением политики групп проверьте в Zammad переименованные или удалённые группы.', + defaultCustomer: 'Клиент заявки по умолчанию', + defaultGroup: 'Группа по умолчанию', + allowedGroups: 'Разрешённые группы заявок', + unverifiedGroup: 'Непроверенная группа (ID {id})', + allowedGroupIds: 'ID разрешённых групп Zammad', + correlationField: 'Поле корреляции', + correlationFieldHint: 'Создайте это текстовое поле в заявках Zammad, чтобы при повторных попытках находить уже созданную заявку.', + completionStatus: 'Статус завершения в Windshift', + noAutomaticCompletion: 'Не завершать автоматически', + closedStates: 'Закрытые состояния Zammad', + closedStateIds: 'ID закрытых состояний Zammad', + allWorkspaces: 'Разрешить все рабочие пространства', + allowedWorkspaces: 'Разрешённые рабочие пространства', + tickets: 'Заявки Zammad', + noTickets: 'Заявка Zammad ещё не связана', + createTicket: 'Создать заявку Zammad', + createTicketConfirm: 'Заголовок и описание элемента будут отправлены в это подключение Zammad как внутренняя заметка. Подтвердите целевую группу.', + connection: 'Подключение', + group: 'Группа', + status: 'Статус', + owner: 'Ответственный', + unassignedOwner: 'Не назначен', + unknown: 'Неизвестно', + notSynced: 'Ещё не синхронизировано', + leaveUnchanged: 'Не изменять', + connectionUnavailable: 'Одно или несколько подключений Zammad требуют авторизации и пока недоступны для действий с заявками.', + loadLinksFailed: 'Не удалось загрузить связи с заявками Zammad', + loadMetadataFailed: 'Не удалось загрузить группы и состояния Zammad', + loadOwnersFailed: 'Не удалось загрузить ответственных из Zammad', + ticketCreated: 'Заявка Zammad создана', + ticketCreationStarted: 'Создание заявки Zammad уже выполняется', + ticketCreationInProgress: 'Заявка создаётся. Обновление станет доступно после установления связи с заявкой.', + ticketCreateFailed: 'Не удалось создать заявку Zammad', + linkExistingTicket: 'Связать существующую заявку', + linkExistingTicketConfirm: 'Свяжите существующую заявку Zammad по её номеру.', + ticketNumberLabel: 'Номер заявки', + ticketNumberPlaceholder: 'Например, 12345', + ticketLinked: 'Заявка Zammad связана', + ticketLinkFailed: 'Не удалось связать заявку Zammad', + editTicket: 'Изменить заявку Zammad', + ticketUpdated: 'Заявка Zammad обновлена', + ticketUpdateFailed: 'Не удалось обновить заявку Zammad', + ticketRefreshed: 'Статус заявки Zammad обновлён', + ticketRefreshFailed: 'Не удалось обновить заявку Zammad', + retrySync: 'Повторить синхронизацию', + refreshTicket: 'Обновить статус заявки', + syncFailedNoTicket: 'Синхронизация завершилась ошибкой. Обновление станет доступно после установления связи с заявкой Zammad.', + creationUncertainNoTicket: 'Результат создания заявки неизвестен. Проверьте Zammad и при необходимости свяжите существующую заявку по номеру.', + removeTicketLink: 'Удалить связь', + removeTicketLinkConfirm: 'Связь с Zammad №{number} и поле корреляции Windshift будут удалены. Сама заявка Zammad останется в системе.', + ticketLinkRemoved: 'Связь с заявкой Zammad удалена', + ticketLinkRemoveFailed: 'Не удалось удалить связь с заявкой Zammad', + ticketNumber: 'Zammad №{number}', + lastSynced: 'Синхронизировано: {time}', + returnLinkResolving: 'Открывается связанный элемент Windshift...', + returnLinkFailed: 'Не удалось открыть связанный элемент Windshift. Возможно, он больше не существует или у вас нет доступа.', + syncState: { + pending: 'Ожидается создание заявки', + creating: 'Заявка создаётся', + linked: 'Заявка связана', + sync_failed: 'Ошибка синхронизации', + creation_uncertain: 'Результат создания неизвестен; повторные попытки только ищут существующую заявку', + }, +} }; diff --git a/frontend/src/lib/locales/zh-CN/index.js b/frontend/src/lib/locales/zh-CN/index.js index 7dfbc0588..aa98e7b5a 100644 --- a/frontend/src/lib/locales/zh-CN/index.js +++ b/frontend/src/lib/locales/zh-CN/index.js @@ -19,6 +19,7 @@ import supplemental from './supplemental.js'; import quality from './quality.js'; import adminOperations from './adminOperations.js'; import dashboard from './dashboard.js'; +import zammad from './zammad.js'; export default createLocale({ common, @@ -41,4 +42,5 @@ export default createLocale({ quality, adminOperations, dashboard, + zammad, }); diff --git a/frontend/src/lib/locales/zh-CN/zammad.js b/frontend/src/lib/locales/zh-CN/zammad.js new file mode 100644 index 000000000..74c513851 --- /dev/null +++ b/frontend/src/lib/locales/zh-CN/zammad.js @@ -0,0 +1,24 @@ +export default { zammad: { + tab: 'Zammad', connections: 'Zammad 连接', connectionsDescription: '配置安全且受工作区范围限制的 Zammad 工单连接。', + addConnection: '添加连接', editConnection: '编辑连接', noConnections: '尚未配置 Zammad 连接', loadConnectionsFailed: '无法加载 Zammad 连接', + connectionCreated: '已创建 Zammad 连接', connectionUpdated: '已更新 Zammad 连接', saveConnectionFailed: '无法保存 Zammad 连接', + connectionDeleted: '已删除 Zammad 连接', deleteConnection: '删除 Zammad 连接', deleteConnectionConfirm: '删除“{name}”?必须先取消所有现有工单关联。', + deleteConnectionFailed: '无法删除 Zammad 连接', testConnection: '测试连接', connectionTestSucceeded: 'Zammad 连接测试成功', connectionTestFailed: 'Zammad 连接失败', + refreshAllTickets: '同步所有工单', refreshAllTicketsStarted: '已启动系统范围的 Zammad 同步', refreshAllTicketsAlreadyRunning: '系统范围的 Zammad 同步已在运行', refreshAllTicketsFailed: '无法在系统范围内同步 Zammad 工单', + metadataSummary: '已配置群组 {groups} 个,可用工单状态 {states} 个', slug: '短标识', baseUrl: '基础网址', apiToken: 'API 令牌', + apiTokenDescription: '对此系统连接使用 Zammad API 令牌。', apiTokenAvailable: 'API 令牌可用', apiTokenMissing: '缺少 API 令牌', + authMethod: '认证方式', oauth: 'OAuth', oauthDescription: '使用 Zammad OAuth 授权此连接。', oauthClientId: 'OAuth 客户端 ID', oauthClientSecret: 'OAuth 客户端密钥', + secretStored: '已安全保存 - 留空以保持不变', oauthPending: '等待 OAuth 授权', oauthConnectedStatus: 'OAuth 已连接', oauthReauthorizationRequired: '需要重新授权 OAuth', + connectOAuth: '连接 Zammad', reauthorizeOAuth: '再次授权', oauthConnected: 'Zammad OAuth 已连接', oauthConnectionFailed: 'Zammad OAuth 授权失败', oauthAuthorizationFailed: '无法开始 Zammad OAuth 授权', + correlationFieldNotVerified: '受限的服务账号权限无法自动验证关联字段。请在创建工单前确认该字段已存在于 Zammad 中。', + defaultCustomer: '默认工单客户', defaultGroup: '默认群组', allowedGroups: '允许的工单组', allowedGroupIds: '允许的 Zammad 组 ID', correlationField: '关联字段', + correlationFieldHint: '请在 Zammad 工单中创建此文本字段,以便重试时找到已创建的工单。', completionStatus: 'Windshift 完成状态', + noAutomaticCompletion: '不自动完成', closedStates: 'Zammad 已关闭状态', closedStateIds: 'Zammad 已关闭状态 ID', + allWorkspaces: '允许所有工作区', allowedWorkspaces: '允许的工作区', tickets: 'Zammad 工单', noTickets: '尚未链接 Zammad 工单', + createTicket: '创建 Zammad 工单', createTicketConfirm: '条目标题和描述将作为内部备注发送到此 Zammad 连接。请确认目标群组。', connection: '连接', group: '群组', status: '状态', owner: '负责人', unassignedOwner: '未分配', unknown: '未知', notSynced: '尚未同步', leaveUnchanged: '保持不变', + connectionUnavailable: '一个或多个 Zammad 连接需要授权,暂时不能用于工单操作。', + loadLinksFailed: '无法加载 Zammad 工单链接', loadMetadataFailed: '无法加载 Zammad 群组和状态', loadOwnersFailed: '无法加载 Zammad 负责人', ticketCreated: '已创建 Zammad 工单', + ticketCreationStarted: 'Zammad 工单创建已在进行中', ticketCreationInProgress: '工单正在创建。关联工单后才可以刷新。', ticketCreateFailed: '无法创建 Zammad 工单', linkExistingTicket: '关联现有工单', linkExistingTicketConfirm: '通过工单编号关联现有的 Zammad 工单。', ticketNumberLabel: '工单编号', ticketNumberPlaceholder: '例如,12345', ticketLinked: '已关联 Zammad 工单', ticketLinkFailed: '无法关联 Zammad 工单', editTicket: '编辑 Zammad 工单', ticketUpdated: '已更新 Zammad 工单', ticketUpdateFailed: '无法更新 Zammad 工单', ticketRefreshed: '已刷新 Zammad 工单状态', + ticketRefreshFailed: '无法刷新 Zammad 工单', retrySync: '重试同步', refreshTicket: '刷新工单状态', syncFailedNoTicket: '同步失败。关联 Zammad 工单后才可以刷新。', creationUncertainNoTicket: '工单创建结果不确定。请检查 Zammad,并在需要时通过编号关联现有工单。', removeTicketLink: '移除关联', removeTicketLinkConfirm: '将移除与 Zammad #{number} 的关联及 Windshift 关联字段。Zammad 工单本身不会被删除。', ticketLinkRemoved: '已移除 Zammad 工单关联', ticketLinkRemoveFailed: '无法移除 Zammad 工单关联', ticketNumber: 'Zammad #{number}', lastSynced: '同步于 {time}', returnLinkResolving: '正在打开关联的 Windshift 条目...', returnLinkFailed: '无法打开关联的 Windshift 条目。该条目可能已不存在,或者你没有访问权限。', + syncState: { pending: '等待创建工单', creating: '正在创建工单', linked: '工单已链接', sync_failed: '同步失败', creation_uncertain: '创建结果不确定;重试只会查找现有工单' }, +} }; diff --git a/frontend/src/lib/pages/mainAppRoutes.js b/frontend/src/lib/pages/mainAppRoutes.js index 9769cc838..9413358a8 100644 --- a/frontend/src/lib/pages/mainAppRoutes.js +++ b/frontend/src/lib/pages/mainAppRoutes.js @@ -49,6 +49,7 @@ export const MAIN_APP_COMPONENT_LOADERS = { homepage: () => import('./Homepage.svelte'), licenses: () => import('./Licenses.svelte'), 'item-detail': () => import('../features/items/ItemDetail.svelte'), + 'zammad-resolve': () => import('../features/items/ZammadLinkResolver.svelte'), 'personal-task-detail': () => import('../features/personal/PersonalTaskDetail.svelte'), 'workspace-detail': () => import('../workspaces/WorkspaceWelcome.svelte'), 'workspace-overview': () => import('../workspaces/WorkspaceWelcome.svelte'), @@ -288,6 +289,10 @@ export const MAIN_APP_ROUTE_CONFIG = { }; }, }), + 'zammad-resolve': route('Resolving Zammad link...', 'Failed to resolve Zammad link', { + wrapper: 'surface-full', + getProps: (currentRoute) => ({ correlationKey: currentRoute.params.correlationKey }), + }), 'personal-task-detail': route('Loading Task...', 'Failed to load Task', { getProps: (currentRoute, context) => ({ workspaceId: currentRoute.path.startsWith('/personal') diff --git a/frontend/src/lib/router.js b/frontend/src/lib/router.js index 8f029c617..5416405d8 100644 --- a/frontend/src/lib/router.js +++ b/frontend/src/lib/router.js @@ -41,6 +41,7 @@ const routes = { // Stable key routes used by CLI/browser deep links. '/workspace/:workspaceKey/item/:itemNumber': 'item-detail', '/item/:itemKey': 'item-detail', + '/zammad/:correlationKey': 'zammad-resolve', // Routes without collection (show all workspace items) '/workspaces/:id/board': 'workspace-board', '/workspaces/:id/board/configure': 'workspace-board-config', diff --git a/frontend/src/lib/settings/IntegrationsManager.svelte b/frontend/src/lib/settings/IntegrationsManager.svelte index 07e751054..34b428f38 100644 --- a/frontend/src/lib/settings/IntegrationsManager.svelte +++ b/frontend/src/lib/settings/IntegrationsManager.svelte @@ -2,17 +2,26 @@ // OAuth integrations share a page: outbound providers connect Windshift to // external apps, while inbound clients authorize apps to mint user tokens. + import { onMount } from 'svelte'; import Tabs from '../components/Tabs.svelte'; import IntegrationProviderManager from './IntegrationProviderManager.svelte'; import OAuthClientManager from './OAuthClientManager.svelte'; - import { ArrowUpRight, ArrowDownLeft } from '@lucide/svelte'; + import ZammadConnectionManager from './ZammadConnectionManager.svelte'; + import { ArrowUpRight, ArrowDownLeft, TicketCheck } from '@lucide/svelte'; import { t } from '../stores/i18n.svelte.js'; let activeTab = $state('outbound'); + onMount(() => { + if (new URLSearchParams(window.location.search).get('tab') === 'zammad') { + activeTab = 'zammad'; + } + }); + const tabs = $derived([ { id: 'outbound', label: t('integrations.directions.outbound'), icon: ArrowUpRight }, { id: 'inbound', label: t('integrations.directions.inbound'), icon: ArrowDownLeft }, + { id: 'zammad', label: t('zammad.tab'), icon: TicketCheck }, ]); @@ -28,6 +37,8 @@ {t('integrations.directions.inboundDescription')}

+ {:else if activeTab === 'zammad'} + {/if} diff --git a/frontend/src/lib/settings/ZammadConnectionManager.spec.js b/frontend/src/lib/settings/ZammadConnectionManager.spec.js new file mode 100644 index 000000000..afd7e5624 --- /dev/null +++ b/frontend/src/lib/settings/ZammadConnectionManager.spec.js @@ -0,0 +1,182 @@ +/** @vitest-environment jsdom */ + +import '@testing-library/jest-dom/vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import ZammadConnectionManager from './ZammadConnectionManager.svelte'; + +const mocks = vi.hoisted(() => ({ + getConnections: vi.fn(), + getWorkspaces: vi.fn(), + getStatuses: vi.fn(), + testConnection: vi.fn(), + updateConnection: vi.fn(), +})); + +vi.mock('../api.js', () => ({ + api: { + zammadConnections: { + getAll: mocks.getConnections, + test: mocks.testConnection, + update: mocks.updateConnection, + }, + workspaces: { getAll: mocks.getWorkspaces }, + statuses: { getAll: mocks.getStatuses }, + }, +})); + +vi.mock('../stores/i18n.svelte.js', () => ({ t: (key) => key })); +vi.mock('../stores/toasts.svelte.js', () => ({ + successToast: vi.fn(), + errorToast: vi.fn(), + warningToast: vi.fn(), +})); +vi.mock('../composables/useConfirm.js', () => ({ confirm: vi.fn() })); + +const connection = { + id: 'zammad-dev', + slug: 'zammad-dev', + name: 'Zammad Dev', + enabled: true, + base_url: 'https://zammad.example.test', + auth_method: 'oauth', + oauth_client_id: 'client-id', + has_oauth_client_secret: true, + oauth_connected: true, + default_group_id: 0, + default_group_name: 'Windshift', + allowed_groups: [ + { id: 2, name: 'Support' }, + { id: 3, name: 'Escalations' }, + ], + default_customer: 'windshift@example.test', + correlation_field: 'windshift_item_key', + closed_state_ids: [4], + applies_to_all_workspaces: true, + workspace_ids: [], +}; + +describe('ZammadConnectionManager', () => { + beforeAll(() => { + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => { + const animation = { + cancel: vi.fn(), + currentTime: 0, + effect: {}, + onfinish: null, + playState: 'finished', + }; + queueMicrotask(() => animation.onfinish?.()); + return animation; + }), + }); + }); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.getConnections.mockResolvedValue([connection]); + mocks.getWorkspaces.mockResolvedValue([]); + mocks.getStatuses.mockResolvedValue([]); + mocks.testConnection.mockResolvedValue({ + metadata: { + groups: [ + { id: 2, name: 'Windshift' }, + { id: 3, name: 'Windshift Escalation' }, + ], + states: [], + correlation_field_verified: true, + }, + }); + mocks.updateConnection.mockResolvedValue(connection); + }); + + afterEach(cleanup); + + it('persists the selected default group ID and name', async () => { + const { container } = render(ZammadConnectionManager); + await screen.findByText('Zammad Dev'); + + await fireEvent.click(screen.getByRole('button', { name: 'zammad.testConnection' })); + await waitFor(() => expect(mocks.testConnection).toHaveBeenCalledWith('zammad-dev')); + + const editButton = container.querySelector('svg.lucide-pen')?.closest('button'); + expect(editButton).toBeTruthy(); + await fireEvent.click(editButton); + + const dialog = screen.getByRole('dialog'); + await fireEvent.change(within(dialog).getAllByRole('combobox')[0], { + target: { value: '2' }, + }); + await fireEvent.click(within(dialog).getByRole('button', { name: 'common.update' })); + + await waitFor(() => + expect(mocks.updateConnection).toHaveBeenCalledWith( + 'zammad-dev', + expect.objectContaining({ default_group_id: 2, default_group_name: 'Windshift' }) + ) + ); + }); + + it('labels and lets administrators repair migrated groups without names', async () => { + mocks.getConnections.mockResolvedValue([ + { + ...connection, + default_group_id: 99, + default_group_name: '', + allowed_groups: [ + { id: 2, name: 'Windshift' }, + { id: 99, name: '' }, + ], + }, + ]); + mocks.testConnection.mockResolvedValue({ + metadata: { + groups: [ + { id: 2, name: 'Windshift' }, + { id: 99, name: '' }, + ], + states: [], + correlation_field_verified: false, + group_catalog_verified: false, + }, + }); + + const { container } = render(ZammadConnectionManager); + await screen.findByText('Zammad Dev'); + await fireEvent.click(screen.getByRole('button', { name: 'zammad.testConnection' })); + await waitFor(() => expect(mocks.testConnection).toHaveBeenCalledWith('zammad-dev')); + + const editButton = container.querySelector('svg.lucide-pen')?.closest('button'); + expect(editButton).toBeTruthy(); + await fireEvent.click(editButton); + + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getAllByText('zammad.unverifiedGroup')).toHaveLength(2); + const defaultGroupSelect = /** @type {HTMLSelectElement} */ ( + within(dialog).getAllByRole('combobox')[0] + ); + const defaultGroupOptions = Array.from(defaultGroupSelect.options).map( + (option) => option.textContent + ); + expect(defaultGroupOptions).toEqual(['Windshift', 'zammad.unverifiedGroup']); + expect(defaultGroupSelect.value).toBe('99'); + + await fireEvent.input(within(dialog).getByLabelText('settings.groups.groupName'), { + target: { value: 'Legacy Support' }, + }); + await fireEvent.click(within(dialog).getByRole('button', { name: 'common.update' })); + + await waitFor(() => + expect(mocks.updateConnection).toHaveBeenCalledWith( + 'zammad-dev', + expect.objectContaining({ + default_group_id: 99, + default_group_name: 'Legacy Support', + allowed_groups: expect.arrayContaining([{ id: 99, name: 'Legacy Support' }]), + }) + ) + ); + }); +}); diff --git a/frontend/src/lib/settings/ZammadConnectionManager.svelte b/frontend/src/lib/settings/ZammadConnectionManager.svelte new file mode 100644 index 000000000..2e5a3ae1d --- /dev/null +++ b/frontend/src/lib/settings/ZammadConnectionManager.svelte @@ -0,0 +1,554 @@ + + +
+ + {#snippet actions()} +
+ + + +
+ {/snippet} +
+ + {#if error}
{/if} + + {#if loading} +
+ {:else if connections.length === 0} + + {:else} +
+ {#each connections as connection} + {@const connectionAuthStatus = authStatus(connection)} +
+
+
+
+

{connection.name}

+ + {connection.enabled ? t('common.enabled') : t('common.disabled')} + + {connectionAuthStatus.label} +
+

{connection.base_url}

+ {#if connection.last_test_error} +

{connection.last_test_error}

+ {/if} +
+
+ + {#if connection.auth_method === 'oauth'} + + {/if} + + +
+
+ {#if metadataByConnection[connection.id]} +

+ {t('zammad.metadataSummary', { + groups: metadataByConnection[connection.id].groups.length, + states: metadataByConnection[connection.id].states.length, + })} +

+ {#if metadataByConnection[connection.id].correlation_field_verified === false} +

{t('zammad.correlationFieldNotVerified')}

+ {/if} + {#if metadataByConnection[connection.id].group_catalog_verified === false} +

{t('zammad.groupCatalogNotVerified')}

+ {/if} + {/if} +
+ {/each} +
+ {/if} +
+ + + (showModal = false)} /> +
{ event.preventDefault(); save(); }} class="p-4 space-y-4 max-h-[75vh] overflow-y-auto"> + + + + {#if editing} + +

+ {form.auth_method === 'oauth' ? t('zammad.oauth') : t('zammad.apiToken')} +

+
+ {:else} + +
+ + +
+
+ {/if} + {#if form.auth_method === 'oauth'} + + + + + + + {:else} + + + + {/if} + + + + {#if editing && metadataByConnection[editing.id]?.groups?.length} + + hasKnownGroupName(group) || group.id === Number(form.default_group_id)) + .map((group) => ({ value: String(group.id), label: groupLabel(group) }))} + /> + + +
+ {#each metadataByConnection[editing.id].groups as group} +
+ entry.id === group.id)} + onchange={(checked) => toggleAllowedGroup(group.id, checked)} + label={groupLabel(group)} + size="small" + /> + {#if !hasKnownGroupName(group) && form.allowed_groups.some((entry) => entry.id === group.id)} + entry.id === group.id)?.name || ''} + oninput={(event) => setAllowedGroupName(group.id, event.currentTarget.value)} + placeholder={t('settings.groups.groupName')} + ariaLabel={t('settings.groups.groupName')} + size="small" + /> + {/if} +
+ {/each} +
+
+ {:else} + +
+ + +
+
+ + (form.allowed_groups = parseAllowedGroups(event.currentTarget.value))} + placeholder="7:Support, 8:Escalations" + /> + + {/if} + + +

{t('zammad.correlationFieldHint')}

+
+ + ({ value: status.id, label: status.name })), + ]} size="small" /> + + + {#if editing && metadataByConnection[editing.id]?.states?.length} + +
+ {#each metadataByConnection[editing.id].states as state} + toggleClosedState(state.id, checked)} + label={state.name} + size="small" + /> + {/each} +
+
+ {:else} + + (form.closed_state_ids = event.currentTarget.value.split(',').map(Number).filter(Boolean))} + placeholder="4" + /> + + {/if} + + + {#if !form.applies_to_all_workspaces} + +
+ {#each workspaces as workspace} + toggleWorkspace(workspace.id, checked)} + label={`${workspace.key} - ${workspace.name}`} + size="small" + /> + {/each} +
+
+ {/if} + + +
+ + +
+ +
diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 0baad8934..659496787 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,5 +1,6 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'; import tailwindcss from '@tailwindcss/vite'; +import { svelteTesting } from '@testing-library/svelte/vite'; import { visualizer } from 'rollup-plugin-visualizer'; import { defineConfig } from 'vite'; import { excalidrawAssetsPlugin } from './scripts/excalidraw-assets.js'; @@ -30,6 +31,7 @@ export default defineConfig({ base: './', plugins: [ svelte(), // Uses svelte.config.js for preprocessors + svelteTesting(), tailwindcss(), excalidrawAssetsPlugin(), ...(enableBundleAnalyzer diff --git a/internal/aitools/items.go b/internal/aitools/items.go index 1e9371c69..e5754dd01 100644 --- a/internal/aitools/items.go +++ b/internal/aitools/items.go @@ -461,6 +461,9 @@ func init() { if errors.Is(err, services.ErrItemDeletionForbidden) { return map[string]string{"error": "permission denied"}, nil } + if errors.Is(err, services.ErrItemHasProtectedIntegrationLinks) { + return map[string]string{"error": "remove all protected integration links from the item or its descendants before deleting them"}, nil + } return nil, err } env.AuditWrite(logger.ResourceItem, itemID, "delete_item", result.Item.Title) diff --git a/internal/database/migrations.go b/internal/database/migrations.go index 761e52c17..d58a5b170 100644 --- a/internal/database/migrations.go +++ b/internal/database/migrations.go @@ -8,6 +8,128 @@ import ( "slices" ) +const zammadSchemaMigrationSQLite = ` + CREATE TABLE zammad_connections ( + provider_id TEXT PRIMARY KEY, + credential_id INTEGER NOT NULL, + base_url TEXT NOT NULL, + default_group_id INTEGER, + default_group_name TEXT DEFAULT '', + allowed_group_ids TEXT NOT NULL DEFAULT '[]', + default_customer TEXT NOT NULL, + correlation_field TEXT NOT NULL DEFAULT 'windshift_item_key', + closed_state_ids TEXT NOT NULL DEFAULT '[]', + completion_status_id INTEGER, + applies_to_all_workspaces BOOLEAN NOT NULL DEFAULT false, + last_tested_at DATETIME, + last_test_error TEXT DEFAULT '', + created_by INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (provider_id) REFERENCES integration_providers(id) ON DELETE CASCADE, + FOREIGN KEY (credential_id) REFERENCES action_credentials(id) ON DELETE RESTRICT, + FOREIGN KEY (completion_status_id) REFERENCES statuses(id) ON DELETE SET NULL, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ); + CREATE INDEX idx_zammad_connections_credential ON zammad_connections(credential_id); + CREATE TABLE zammad_connection_workspaces ( + provider_id TEXT NOT NULL, + workspace_id INTEGER NOT NULL, + PRIMARY KEY (provider_id, workspace_id), + FOREIGN KEY (provider_id) REFERENCES zammad_connections(provider_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + CREATE INDEX idx_zammad_connection_workspaces_workspace ON zammad_connection_workspaces(workspace_id); + CREATE TABLE zammad_ticket_links ( + id TEXT PRIMARY KEY, + item_id INTEGER NOT NULL, + provider_id TEXT NOT NULL, + item_integration_link_id TEXT, + ticket_id INTEGER, + ticket_number TEXT DEFAULT '', + ticket_url TEXT DEFAULT '', + group_id INTEGER, + group_name TEXT DEFAULT '', + correlation_key TEXT NOT NULL, + sync_state TEXT NOT NULL DEFAULT 'pending', + creating_started_at DATETIME, + last_status_id INTEGER, + last_status_name TEXT DEFAULT '', + last_synced_at DATETIME, + last_error TEXT DEFAULT '', + completion_applied BOOLEAN NOT NULL DEFAULT false, + sync_lock_until DATETIME, + created_by INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, + FOREIGN KEY (provider_id) REFERENCES zammad_connections(provider_id) ON DELETE CASCADE, + FOREIGN KEY (item_integration_link_id) REFERENCES item_integration_links(id) ON DELETE SET NULL, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE(item_id, provider_id), + UNIQUE(provider_id, ticket_id), + UNIQUE(provider_id, correlation_key) + ); + CREATE INDEX idx_zammad_ticket_links_item ON zammad_ticket_links(item_id); + CREATE INDEX idx_zammad_ticket_links_sync ON zammad_ticket_links(sync_state, last_synced_at); +` + +const zammadSchemaMigrationPostgres = ` + CREATE TABLE zammad_connections ( + provider_id TEXT PRIMARY KEY REFERENCES integration_providers(id) ON DELETE CASCADE, + credential_id INTEGER NOT NULL REFERENCES action_credentials(id) ON DELETE RESTRICT, + base_url TEXT NOT NULL, + default_group_id INTEGER, + default_group_name TEXT DEFAULT '', + allowed_group_ids TEXT NOT NULL DEFAULT '[]', + default_customer TEXT NOT NULL, + correlation_field TEXT NOT NULL DEFAULT 'windshift_item_key', + closed_state_ids TEXT NOT NULL DEFAULT '[]', + completion_status_id INTEGER REFERENCES statuses(id) ON DELETE SET NULL, + applies_to_all_workspaces BOOLEAN NOT NULL DEFAULT false, + last_tested_at TIMESTAMPTZ, + last_test_error TEXT DEFAULT '', + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + CREATE INDEX idx_zammad_connections_credential ON zammad_connections(credential_id); + CREATE TABLE zammad_connection_workspaces ( + provider_id TEXT NOT NULL REFERENCES zammad_connections(provider_id) ON DELETE CASCADE, + workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + PRIMARY KEY (provider_id, workspace_id) + ); + CREATE INDEX idx_zammad_connection_workspaces_workspace ON zammad_connection_workspaces(workspace_id); + CREATE TABLE zammad_ticket_links ( + id TEXT PRIMARY KEY, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + provider_id TEXT NOT NULL REFERENCES zammad_connections(provider_id) ON DELETE CASCADE, + item_integration_link_id TEXT REFERENCES item_integration_links(id) ON DELETE SET NULL, + ticket_id INTEGER, + ticket_number TEXT DEFAULT '', + ticket_url TEXT DEFAULT '', + group_id INTEGER, + group_name TEXT DEFAULT '', + correlation_key TEXT NOT NULL, + sync_state TEXT NOT NULL DEFAULT 'pending', + creating_started_at TIMESTAMPTZ, + last_status_id INTEGER, + last_status_name TEXT DEFAULT '', + last_synced_at TIMESTAMPTZ, + last_error TEXT DEFAULT '', + completion_applied BOOLEAN NOT NULL DEFAULT false, + sync_lock_until TIMESTAMPTZ, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(item_id, provider_id), + UNIQUE(provider_id, ticket_id), + UNIQUE(provider_id, correlation_key) + ); + CREATE INDEX idx_zammad_ticket_links_item ON zammad_ticket_links(item_id); + CREATE INDEX idx_zammad_ticket_links_sync ON zammad_ticket_links(sync_state, last_synced_at); +` + // Migration is one entry in the schema_migrations catalog. Version is a // stable slug used as the schema_migrations primary key; Name is a human // label. CheckSQLite / CheckPostgres are queries that return COUNT >= 1 @@ -499,6 +621,307 @@ var Catalog = []Migration{ Postgres: `ALTER TABLE sso_providers ALTER COLUMN attribute_mapping SET DEFAULT '{"email":"email","name":"name","given_name":"given_name","family_name":"family_name","username":"preferred_username","email_verified":"email_verified"}'`, ApplySQLite: applySQLiteSSOAttributeMappingDefault, }, + { + Version: "20260829_zammad_integration", + Name: "Add Zammad connections and durable ticket links", + CheckSQLite: sqliteTableCheck("zammad_ticket_links"), + CheckPostgres: pgTableCheck("zammad_ticket_links"), + SQLite: zammadSchemaMigrationSQLite, + Postgres: zammadSchemaMigrationPostgres, + }, + { + Version: "20260830_zammad_oauth_connections", + Name: "Add connection-scoped Zammad OAuth credentials", + CheckSQLite: sqliteTableCheck("zammad_oauth_tokens"), + CheckPostgres: pgTableCheck("zammad_oauth_tokens"), + SQLite: ` + ALTER TABLE zammad_connections ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'api_token'; + CREATE TABLE zammad_oauth_tokens ( + provider_id TEXT PRIMARY KEY REFERENCES zammad_connections(provider_id) ON DELETE CASCADE, + expires_at DATETIME NOT NULL, + reauthorization_required BOOLEAN NOT NULL DEFAULT false, refresh_lock_until DATETIME, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE zammad_oauth_state ( + state TEXT PRIMARY KEY, provider_id TEXT NOT NULL REFERENCES zammad_connections(provider_id) ON DELETE CASCADE, + initiated_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX idx_zammad_oauth_state_expires ON zammad_oauth_state(expires_at); + `, + Postgres: ` + ALTER TABLE zammad_connections ADD COLUMN IF NOT EXISTS auth_method TEXT NOT NULL DEFAULT 'api_token'; + CREATE TABLE zammad_oauth_tokens ( + provider_id TEXT PRIMARY KEY REFERENCES zammad_connections(provider_id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + reauthorization_required BOOLEAN NOT NULL DEFAULT false, refresh_lock_until TIMESTAMPTZ, updated_at TIMESTAMPTZ DEFAULT NOW() + ); + CREATE TABLE zammad_oauth_state ( + state TEXT PRIMARY KEY, provider_id TEXT NOT NULL REFERENCES zammad_connections(provider_id) ON DELETE CASCADE, + initiated_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() + ); + CREATE INDEX idx_zammad_oauth_state_expires ON zammad_oauth_state(expires_at); + `, + }, + { + Version: "20260830_zammad_oauth_generation", + Name: "Bind Zammad OAuth writes to connection generations and refresh claims", + CheckSQLite: sqliteColumnCheck("zammad_connections", "oauth_generation"), + CheckPostgres: pgColumnCheck("zammad_connections", "oauth_generation"), + SQLite: ` + ALTER TABLE zammad_connections ADD COLUMN oauth_generation INTEGER NOT NULL DEFAULT 1; + ALTER TABLE zammad_connections ADD COLUMN oauth_attempt_id TEXT; + ALTER TABLE zammad_oauth_tokens ADD COLUMN oauth_generation INTEGER NOT NULL DEFAULT 1; + ALTER TABLE zammad_oauth_tokens ADD COLUMN refresh_claim_owner TEXT; + ALTER TABLE zammad_oauth_state ADD COLUMN oauth_generation INTEGER NOT NULL DEFAULT 1; + DELETE FROM zammad_oauth_state; + CREATE UNIQUE INDEX idx_zammad_oauth_state_provider ON zammad_oauth_state(provider_id); + `, + Postgres: ` + ALTER TABLE zammad_connections ADD COLUMN IF NOT EXISTS oauth_generation BIGINT NOT NULL DEFAULT 1; + ALTER TABLE zammad_connections ADD COLUMN IF NOT EXISTS oauth_attempt_id TEXT; + ALTER TABLE zammad_oauth_tokens ADD COLUMN IF NOT EXISTS oauth_generation BIGINT NOT NULL DEFAULT 1; + ALTER TABLE zammad_oauth_tokens ADD COLUMN IF NOT EXISTS refresh_claim_owner TEXT; + ALTER TABLE zammad_oauth_state ADD COLUMN IF NOT EXISTS oauth_generation BIGINT NOT NULL DEFAULT 1; + DELETE FROM zammad_oauth_state; + CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_oauth_state_provider ON zammad_oauth_state(provider_id); + `, + }, + { + Version: "20260830_zammad_ticket_link_metadata", + Name: "Add Zammad ticket ownership and retry metadata", + CheckSQLite: ` + SELECT CASE WHEN COUNT(*) = 4 THEN 1 ELSE 0 END + FROM pragma_table_info('zammad_ticket_links') + WHERE name IN ('owner_id', 'owner_name', 'last_attempt_at', 'next_attempt_at') + `, + CheckPostgres: ` + SELECT CASE WHEN COUNT(*) = 4 THEN 1 ELSE 0 END + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'zammad_ticket_links' + AND column_name IN ('owner_id', 'owner_name', 'last_attempt_at', 'next_attempt_at') + `, + SQLite: ` + ALTER TABLE zammad_ticket_links ADD COLUMN owner_id INTEGER; + ALTER TABLE zammad_ticket_links ADD COLUMN owner_name TEXT DEFAULT ''; + ALTER TABLE zammad_ticket_links ADD COLUMN last_attempt_at DATETIME; + ALTER TABLE zammad_ticket_links ADD COLUMN next_attempt_at DATETIME; + `, + Postgres: ` + ALTER TABLE zammad_ticket_links ADD COLUMN IF NOT EXISTS owner_id INTEGER; + ALTER TABLE zammad_ticket_links ADD COLUMN IF NOT EXISTS owner_name TEXT DEFAULT ''; + ALTER TABLE zammad_ticket_links ADD COLUMN IF NOT EXISTS last_attempt_at TIMESTAMPTZ; + ALTER TABLE zammad_ticket_links ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMPTZ; + `, + }, + { + Version: "20260830_zammad_ticket_link_completion_postgres", + Name: "Backfill missing PostgreSQL Zammad completion marker", + CheckSQLite: `SELECT 1`, + CheckPostgres: pgColumnCheck("zammad_ticket_links", "completion_applied"), + SQLite: `SELECT 1;`, + Postgres: ` + ALTER TABLE zammad_ticket_links ADD COLUMN IF NOT EXISTS completion_applied BOOLEAN NOT NULL DEFAULT false; + `, + }, + { + Version: "20260831_zammad_connection_config_revision", + Name: "Add optimistic revision for Zammad connection configuration", + CheckSQLite: sqliteColumnCheck("zammad_connections", "config_revision"), + CheckPostgres: pgColumnCheck("zammad_connections", "config_revision"), + SQLite: ` + ALTER TABLE zammad_connections ADD COLUMN config_revision INTEGER NOT NULL DEFAULT 1; + `, + Postgres: ` + ALTER TABLE zammad_connections ADD COLUMN IF NOT EXISTS config_revision BIGINT NOT NULL DEFAULT 1; + `, + }, + { + Version: "20260831_zammad_ticket_sync_lock_owner", + Name: "Bind Zammad ticket sync leases to their owner", + CheckSQLite: sqliteColumnCheck("zammad_ticket_links", "sync_lock_owner"), + CheckPostgres: pgColumnCheck("zammad_ticket_links", "sync_lock_owner"), + SQLite: ` + ALTER TABLE zammad_ticket_links ADD COLUMN sync_lock_owner TEXT; + `, + Postgres: ` + ALTER TABLE zammad_ticket_links ADD COLUMN IF NOT EXISTS sync_lock_owner TEXT; + `, + }, + { + Version: "20260831_zammad_ticket_link_item_restrict", + Name: "Require Zammad ticket unlink before item deletion", + CheckSQLite: ` + SELECT COUNT(*) + FROM pragma_foreign_key_list('zammad_ticket_links') + WHERE "table" = 'items' AND "from" = 'item_id' AND on_delete = 'RESTRICT' + `, + CheckPostgres: ` + SELECT COUNT(*) + FROM information_schema.referential_constraints rc + JOIN information_schema.key_column_usage kcu + ON kcu.constraint_schema = rc.constraint_schema + AND kcu.constraint_name = rc.constraint_name + WHERE rc.constraint_schema = current_schema() + AND kcu.table_schema = current_schema() + AND kcu.table_name = 'zammad_ticket_links' + AND kcu.column_name = 'item_id' + AND rc.delete_rule = 'RESTRICT' + `, + SQLite: ` + CREATE TABLE zammad_ticket_links_item_restrict ( + id TEXT PRIMARY KEY, + item_id INTEGER NOT NULL, + provider_id TEXT NOT NULL, + item_integration_link_id TEXT, + ticket_id INTEGER, + ticket_number TEXT DEFAULT '', + ticket_url TEXT DEFAULT '', + group_id INTEGER, + group_name TEXT DEFAULT '', + owner_id INTEGER, + owner_name TEXT DEFAULT '', + correlation_key TEXT NOT NULL, + sync_state TEXT NOT NULL DEFAULT 'pending', + creating_started_at DATETIME, + last_status_id INTEGER, + last_status_name TEXT DEFAULT '', + last_synced_at DATETIME, + last_attempt_at DATETIME, + next_attempt_at DATETIME, + last_error TEXT DEFAULT '', + completion_applied BOOLEAN NOT NULL DEFAULT false, + sync_lock_until DATETIME, + sync_lock_owner TEXT, + created_by INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE RESTRICT, + FOREIGN KEY (provider_id) REFERENCES zammad_connections(provider_id) ON DELETE CASCADE, + FOREIGN KEY (item_integration_link_id) REFERENCES item_integration_links(id) ON DELETE SET NULL, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE(item_id, provider_id), + UNIQUE(provider_id, ticket_id), + UNIQUE(provider_id, correlation_key) + ); + INSERT INTO zammad_ticket_links_item_restrict ( + id, item_id, provider_id, item_integration_link_id, ticket_id, + ticket_number, ticket_url, group_id, group_name, owner_id, + owner_name, correlation_key, sync_state, creating_started_at, + last_status_id, last_status_name, last_synced_at, last_attempt_at, + next_attempt_at, last_error, completion_applied, sync_lock_until, + sync_lock_owner, created_by, created_at, updated_at + ) + SELECT + id, item_id, provider_id, item_integration_link_id, ticket_id, + ticket_number, ticket_url, group_id, group_name, owner_id, + owner_name, correlation_key, sync_state, creating_started_at, + last_status_id, last_status_name, last_synced_at, last_attempt_at, + next_attempt_at, last_error, completion_applied, sync_lock_until, + sync_lock_owner, created_by, created_at, updated_at + FROM zammad_ticket_links; + DROP TABLE zammad_ticket_links; + ALTER TABLE zammad_ticket_links_item_restrict RENAME TO zammad_ticket_links; + CREATE INDEX idx_zammad_ticket_links_item ON zammad_ticket_links(item_id); + CREATE INDEX idx_zammad_ticket_links_sync ON zammad_ticket_links(sync_state, last_synced_at); + `, + Postgres: ` + ALTER TABLE zammad_ticket_links + DROP CONSTRAINT zammad_ticket_links_item_id_fkey; + ALTER TABLE zammad_ticket_links + ADD CONSTRAINT zammad_ticket_links_item_id_fkey + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE RESTRICT; + `, + }, + { + Version: "20260831_zammad_persisted_group_catalog", + Name: "Persist Zammad group names for least-privilege runtime access", + CheckSQLite: sqliteColumnCheck("zammad_connections", "allowed_groups"), + CheckPostgres: pgColumnCheck("zammad_connections", "allowed_groups"), + SQLite: ` + ALTER TABLE zammad_connections ADD COLUMN allowed_groups TEXT NOT NULL DEFAULT '[]'; + UPDATE zammad_connections + SET allowed_groups = COALESCE(( + SELECT json_group_array(json_object( + 'id', CAST(group_id.value AS INTEGER), + 'name', CASE + WHEN CAST(group_id.value AS INTEGER) = default_group_id THEN default_group_name + ELSE '' + END + )) + FROM json_each(zammad_connections.allowed_group_ids) AS group_id + WHERE group_id.type = 'integer' AND CAST(group_id.value AS INTEGER) > 0 + ), '[]'); + `, + Postgres: ` + ALTER TABLE zammad_connections ADD COLUMN IF NOT EXISTS allowed_groups TEXT NOT NULL DEFAULT '[]'; + UPDATE zammad_connections + SET allowed_groups = COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'id', group_id.value::INTEGER, + 'name', CASE + WHEN group_id.value::INTEGER = default_group_id THEN default_group_name + ELSE '' + END + ))::TEXT + FROM jsonb_array_elements_text(zammad_connections.allowed_group_ids::jsonb) AS group_id(value) + WHERE group_id.value ~ '^[1-9][0-9]*$' + ), '[]'); + `, + }, + { + Version: "20260901_zammad_canonical_workspace_scope", + Name: "Backfill managed-credential scope for existing Zammad connections", + CheckSQLite: `SELECT CASE WHEN + NOT EXISTS (SELECT 1 FROM pragma_table_info('zammad_connections') WHERE name='applies_to_all_workspaces') + OR NOT EXISTS (SELECT 1 FROM sqlite_master WHERE type='table' AND name='zammad_connection_workspaces') + THEN 1 ELSE 0 END`, + CheckPostgres: `SELECT CASE WHEN + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='zammad_connections' AND column_name='applies_to_all_workspaces') + OR NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=current_schema() AND table_name='zammad_connection_workspaces') + THEN 1 ELSE 0 END`, + SQLite: ` + UPDATE action_credentials + SET applies_to_all_workspaces = ( + SELECT zc.applies_to_all_workspaces FROM zammad_connections zc + WHERE zc.credential_id = action_credentials.id + ) + WHERE id IN (SELECT credential_id FROM zammad_connections) + AND json_extract(secret_metadata, '$._windshift_managed_credential') = 'v1' + AND json_extract(secret_metadata, '$.managed_by') = 'zammad' + AND json_extract(secret_metadata, '$.owner_id') = ( + SELECT zc.provider_id FROM zammad_connections zc + WHERE zc.credential_id = action_credentials.id + ); + INSERT OR IGNORE INTO action_credential_workspaces (credential_id, workspace_id) + SELECT zc.credential_id, zcw.workspace_id + FROM zammad_connections zc + JOIN zammad_connection_workspaces zcw ON zcw.provider_id = zc.provider_id + JOIN action_credentials ac ON ac.id = zc.credential_id + WHERE zc.applies_to_all_workspaces = false + AND json_extract(ac.secret_metadata, '$._windshift_managed_credential') = 'v1' + AND json_extract(ac.secret_metadata, '$.managed_by') = 'zammad' + AND json_extract(ac.secret_metadata, '$.owner_id') = zc.provider_id; + `, + Postgres: ` + UPDATE action_credentials ac + SET applies_to_all_workspaces = zc.applies_to_all_workspaces + FROM zammad_connections zc + WHERE zc.credential_id = ac.id + AND ac.secret_metadata::jsonb ->> '_windshift_managed_credential' = 'v1' + AND ac.secret_metadata::jsonb ->> 'managed_by' = 'zammad' + AND ac.secret_metadata::jsonb ->> 'owner_id' = zc.provider_id; + INSERT INTO action_credential_workspaces (credential_id, workspace_id) + SELECT zc.credential_id, zcw.workspace_id + FROM zammad_connections zc + JOIN zammad_connection_workspaces zcw ON zcw.provider_id = zc.provider_id + JOIN action_credentials ac ON ac.id = zc.credential_id + WHERE zc.applies_to_all_workspaces = false + AND ac.secret_metadata::jsonb ->> '_windshift_managed_credential' = 'v1' + AND ac.secret_metadata::jsonb ->> 'managed_by' = 'zammad' + AND ac.secret_metadata::jsonb ->> 'owner_id' = zc.provider_id + ON CONFLICT (credential_id, workspace_id) DO NOTHING; + `, + }, } func applySQLiteSSOAttributeMappingDefault(db Database) (retErr error) { diff --git a/internal/database/schema/integrations.sql b/internal/database/schema/integrations.sql index bae1f5d71..edd96ac67 100644 --- a/internal/database/schema/integrations.sql +++ b/internal/database/schema/integrations.sql @@ -135,4 +135,102 @@ CREATE INDEX IF NOT EXISTS idx_todoist_task_links_user ON todoist_task_links(use CREATE INDEX IF NOT EXISTS idx_todoist_task_links_item ON todoist_task_links(item_id); CREATE INDEX IF NOT EXISTS idx_todoist_task_links_todoist ON todoist_task_links(todoist_task_id); +-- Zammad provider extension. Provider identity and visible item links remain +-- in the generic integration tables; these tables hold only Zammad-specific +-- connection policy and durable synchronization state. +CREATE TABLE IF NOT EXISTS zammad_connections ( + provider_id TEXT PRIMARY KEY, + credential_id INTEGER NOT NULL, + auth_method TEXT NOT NULL DEFAULT 'api_token', + oauth_generation INTEGER NOT NULL DEFAULT 1, + config_revision INTEGER NOT NULL DEFAULT 1, + oauth_attempt_id TEXT, + base_url TEXT NOT NULL, + default_group_id INTEGER, + default_group_name TEXT DEFAULT '', + allowed_groups TEXT NOT NULL DEFAULT '[]', + default_customer TEXT NOT NULL, + correlation_field TEXT NOT NULL DEFAULT 'windshift_item_key', + closed_state_ids TEXT NOT NULL DEFAULT '[]', + completion_status_id INTEGER, + last_tested_at DATETIME, + last_test_error TEXT DEFAULT '', + created_by INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (provider_id) REFERENCES integration_providers(id) ON DELETE CASCADE, + FOREIGN KEY (credential_id) REFERENCES action_credentials(id) ON DELETE RESTRICT, + FOREIGN KEY (completion_status_id) REFERENCES statuses(id) ON DELETE SET NULL, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_zammad_connections_credential ON zammad_connections(credential_id); + +-- Connection-scoped OAuth material. Zammad is a system/workspace integration, +-- so these tokens intentionally never use user_integration_tokens. +CREATE TABLE IF NOT EXISTS zammad_oauth_tokens ( + provider_id TEXT PRIMARY KEY, + oauth_generation INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + reauthorization_required BOOLEAN NOT NULL DEFAULT false, + refresh_lock_until DATETIME, + refresh_claim_owner TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (provider_id) REFERENCES zammad_connections(provider_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS zammad_oauth_state ( + state TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + initiated_by INTEGER NOT NULL, + oauth_generation INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (provider_id) REFERENCES zammad_connections(provider_id) ON DELETE CASCADE, + FOREIGN KEY (initiated_by) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_zammad_oauth_state_expires ON zammad_oauth_state(expires_at); +CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_oauth_state_provider ON zammad_oauth_state(provider_id); + +CREATE TABLE IF NOT EXISTS zammad_ticket_links ( + id TEXT PRIMARY KEY, + item_id INTEGER NOT NULL, + provider_id TEXT NOT NULL, + item_integration_link_id TEXT, + ticket_id INTEGER, + ticket_number TEXT DEFAULT '', + ticket_url TEXT DEFAULT '', + group_id INTEGER, + group_name TEXT DEFAULT '', + owner_id INTEGER, + owner_name TEXT DEFAULT '', + correlation_key TEXT NOT NULL, + sync_state TEXT NOT NULL DEFAULT 'pending', + creating_started_at DATETIME, + last_status_id INTEGER, + last_status_name TEXT DEFAULT '', + last_synced_at DATETIME, + last_attempt_at DATETIME, + next_attempt_at DATETIME, + last_error TEXT DEFAULT '', + completion_applied BOOLEAN NOT NULL DEFAULT false, + sync_lock_until DATETIME, + sync_lock_owner TEXT, + created_by INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE RESTRICT, + FOREIGN KEY (provider_id) REFERENCES zammad_connections(provider_id) ON DELETE CASCADE, + FOREIGN KEY (item_integration_link_id) REFERENCES item_integration_links(id) ON DELETE SET NULL, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE(item_id, provider_id), + UNIQUE(provider_id, ticket_id), + UNIQUE(provider_id, correlation_key) +); + +CREATE INDEX IF NOT EXISTS idx_zammad_ticket_links_item ON zammad_ticket_links(item_id); +CREATE INDEX IF NOT EXISTS idx_zammad_ticket_links_sync ON zammad_ticket_links(sync_state, last_synced_at); + -- migration: 20260615_todoist_sync_tables +-- migration: 20260829_zammad_integration diff --git a/internal/database/schema/integrations_postgres.sql b/internal/database/schema/integrations_postgres.sql index d0d92a6a2..3132f3b5c 100644 --- a/internal/database/schema/integrations_postgres.sql +++ b/internal/database/schema/integrations_postgres.sql @@ -119,3 +119,86 @@ CREATE TABLE IF NOT EXISTS todoist_task_links ( CREATE INDEX IF NOT EXISTS idx_todoist_task_links_user ON todoist_task_links(user_id); CREATE INDEX IF NOT EXISTS idx_todoist_task_links_item ON todoist_task_links(item_id); CREATE INDEX IF NOT EXISTS idx_todoist_task_links_todoist ON todoist_task_links(todoist_task_id); + +CREATE TABLE IF NOT EXISTS zammad_connections ( + provider_id TEXT PRIMARY KEY REFERENCES integration_providers(id) ON DELETE CASCADE, + credential_id INTEGER NOT NULL REFERENCES action_credentials(id) ON DELETE RESTRICT, + auth_method TEXT NOT NULL DEFAULT 'api_token', + oauth_generation BIGINT NOT NULL DEFAULT 1, + config_revision BIGINT NOT NULL DEFAULT 1, + oauth_attempt_id TEXT, + base_url TEXT NOT NULL, + default_group_id INTEGER, + default_group_name TEXT DEFAULT '', + allowed_groups TEXT NOT NULL DEFAULT '[]', + default_customer TEXT NOT NULL, + correlation_field TEXT NOT NULL DEFAULT 'windshift_item_key', + closed_state_ids TEXT NOT NULL DEFAULT '[]', + completion_status_id INTEGER REFERENCES statuses(id) ON DELETE SET NULL, + last_tested_at TIMESTAMPTZ, + last_test_error TEXT DEFAULT '', + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_zammad_connections_credential ON zammad_connections(credential_id); + +CREATE TABLE IF NOT EXISTS zammad_oauth_tokens ( + provider_id TEXT PRIMARY KEY REFERENCES zammad_connections(provider_id) ON DELETE CASCADE, + oauth_generation BIGINT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + reauthorization_required BOOLEAN NOT NULL DEFAULT false, + refresh_lock_until TIMESTAMPTZ, + refresh_claim_owner TEXT, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS zammad_oauth_state ( + state TEXT PRIMARY KEY, + provider_id TEXT NOT NULL REFERENCES zammad_connections(provider_id) ON DELETE CASCADE, + initiated_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + oauth_generation BIGINT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_zammad_oauth_state_expires ON zammad_oauth_state(expires_at); +CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_oauth_state_provider ON zammad_oauth_state(provider_id); + +CREATE TABLE IF NOT EXISTS zammad_ticket_links ( + id TEXT PRIMARY KEY, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE RESTRICT, + provider_id TEXT NOT NULL REFERENCES zammad_connections(provider_id) ON DELETE CASCADE, + item_integration_link_id TEXT REFERENCES item_integration_links(id) ON DELETE SET NULL, + ticket_id INTEGER, + ticket_number TEXT DEFAULT '', + ticket_url TEXT DEFAULT '', + group_id INTEGER, + group_name TEXT DEFAULT '', + owner_id INTEGER, + owner_name TEXT DEFAULT '', + correlation_key TEXT NOT NULL, + sync_state TEXT NOT NULL DEFAULT 'pending', + creating_started_at TIMESTAMPTZ, + last_status_id INTEGER, + last_status_name TEXT DEFAULT '', + last_synced_at TIMESTAMPTZ, + last_attempt_at TIMESTAMPTZ, + next_attempt_at TIMESTAMPTZ, + last_error TEXT DEFAULT '', + completion_applied BOOLEAN NOT NULL DEFAULT false, + sync_lock_until TIMESTAMPTZ, + sync_lock_owner TEXT, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(item_id, provider_id), + UNIQUE(provider_id, ticket_id), + UNIQUE(provider_id, correlation_key) +); + +CREATE INDEX IF NOT EXISTS idx_zammad_ticket_links_item ON zammad_ticket_links(item_id); +CREATE INDEX IF NOT EXISTS idx_zammad_ticket_links_sync ON zammad_ticket_links(sync_state, last_synced_at); + +-- migration: 20260829_zammad_integration diff --git a/internal/database/zammad_schema_test.go b/internal/database/zammad_schema_test.go new file mode 100644 index 000000000..552a8cad0 --- /dev/null +++ b/internal/database/zammad_schema_test.go @@ -0,0 +1,332 @@ +package database + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +func TestZammadSchemaInitializesOnSQLite(t *testing.T) { + db, err := NewSQLiteDB(filepath.Join(t.TempDir(), "windshift-test.db")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + if err := db.Initialize(); err != nil { + t.Fatalf("initialize SQLite schema: %v", err) + } + + for _, table := range []string{"zammad_connections", "zammad_ticket_links", "zammad_oauth_tokens", "zammad_oauth_state"} { + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("expected table %s", table) + } + } + + for _, version := range []string{"20260829_zammad_integration", "20260830_zammad_oauth_connections", "20260830_zammad_oauth_generation", "20260830_zammad_ticket_link_metadata", "20260830_zammad_ticket_link_completion_postgres", "20260831_zammad_connection_config_revision", "20260831_zammad_ticket_sync_lock_owner", "20260831_zammad_ticket_link_item_restrict", "20260831_zammad_persisted_group_catalog", "20260901_zammad_canonical_workspace_scope"} { + var migrationCount int + if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version=?", version).Scan(&migrationCount); err != nil { + t.Fatal(err) + } + if migrationCount != 1 { + t.Fatalf("Zammad migration %s was not stamped", version) + } + } + rows, err := db.Query("PRAGMA table_info(zammad_connections)") + if err != nil { + t.Fatal(err) + } + defer func() { _ = rows.Close() }() + var requiredCredential, authMethod, oauthGeneration, configRevision, oauthAttempt bool + for rows.Next() { + var cid int + var name, columnType string + var notNull, primaryKey int + var defaultValue any + if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil { + t.Fatal(err) + } + if name == "credential_id" { + requiredCredential = notNull == 1 + } + if name == "auth_method" { + authMethod = true + } + if name == "oauth_generation" { + oauthGeneration = notNull == 1 + } + if name == "config_revision" { + configRevision = notNull == 1 + } + if name == "oauth_attempt_id" { + oauthAttempt = true + } + } + if !requiredCredential || !authMethod || !oauthGeneration || !configRevision || !oauthAttempt { + t.Fatal("Zammad OAuth connection schema must retain the legacy managed-credential constraint") + } + for table, columns := range map[string][]string{ + "zammad_oauth_tokens": {"oauth_generation", "refresh_claim_owner"}, + "zammad_oauth_state": {"oauth_generation"}, + "zammad_ticket_links": {"sync_lock_owner"}, + } { + for _, column := range columns { + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?", table, column).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("expected %s.%s", table, column) + } + } + } + var stateProviderIndex int + if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_zammad_oauth_state_provider'").Scan(&stateProviderIndex); err != nil || stateProviderIndex != 1 { + t.Fatalf("expected unique per-connection OAuth state index: count=%d err=%v", stateProviderIndex, err) + } +} + +func TestZammadTicketLinkMetadataSQLiteUpgrade(t *testing.T) { + db, err := NewSQLiteDB(filepath.Join(t.TempDir(), "legacy-zammad-links.db")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + if err := db.Initialize(); err != nil { + t.Fatal(err) + } + + // Recreate the stamped baseline table without the metadata columns. This + // models an existing install whose 20260829 migration already ran. + if _, err := db.Exec("PRAGMA foreign_keys=OFF"); err != nil { + t.Fatal(err) + } + _, err = db.Exec(` + CREATE TABLE zammad_ticket_links_legacy ( + id TEXT PRIMARY KEY, item_id INTEGER NOT NULL, provider_id TEXT NOT NULL, + item_integration_link_id TEXT, ticket_id INTEGER, ticket_number TEXT DEFAULT '', + ticket_url TEXT DEFAULT '', group_id INTEGER, group_name TEXT DEFAULT '', + correlation_key TEXT NOT NULL, sync_state TEXT NOT NULL DEFAULT 'pending', + creating_started_at DATETIME, last_status_id INTEGER, last_status_name TEXT DEFAULT '', + last_synced_at DATETIME, last_error TEXT DEFAULT '', + completion_applied BOOLEAN NOT NULL DEFAULT false, sync_lock_until DATETIME, + created_by INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(item_id, provider_id), UNIQUE(provider_id, ticket_id), + UNIQUE(provider_id, correlation_key) + ); + DROP TABLE zammad_ticket_links; + ALTER TABLE zammad_ticket_links_legacy RENAME TO zammad_ticket_links; + DELETE FROM schema_migrations + WHERE version IN ( + '20260830_zammad_ticket_link_metadata', + '20260831_zammad_ticket_sync_lock_owner', + '20260831_zammad_ticket_link_item_restrict' + ); + `) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { + t.Fatal(err) + } + if err := runPendingMigrations(db, Catalog); err != nil { + t.Fatalf("run additive ticket-link metadata migration: %v", err) + } + + for _, column := range []string{"owner_id", "owner_name", "last_attempt_at", "next_attempt_at", "completion_applied", "sync_lock_owner"} { + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('zammad_ticket_links') WHERE name = ?", column).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("expected migrated zammad_ticket_links.%s", column) + } + } + var deleteRule string + if err := db.QueryRow(`SELECT on_delete FROM pragma_foreign_key_list('zammad_ticket_links') WHERE "table" = 'items' AND "from" = 'item_id'`).Scan(&deleteRule); err != nil { + t.Fatal(err) + } + if deleteRule != "RESTRICT" { + t.Fatalf("expected migrated item foreign key to restrict deletion, got %q", deleteRule) + } +} + +func TestZammadTicketLinkMetadataMigrationBackendParity(t *testing.T) { + var migration *Migration + for i := range Catalog { + if Catalog[i].Version == "20260830_zammad_ticket_link_metadata" { + migration = &Catalog[i] + break + } + } + if migration == nil { + t.Fatal("Zammad ticket-link metadata migration is missing") + } + for _, column := range []string{"owner_id", "owner_name", "last_attempt_at", "next_attempt_at"} { + if !strings.Contains(migration.CheckSQLite, column) || !strings.Contains(migration.CheckPostgres, column) { + t.Fatalf("metadata column %s is missing from one of the backend checks", column) + } + if !strings.Contains(migration.SQLite, "ADD COLUMN "+column) || !strings.Contains(migration.Postgres, "ADD COLUMN IF NOT EXISTS "+column) { + t.Fatalf("metadata column %s is missing from one of the backend migration bodies", column) + } + } + if !strings.Contains(zammadSchemaMigrationSQLite, "completion_applied") || !strings.Contains(zammadSchemaMigrationPostgres, "completion_applied") { + t.Fatal("baseline Zammad migrations must include completion_applied") + } + var completionMigration *Migration + for i := range Catalog { + if Catalog[i].Version == "20260830_zammad_ticket_link_completion_postgres" { + completionMigration = &Catalog[i] + break + } + } + if completionMigration == nil || !strings.Contains(completionMigration.Postgres, "ADD COLUMN IF NOT EXISTS completion_applied") { + t.Fatal("PostgreSQL completion_applied backfill migration is missing") + } +} + +func TestZammadTicketSyncLockOwnerMigrationBackendParity(t *testing.T) { + var migration *Migration + for i := range Catalog { + if Catalog[i].Version == "20260831_zammad_ticket_sync_lock_owner" { + migration = &Catalog[i] + break + } + } + if migration == nil { + t.Fatal("Zammad sync lock owner migration is missing") + } + if !strings.Contains(migration.CheckSQLite, "sync_lock_owner") || !strings.Contains(migration.CheckPostgres, "sync_lock_owner") { + t.Fatal("sync lock owner migration checks must cover both backends") + } + if !strings.Contains(migration.SQLite, "ADD COLUMN sync_lock_owner TEXT") || !strings.Contains(migration.Postgres, "ADD COLUMN IF NOT EXISTS sync_lock_owner TEXT") { + t.Fatal("sync lock owner migration bodies must cover both backends") + } + if strings.Contains(zammadSchemaMigrationSQLite, "sync_lock_owner TEXT") || strings.Contains(zammadSchemaMigrationPostgres, "sync_lock_owner TEXT") { + t.Fatal("historical Zammad migration must remain checksum-stable; sync_lock_owner belongs only in the additive migration") + } +} + +func TestZammadTicketLinkItemRestrictMigrationBackendParity(t *testing.T) { + var migration *Migration + for i := range Catalog { + if Catalog[i].Version == "20260831_zammad_ticket_link_item_restrict" { + migration = &Catalog[i] + break + } + } + if migration == nil { + t.Fatal("Zammad item deletion restriction migration is missing") + } + if !strings.Contains(migration.CheckSQLite, "on_delete = 'RESTRICT'") || !strings.Contains(migration.CheckPostgres, "delete_rule = 'RESTRICT'") { + t.Fatal("item deletion restriction migration checks must cover both backends") + } + if !strings.Contains(migration.SQLite, "REFERENCES items(id) ON DELETE RESTRICT") || !strings.Contains(migration.Postgres, "REFERENCES items(id) ON DELETE RESTRICT") { + t.Fatal("item deletion restriction migration bodies must cover both backends") + } + if !strings.Contains(zammadSchemaMigrationSQLite, "REFERENCES items(id) ON DELETE CASCADE") || !strings.Contains(zammadSchemaMigrationPostgres, "REFERENCES items(id) ON DELETE CASCADE") { + t.Fatal("historical Zammad migrations must remain checksum-stable") + } +} + +// This starts from the deployed 20260829 table shape. The OAuth migration is +// additive: it preserves an API-token connection and permits a new OAuth row +// with its mandatory managed-credential container. +func TestZammadOAuthSQLiteUpgradePreservesLegacyConnection(t *testing.T) { + db, err := NewSQLiteDB(filepath.Join(t.TempDir(), "legacy-zammad.db")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + if err := db.Initialize(); err != nil { + t.Fatal(err) + } + for _, statement := range []string{ + `INSERT INTO users(id, email, username, first_name, last_name) VALUES (9001, 'zammad-upgrade@example.test', 'zammad-upgrade', 'Zammad', 'Upgrade')`, + `INSERT INTO workspaces(id, name, key) VALUES (9100, 'Legacy scope', 'LEGACY')`, + `INSERT INTO action_credentials(id, name, credential_type, applies_to_all_workspaces, encrypted_secret, secret_metadata, is_enabled) VALUES (9001, 'legacy API', 'custom_header', true, 'opaque-ciphertext', '{"_windshift_managed_credential":"v1","managed_by":"zammad","owner_id":"legacy-zammad"}', true)`, + `INSERT INTO integration_providers(id, slug, name, provider_type, enabled, provider_config) VALUES ('legacy-zammad', 'legacy-zammad', 'Legacy Zammad', 'zammad', true, '{}')`, + `INSERT INTO zammad_connections(provider_id, credential_id, auth_method, base_url, default_group_id, default_group_name, allowed_groups, default_customer, closed_state_ids, created_by) VALUES ('legacy-zammad', 9001, 'api_token', 'https://legacy.example.test', 7, 'Support', '[]', 'robot@example.test', '[]', 9001)`, + } { + if _, err := db.ExecWrite(statement); err != nil { + t.Fatal(err) + } + } + if _, err := db.Exec("PRAGMA foreign_keys=OFF"); err != nil { + t.Fatal(err) + } + _, err = db.Exec(` + CREATE TABLE zammad_connections_legacy ( + provider_id TEXT PRIMARY KEY, credential_id INTEGER NOT NULL, base_url TEXT NOT NULL, default_group_id INTEGER, + default_group_name TEXT DEFAULT '', allowed_group_ids TEXT NOT NULL DEFAULT '[]', default_customer TEXT NOT NULL, + correlation_field TEXT NOT NULL DEFAULT 'windshift_item_key', closed_state_ids TEXT NOT NULL DEFAULT '[]', completion_status_id INTEGER, + applies_to_all_workspaces BOOLEAN NOT NULL DEFAULT false, last_tested_at DATETIME, last_test_error TEXT DEFAULT '', created_by INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO zammad_connections_legacy(provider_id, credential_id, base_url, default_group_id, default_group_name, allowed_group_ids, default_customer, correlation_field, closed_state_ids, completion_status_id, applies_to_all_workspaces, last_tested_at, last_test_error, created_by, created_at, updated_at) + SELECT provider_id, credential_id, base_url, default_group_id, default_group_name, allowed_groups, default_customer, correlation_field, closed_state_ids, completion_status_id, false, last_tested_at, last_test_error, created_by, created_at, updated_at FROM zammad_connections; + DROP TABLE zammad_connections; + ALTER TABLE zammad_connections_legacy RENAME TO zammad_connections; + CREATE TABLE zammad_connection_workspaces ( + provider_id TEXT NOT NULL, workspace_id INTEGER NOT NULL, + PRIMARY KEY (provider_id, workspace_id) + ); + INSERT INTO zammad_connection_workspaces(provider_id, workspace_id) VALUES ('legacy-zammad', 9100); + UPDATE zammad_connections SET allowed_group_ids = '[7,"bad",8,0]'; + DROP TABLE zammad_oauth_state; + DROP TABLE zammad_oauth_tokens; + DELETE FROM schema_migrations WHERE version = '20260830_zammad_oauth_connections'; + DELETE FROM schema_migrations WHERE version = '20260830_zammad_oauth_generation'; + DELETE FROM schema_migrations WHERE version = '20260831_zammad_connection_config_revision'; + DELETE FROM schema_migrations WHERE version = '20260831_zammad_persisted_group_catalog'; + DELETE FROM schema_migrations WHERE version = '20260901_zammad_canonical_workspace_scope'; + `) + if _, onErr := db.Exec("PRAGMA foreign_keys=ON"); onErr != nil { + t.Fatal(onErr) + } + if err != nil { + t.Fatal(err) + } + if err := runPendingMigrations(db, Catalog); err != nil { + t.Fatalf("run additive OAuth migration: %v", err) + } + var credentialID int + var configRevision int64 + var authMethod string + if err := db.QueryRow(`SELECT credential_id, auth_method, config_revision FROM zammad_connections WHERE provider_id='legacy-zammad'`).Scan(&credentialID, &authMethod, &configRevision); err != nil || credentialID != 9001 || authMethod != "api_token" || configRevision != 1 { + t.Fatalf("legacy API connection was not preserved: credential=%d method=%q revision=%d err=%v", credentialID, authMethod, configRevision, err) + } + var migratedGroupsJSON string + if err := db.QueryRow(`SELECT allowed_groups FROM zammad_connections WHERE provider_id='legacy-zammad'`).Scan(&migratedGroupsJSON); err != nil { + t.Fatal(err) + } + var migratedGroups []struct { + ID int `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal([]byte(migratedGroupsJSON), &migratedGroups); err != nil || len(migratedGroups) != 2 || migratedGroups[0].ID != 7 || migratedGroups[0].Name != "Support" || migratedGroups[1].ID != 8 || migratedGroups[1].Name != "" { + t.Fatalf("legacy group IDs were not migrated safely: groups=%#v raw=%q err=%v", migratedGroups, migratedGroupsJSON, err) + } + var appliesToAll bool + if err := db.QueryRow(`SELECT applies_to_all_workspaces FROM action_credentials WHERE id=9001`).Scan(&appliesToAll); err != nil || appliesToAll { + t.Fatalf("legacy restricted scope was not copied to the managed credential: applies_all=%v err=%v", appliesToAll, err) + } + var scopeRows int + if err := db.QueryRow(`SELECT COUNT(*) FROM action_credential_workspaces WHERE credential_id=9001 AND workspace_id=9100`).Scan(&scopeRows); err != nil || scopeRows != 1 { + t.Fatalf("legacy workspace allowlist was not copied to the managed credential: count=%d err=%v", scopeRows, err) + } + for _, statement := range []string{ + `INSERT INTO action_credentials(id, name, credential_type, applies_to_all_workspaces, encrypted_secret, is_enabled) VALUES (9002, 'OAuth pending', 'custom_header', true, 'opaque-pending-ciphertext', true)`, + `INSERT INTO integration_providers(id, slug, name, provider_type, enabled, oauth_client_id, oauth_client_secret_encrypted, provider_config) VALUES ('oauth-zammad', 'oauth-zammad', 'OAuth Zammad', 'zammad', true, 'client', 'opaque-ciphertext', '{}')`, + `INSERT INTO zammad_connections(provider_id, credential_id, auth_method, base_url, allowed_groups, default_customer, closed_state_ids, applies_to_all_workspaces, created_by) VALUES ('oauth-zammad', 9002, 'oauth', 'https://oauth.example.test', '[]', 'robot@example.test', '[]', true, 9001)`, + } { + if _, err := db.ExecWrite(statement); err != nil { + t.Fatalf("upgraded schema rejected OAuth pending managed credential: %v", err) + } + } +} diff --git a/internal/handlers/integration_item_links.go b/internal/handlers/integration_item_links.go index 3c97fbee2..cc2a5e629 100644 --- a/internal/handlers/integration_item_links.go +++ b/internal/handlers/integration_item_links.go @@ -10,6 +10,7 @@ import ( "time" "windshift/internal/database" + "windshift/internal/integrations" "windshift/internal/integrations/notion" "windshift/internal/logger" "windshift/internal/models" @@ -117,7 +118,9 @@ func (h *IntegrationItemLinksHandler) GetItemLinks(w http.ResponseWriter, r *htt if metadata.Valid { link.LinkMetadata = metadata.String } - links = append(links, link) + if integrations.SupportsGenericItemLinks(models.IntegrationProviderType(link.ProviderType)) { + links = append(links, link) + } } if err := rows.Err(); err != nil { respondInternalError(w, r, err) @@ -170,10 +173,10 @@ func (h *IntegrationItemLinksHandler) CreateItemLink(w http.ResponseWriter, r *h return } - // Verify provider exists and is enabled - var providerExists bool - err := h.db.QueryRow("SELECT EXISTS(SELECT 1 FROM integration_providers WHERE id = ? AND enabled = true)", req.ProviderID).Scan(&providerExists) - if err != nil || !providerExists { + // Verify provider exists, is enabled, and explicitly allows generic links. + var providerType models.IntegrationProviderType + err := h.db.QueryRow("SELECT provider_type FROM integration_providers WHERE id = ? AND enabled = true", req.ProviderID).Scan(&providerType) + if err != nil || !integrations.SupportsGenericItemLinks(providerType) { respondBadRequest(w, r, "Integration provider not found or disabled") return } @@ -234,6 +237,10 @@ func (h *IntegrationItemLinksHandler) DeleteItemLink(w http.ResponseWriter, r *h respondInternalError(w, r, fmt.Errorf("invalid item id on integration link: %w", err)) return } + if !integrations.SupportsGenericItemLinks(models.IntegrationProviderType(link.ProviderType)) { + respondNotFound(w, r, "integration_link") + return + } if !CheckItemPermission(w, r, repository.NewItemRepository(h.db), h.permissionService, itemID, models.PermissionItemEdit) { return @@ -245,7 +252,6 @@ func (h *IntegrationItemLinksHandler) DeleteItemLink(w http.ResponseWriter, r *h respondInternalError(w, r, err) return } - h.auditItemLink(r, user, logger.ActionIntegrationItemLinkDelete, link) w.WriteHeader(http.StatusNoContent) } @@ -276,6 +282,10 @@ func (h *IntegrationItemLinksHandler) RefreshItemLink(w http.ResponseWriter, r * } return } + if !integrations.SupportsGenericItemRefresh(providerType) { + respondNotFound(w, r, "integration_link") + return + } if !CheckItemPermission(w, r, repository.NewItemRepository(h.db), h.permissionService, itemID, models.PermissionItemView) { return diff --git a/internal/handlers/integration_oauth.go b/internal/handlers/integration_oauth.go index 7bcf93ebe..a8c05c3cb 100644 --- a/internal/handlers/integration_oauth.go +++ b/internal/handlers/integration_oauth.go @@ -10,12 +10,16 @@ import ( "log/slog" "net/http" "net/url" + "strings" "time" "windshift/internal/database" + "windshift/internal/integrations" "windshift/internal/integrations/notion" "windshift/internal/integrations/todoist" + "windshift/internal/logger" "windshift/internal/models" + "windshift/internal/repository" "windshift/internal/sso" "uuid" @@ -23,19 +27,74 @@ import ( // IntegrationOAuthHandler handles OAuth flows and user connection management type IntegrationOAuthHandler struct { - db database.Database - encryption *sso.SecretEncryption - baseURL string + db database.Database + encryption *sso.SecretEncryption + baseURL string + systemFlows map[models.IntegrationProviderType]systemOAuthRegistration +} + +type systemOAuthRegistration struct { + flow integrations.SystemOAuthFlow + auditor *logger.Auditor } // NewIntegrationOAuthHandler creates a new integration OAuth handler. // baseURL: public URL of the application (from config.Load). func NewIntegrationOAuthHandler(db database.Database, encryption *sso.SecretEncryption, baseURL string) *IntegrationOAuthHandler { return &IntegrationOAuthHandler{ - db: db, - encryption: encryption, - baseURL: baseURL, + db: db, + encryption: encryption, + baseURL: baseURL, + systemFlows: make(map[models.IntegrationProviderType]systemOAuthRegistration), + } +} + +// RegisterSystemOAuthFlow adds a system-owned provider extension to the same +// OAuth handler used by user-owned integrations. +func (h *IntegrationOAuthHandler) RegisterSystemOAuthFlow(providerType models.IntegrationProviderType, flow integrations.SystemOAuthFlow, auditor *logger.Auditor) { + capabilities, ok := integrations.Capabilities(providerType) + if !ok || !capabilities.OAuth || capabilities.CredentialOwner != integrations.CredentialOwnerSystem || flow == nil { + return + } + h.systemFlows[providerType] = systemOAuthRegistration{flow: flow, auditor: auditor} +} + +// StartSystemOAuth initiates OAuth for an administrator-managed provider row. +func (h *IntegrationOAuthHandler) StartSystemOAuth(w http.ResponseWriter, r *http.Request) { + user, ok := RequireAuth(w, r) + if !ok { + return + } + providerID := r.PathValue("id") + var providerType models.IntegrationProviderType + if err := h.db.QueryRow("SELECT provider_type FROM integration_providers WHERE id = ? AND enabled = true", providerID).Scan(&providerType); err != nil { + if errors.Is(err, sql.ErrNoRows) { + respondNotFound(w, r, "integration_provider") + } else { + respondInternalError(w, r, err) + } + return + } + registration, registered := h.systemFlows[providerType] + if !registered || registration.flow == nil { + respondBadRequest(w, r, "OAuth is not supported for this provider") + return + } + authURL, err := registration.flow.StartOAuth(r.Context(), providerID, user.ID, h.baseURL) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + respondNotFound(w, r, "integration_provider") + } else { + respondBadRequest(w, r, "OAuth is not configured for this provider") + } + return + } + if registration.auditor != nil { + registration.auditor.LogWithDetails(r, user, logger.ActionIntegrationProviderUpdate, logger.ResourceIntegrationProvider, nil, providerID, map[string]any{ + "provider_id": providerID, "provider_type": providerType, "oauth_started": true, + }) } + respondJSONOK(w, map[string]string{"auth_url": authURL}) } // StartOAuth initiates the OAuth flow for an integration provider @@ -64,6 +123,10 @@ func (h *IntegrationOAuthHandler) StartOAuth(w http.ResponseWriter, r *http.Requ } return } + if !integrations.SupportsUserOAuth(providerType) { + respondNotFound(w, r, "integration_provider") + return + } if !clientID.Valid || clientID.String == "" { respondBadRequest(w, r, "OAuth not configured for this provider") @@ -158,6 +221,10 @@ func (h *IntegrationOAuthHandler) OAuthCallback(w http.ResponseWriter, r *http.R h.redirectWithError(w, r, "Provider not found") return } + if !integrations.SupportsUserOAuth(providerType) { + h.redirectWithError(w, r, "Unsupported provider type") + return + } if !clientSecretEnc.Valid || clientSecretEnc.String == "" { h.redirectWithError(w, r, "OAuth secret not configured") @@ -190,6 +257,61 @@ func (h *IntegrationOAuthHandler) OAuthCallback(w http.ResponseWriter, r *http.R } } +// SystemOAuthCallback completes an administrator-managed provider flow on a +// path that cannot collide with user-configurable provider slugs. +func (h *IntegrationOAuthHandler) SystemOAuthCallback(w http.ResponseWriter, r *http.Request) { + providerType := models.IntegrationProviderType(r.PathValue("providerType")) + registration, ok := h.systemFlows[providerType] + if !ok || registration.flow == nil { + respondNotFound(w, r, "integration_provider") + return + } + h.handleSystemOAuthCallback(w, r, providerType, registration) +} + +func (h *IntegrationOAuthHandler) handleSystemOAuthCallback(w http.ResponseWriter, r *http.Request, providerType models.IntegrationProviderType, registration systemOAuthRegistration) { + state, code := r.URL.Query().Get("state"), r.URL.Query().Get("code") + var result *integrations.SystemOAuthCallbackResult + var err error + if state == "" || code == "" || r.URL.Query().Get("error") != "" { + if state != "" { + result, _ = registration.flow.ConsumeFailedOAuthCallback(state) + } + err = errors.New("OAuth callback was rejected") + } else { + result, err = registration.flow.CompleteOAuth(r.Context(), state, code, h.baseURL) + } + h.auditSystemOAuthCallback(r, providerType, registration.auditor, result, err == nil) + h.redirectSystemOAuthResult(w, r, providerType, err == nil) +} + +func (h *IntegrationOAuthHandler) auditSystemOAuthCallback(r *http.Request, providerType models.IntegrationProviderType, auditor *logger.Auditor, result *integrations.SystemOAuthCallbackResult, success bool) { + if auditor == nil || result == nil || result.Initiator == nil { + return + } + details := map[string]any{ + "provider_id": result.ProviderID, "provider_type": providerType, + "oauth_callback": true, "credential_mutation": true, "generation": result.Generation, + } + if success { + auditor.LogWithDetails(r, result.Initiator, logger.ActionIntegrationProviderOAuthCredentialSet, logger.ResourceIntegrationProvider, nil, result.ProviderName, details) + return + } + auditor.LogFailure(r, result.Initiator, logger.ActionIntegrationProviderOAuthCredentialSet, logger.ResourceIntegrationProvider, nil, result.ProviderName, "oauth_callback_failed", details) +} + +func (h *IntegrationOAuthHandler) redirectSystemOAuthResult(w http.ResponseWriter, r *http.Request, providerType models.IntegrationProviderType, success bool) { + result := "error" + if success { + result = "success" + } + contextPath := "" + if publicURL, err := url.Parse(h.baseURL); err == nil { + contextPath = strings.TrimRight(publicURL.EscapedPath(), "/") + } + http.Redirect(w, r, contextPath+"/admin/integration-providers?tab="+url.QueryEscape(string(providerType))+"&oauth="+url.QueryEscape(result), http.StatusFound) +} + func (h *IntegrationOAuthHandler) handleTodoistCallback(w http.ResponseWriter, r *http.Request, clientID, clientSecret, code, providerID, userID, providerSlug string) { result, err := todoist.ExchangeOAuthCode(clientID, clientSecret, code) if err != nil { @@ -386,7 +508,9 @@ func (h *IntegrationOAuthHandler) GetAvailableProviders(w http.ResponseWriter, r if err := rows.Scan(&p.ID, &p.Slug, &p.Name, &p.ProviderType); err != nil { continue } - providers = append(providers, p) + if integrations.SupportsUserOAuth(models.IntegrationProviderType(p.ProviderType)) { + providers = append(providers, p) + } } if err := rows.Err(); err != nil { respondInternalError(w, r, err) diff --git a/internal/handlers/integration_providers.go b/internal/handlers/integration_providers.go index 56295a48c..f2a3678fe 100644 --- a/internal/handlers/integration_providers.go +++ b/internal/handlers/integration_providers.go @@ -5,6 +5,7 @@ import ( "net/http" "time" + "windshift/internal/integrations" "windshift/internal/logger" "windshift/internal/models" "windshift/internal/repository" @@ -101,13 +102,10 @@ func (h *IntegrationProviderHandler) CreateProvider(w http.ResponseWriter, r *ht return } - // Validate provider type - validTypes := map[string]bool{ - string(models.IntegrationProviderNotion): true, - string(models.IntegrationProviderTodoist): true, - } - if !validTypes[req.ProviderType] { - respondBadRequest(w, r, "Invalid provider type. Supported: notion, todoist") + // Provider capabilities are declared centrally so adding a provider does + // not require another hardcoded allowlist in this handler. + if !integrations.SupportsAdminProviderCRUD(models.IntegrationProviderType(req.ProviderType)) { + respondBadRequest(w, r, "Provider type is not managed by this endpoint") return } diff --git a/internal/handlers/items.go b/internal/handlers/items.go index 39e135c4f..4c77e3098 100644 --- a/internal/handlers/items.go +++ b/internal/handlers/items.go @@ -1140,6 +1140,14 @@ func (h *ItemHandler) respondItemDeletionError(w http.ResponseWriter, r *http.Re respondNotFound(w, r, "item") return true } + if errors.Is(err, services.ErrItemHasProtectedIntegrationLinks) { + respondError(w, r, restapi.NewAPIError( + http.StatusConflict, + restapi.ErrCodeConflict, + "Remove all protected integration links from the affected items before deleting them.", + )) + return true + } respondInternalError(w, r, err) return true } diff --git a/internal/handlers/users.go b/internal/handlers/users.go index 7ec5b78c5..df2700eda 100644 --- a/internal/handlers/users.go +++ b/internal/handlers/users.go @@ -666,7 +666,17 @@ func (h *UserHandler) Delete(w http.ResponseWriter, r *http.Request) { return } - // Audit before anonymization so we capture the original details. + if err := h.offboardUser(id); err != nil { + if errors.Is(err, services.ErrUserOffboardingHasProtectedIntegrationLinks) { + respondConflict(w, r, "Cannot offboard user while their personal workspace contains protected integration links") + return + } + respondInternalError(w, r, err) + return + } + + // Audit after successful offboarding so a failed or blocked operation is + // never recorded as a successful deletion. if currentUser != nil { h.auditor.LogWithDetails(r, currentUser, logger.ActionUserDelete, logger.ResourceUser, @@ -678,11 +688,6 @@ func (h *UserHandler) Delete(w http.ResponseWriter, r *http.Request) { }, ) } - - if err := h.offboardUser(id); err != nil { - respondInternalError(w, r, err) - return - } h.invalidateUserSessions(id) w.WriteHeader(http.StatusNoContent) diff --git a/internal/handlers/workspaces_handler.go b/internal/handlers/workspaces_handler.go index 63ada563e..d334a627f 100644 --- a/internal/handlers/workspaces_handler.go +++ b/internal/handlers/workspaces_handler.go @@ -536,13 +536,12 @@ func (h *WorkspaceHandler) Delete(w http.ResponseWriter, r *http.Request) { return } - // Drop item number sequence for this workspace (PostgreSQL only, no-op for SQLite) - if err = h.repo.DropItemSequence(int64(id)); err != nil { - slog.Warn("failed to drop item sequence for workspace", slog.String("component", "workspaces"), slog.Int("workspace_id", id), slog.Any("error", err)) - } - - err = h.repo.Delete(id) + err = h.workspaceService.Delete(id) if err != nil { + if errors.Is(err, services.ErrWorkspaceHasProtectedIntegrationLinks) { + respondConflict(w, r, "Remove all protected integration links from this workspace before deleting it.") + return + } respondInternalError(w, r, err) return } diff --git a/internal/handlers/zammad.go b/internal/handlers/zammad.go new file mode 100644 index 000000000..c69545b58 --- /dev/null +++ b/internal/handlers/zammad.go @@ -0,0 +1,442 @@ +package handlers + +import ( + "errors" + "net/http" + "slices" + "strconv" + "strings" + + "windshift/internal/integrations/zammad" + "windshift/internal/logger" + "windshift/internal/models" + "windshift/internal/repository" + "windshift/internal/restapi" + "windshift/internal/services" + "windshift/internal/utils" +) + +type ZammadHandler struct { + itemRepo *repository.ItemRepository + service *services.ZammadService + permissionService *services.PermissionService + auditor *logger.Auditor + syncAllTrigger func() bool +} + +func NewZammadHandler(itemRepo *repository.ItemRepository, service *services.ZammadService, permissionService *services.PermissionService, auditor *logger.Auditor) *ZammadHandler { + return &ZammadHandler{itemRepo: itemRepo, service: service, permissionService: permissionService, auditor: auditor} +} + +func (h *ZammadHandler) SetSyncAllTrigger(trigger func() bool) { h.syncAllTrigger = trigger } + +func (h *ZammadHandler) ListConnections(w http.ResponseWriter, r *http.Request) { + connections, err := h.service.ListConnections() + if err != nil { + respondInternalError(w, r, err) + return + } + respondJSONOK(w, connections) +} + +func (h *ZammadHandler) GetConnection(w http.ResponseWriter, r *http.Request) { + connection, err := h.service.GetConnection(r.PathValue("id")) + if !h.respondServiceError(w, r, err) { + return + } + respondJSONOK(w, connection) +} + +func (h *ZammadHandler) CreateConnection(w http.ResponseWriter, r *http.Request) { + req, ok := decodeJSON[models.CreateZammadConnectionRequest](w, r) + if !ok { + return + } + user, ok := RequireAuth(w, r) + if !ok { + return + } + connection, err := h.service.CreateConnection(req, user.ID) + if !h.respondServiceError(w, r, err) { + return + } + h.audit(r, user, logger.ActionZammadConnectionCreate, logger.ResourceZammadConnection, connection.ProviderID, connection.Name, map[string]any{"workspace_ids": connection.WorkspaceIDs, "all_workspaces": connection.AppliesToAllWorkspaces}) + respondJSONCreated(w, connection) +} + +func (h *ZammadHandler) UpdateConnection(w http.ResponseWriter, r *http.Request) { + req, ok := decodeJSON[models.UpdateZammadConnectionRequest](w, r) + if !ok { + return + } + connection, err := h.service.UpdateConnection(r.PathValue("id"), req) + if !h.respondServiceError(w, r, err) { + return + } + h.audit(r, currentUser(r), logger.ActionZammadConnectionUpdate, logger.ResourceZammadConnection, connection.ProviderID, connection.Name, map[string]any{"token_rotated": req.APIToken != nil && *req.APIToken != ""}) + respondJSONOK(w, connection) +} + +func (h *ZammadHandler) DeleteConnection(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + connection, err := h.service.GetConnection(id) + if !h.respondServiceError(w, r, err) { + return + } + if !h.respondServiceError(w, r, h.service.DeleteConnection(id)) { + return + } + h.audit(r, currentUser(r), logger.ActionZammadConnectionDelete, logger.ResourceZammadConnection, id, connection.Name, nil) + w.WriteHeader(http.StatusNoContent) +} + +func (h *ZammadHandler) TestConnection(w http.ResponseWriter, r *http.Request) { + metadata, err := h.service.TestConnection(r.Context(), r.PathValue("id")) + if !h.respondServiceError(w, r, err) { + return + } + h.audit(r, currentUser(r), logger.ActionZammadConnectionTest, logger.ResourceZammadConnection, r.PathValue("id"), "", map[string]any{"groups": len(metadata.Groups), "states": len(metadata.States)}) + respondJSONOK(w, map[string]any{"ok": true, "metadata": metadata}) +} + +func (h *ZammadHandler) RefreshAllTickets(w http.ResponseWriter, r *http.Request) { + if h.syncAllTrigger == nil { + respondServiceUnavailable(w, r, "Zammad synchronization is not available") + return + } + started := h.syncAllTrigger() + h.audit(r, currentUser(r), logger.ActionZammadTicketRefreshAll, logger.ResourceZammadTicket, "all", "", map[string]any{ + "started": started, + }) + respondJSON(w, http.StatusAccepted, map[string]bool{"started": started}) +} + +func (h *ZammadHandler) RetryUncertainTicketCreation(w http.ResponseWriter, r *http.Request) { + user, ok := RequireAuth(w, r) + if !ok { + return + } + link, err := h.service.RetryUncertainTicketCreation(r.Context(), r.PathValue("linkId"), user.ID) + if !h.respondServiceError(w, r, err) { + return + } + h.audit(r, user, logger.ActionZammadTicketCreate, logger.ResourceZammadTicket, link.ID, link.TicketNumber, map[string]any{ + "item_id": link.ItemID, "connection_id": link.ProviderID, "ticket_id": link.TicketID, "admin_uncertain_override": true, + }) + respondJSONOK(w, link.Response()) +} + +// DetachTicketLinkLocally is an administrator-only recovery action. The route +// middleware enforces system-admin access; the handler records the deliberately +// orphan-prone operation so it remains distinguishable from a normal unlink. +func (h *ZammadHandler) DetachTicketLinkLocally(w http.ResponseWriter, r *http.Request) { + user, ok := RequireAuth(w, r) + if !ok { + return + } + link, err := h.service.DetachTicketLinkLocally(r.PathValue("linkId")) + if !h.respondServiceError(w, r, err) { + return + } + h.audit(r, user, logger.ActionZammadTicketDetachLocal, logger.ResourceZammadTicket, link.ID, link.TicketNumber, map[string]any{ + "item_id": link.ItemID, "connection_id": link.ProviderID, "ticket_id": link.TicketID, + "remote_correlation_may_remain": true, + }) + w.WriteHeader(http.StatusNoContent) +} + +func (h *ZammadHandler) ListWorkspaceConnections(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := requireIDParam(w, r, "workspaceId") + if !ok { + return + } + user, ok := RequireAuth(w, r) + if !ok || !RequireWorkspacePermission(w, r, user.ID, workspaceID, models.PermissionItemView, h.permissionService) { + return + } + connections, err := h.service.ListConnectionsForWorkspace(workspaceID) + if err != nil { + respondInternalError(w, r, err) + return + } + responses := make([]models.ZammadWorkspaceConnection, 0, len(connections)) + for _, connection := range connections { + ready := connection.AuthMethod == models.ZammadAuthMethodAPIToken && connection.HasAPIToken + if connection.AuthMethod == models.ZammadAuthMethodOAuth { + ready = connection.OAuthConnected && !connection.ReauthorizationRequired + } + ready = ready && zammadConnectionHasUsableDefaultGroup(connection) + responses = append(responses, models.ZammadWorkspaceConnection{ + ProviderID: connection.ProviderID, Name: connection.Name, AuthMethod: connection.AuthMethod, + Ready: ready, OAuthConnected: connection.OAuthConnected, ReauthorizationRequired: connection.ReauthorizationRequired, + DefaultGroupID: connection.DefaultGroupID, DefaultGroupName: connection.DefaultGroupName, + AllowedGroups: connection.AllowedGroups, + }) + } + respondJSONOK(w, responses) +} + +// zammadConnectionHasUsableDefaultGroup checks only locally persisted +// configuration. A name-based default is usable without an allowlist, but +// cannot be proven to belong to a non-empty allowlist until remote metadata +// resolves it to an ID. Do not present that ambiguous configuration as ready. +func zammadConnectionHasUsableDefaultGroup(connection *models.ZammadConnection) bool { + if connection.DefaultGroupID <= 0 { + return strings.TrimSpace(connection.DefaultGroupName) != "" && len(connection.AllowedGroups) == 0 + } + return len(connection.AllowedGroups) == 0 || slices.ContainsFunc(connection.AllowedGroups, func(group models.ZammadGroupRef) bool { + return group.ID == connection.DefaultGroupID && strings.TrimSpace(group.Name) != "" + }) +} + +func (h *ZammadHandler) GetWorkspaceMetadata(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := requireIDParam(w, r, "workspaceId") + if !ok { + return + } + user, ok := RequireAuth(w, r) + if !ok || !RequireWorkspacePermission(w, r, user.ID, workspaceID, models.PermissionItemView, h.permissionService) { + return + } + metadata, err := h.service.MetadataForWorkspace(r.Context(), r.PathValue("id"), workspaceID) + if !h.respondServiceError(w, r, err) { + return + } + respondJSONOK(w, metadata) +} + +func (h *ZammadHandler) GetWorkspaceOwners(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := requireIDParam(w, r, "workspaceId") + if !ok { + return + } + user, ok := RequireAuth(w, r) + if !ok || !RequireWorkspacePermission(w, r, user.ID, workspaceID, models.PermissionItemEdit, h.permissionService) { + return + } + groupID, err := strconv.Atoi(r.URL.Query().Get("group_id")) + if err != nil || groupID <= 0 { + respondValidationError(w, r, "group_id must be positive") + return + } + owners, err := h.service.OwnersForWorkspace(r.Context(), r.PathValue("id"), workspaceID, groupID) + if !h.respondServiceError(w, r, err) { + return + } + respondJSONOK(w, owners) +} + +func (h *ZammadHandler) GetItemLinks(w http.ResponseWriter, r *http.Request) { + itemID, ok := requireIDParam(w, r, "id") + if !ok { + return + } + if !CheckItemPermission(w, r, h.itemRepo, h.permissionService, itemID, models.PermissionItemView) { + return + } + links, err := h.service.TicketLinksForItem(itemID) + if err != nil { + respondInternalError(w, r, err) + return + } + responses := make([]models.ZammadTicketLinkResponse, 0, len(links)) + for _, link := range links { + responses = append(responses, link.Response()) + } + respondJSONOK(w, responses) +} + +// ResolveTicketLink turns the opaque correlation key stored in Zammad into a +// current Windshift item destination. Item permissions are checked before the +// destination is disclosed so the link cannot be used to enumerate items. +func (h *ZammadHandler) ResolveTicketLink(w http.ResponseWriter, r *http.Request) { + user, ok := RequireAuth(w, r) + if !ok { + return + } + itemID, workspaceID, err := h.service.ResolveTicketLink(r.PathValue("correlationKey")) + if err != nil { + respondNotFound(w, r, "Item") + return + } + allowed, err := h.permissionService.HasWorkspacePermission(user.ID, workspaceID, models.PermissionItemView) + if err != nil || !allowed { + respondNotFound(w, r, "Item") + return + } + respondJSONOK(w, map[string]int{"workspace_id": workspaceID, "item_id": itemID}) +} + +func (h *ZammadHandler) CreateTicket(w http.ResponseWriter, r *http.Request) { + itemID, ok := requireIDParam(w, r, "id") + if !ok { + return + } + if !CheckItemPermission(w, r, h.itemRepo, h.permissionService, itemID, models.PermissionItemEdit) { + return + } + user, ok := RequireAuth(w, r) + if !ok { + return + } + req, ok := decodeJSON[models.CreateZammadTicketRequest](w, r) + if !ok { + return + } + link, err := h.service.CreateTicket(r.Context(), itemID, user.ID, req) + if !h.respondServiceError(w, r, err) { + return + } + h.audit(r, user, logger.ActionZammadTicketCreate, logger.ResourceZammadTicket, link.ID, link.TicketNumber, map[string]any{"item_id": link.ItemID, "connection_id": link.ProviderID, "ticket_id": link.TicketID, "sync_state": link.SyncState}) + respondJSONOK(w, link.Response()) +} + +func (h *ZammadHandler) LinkExistingTicket(w http.ResponseWriter, r *http.Request) { + itemID, ok := requireIDParam(w, r, "id") + if !ok { + return + } + if !CheckItemPermission(w, r, h.itemRepo, h.permissionService, itemID, models.PermissionItemEdit) { + return + } + user, ok := RequireAuth(w, r) + if !ok { + return + } + req, ok := decodeJSON[models.LinkZammadTicketRequest](w, r) + if !ok { + return + } + link, err := h.service.LinkExistingTicket(r.Context(), itemID, user.ID, req) + if !h.respondServiceError(w, r, err) { + return + } + h.audit(r, user, logger.ActionZammadTicketLinkExisting, logger.ResourceZammadTicket, link.ID, link.TicketNumber, map[string]any{ + "item_id": link.ItemID, "connection_id": link.ProviderID, "ticket_id": link.TicketID, + }) + respondJSONCreated(w, link.Response()) +} + +func (h *ZammadHandler) UpdateTicketLink(w http.ResponseWriter, r *http.Request) { + link, err := h.service.GetTicketLink(r.PathValue("linkId")) + if !h.respondServiceError(w, r, err) { + return + } + if !CheckItemPermission(w, r, h.itemRepo, h.permissionService, link.ItemID, models.PermissionItemEdit) { + return + } + req, ok := decodeJSON[models.UpdateZammadTicketLinkRequest](w, r) + if !ok { + return + } + updated, err := h.service.UpdateTicketLink(r.Context(), link.ID, req) + if !h.respondServiceError(w, r, err) { + return + } + h.audit(r, currentUser(r), logger.ActionZammadTicketUpdate, logger.ResourceZammadTicket, updated.ID, updated.TicketNumber, map[string]any{ + "item_id": updated.ItemID, "connection_id": updated.ProviderID, "ticket_id": updated.TicketID, + "state_id": updated.LastStatusID, "group_id": updated.GroupID, "owner_id": updated.OwnerID, + }) + respondJSONOK(w, updated.Response()) +} + +func (h *ZammadHandler) DeleteTicketLink(w http.ResponseWriter, r *http.Request) { + link, err := h.service.GetTicketLink(r.PathValue("linkId")) + if !h.respondServiceError(w, r, err) { + return + } + if !CheckItemPermission(w, r, h.itemRepo, h.permissionService, link.ItemID, models.PermissionItemEdit) { + return + } + removed, err := h.service.UnlinkTicket(r.Context(), link.ID) + if !h.respondServiceError(w, r, err) { + return + } + h.audit(r, currentUser(r), logger.ActionZammadTicketUnlink, logger.ResourceZammadTicket, removed.ID, removed.TicketNumber, map[string]any{ + "item_id": removed.ItemID, "connection_id": removed.ProviderID, "ticket_id": removed.TicketID, + }) + w.WriteHeader(http.StatusNoContent) +} + +func (h *ZammadHandler) RefreshTicket(w http.ResponseWriter, r *http.Request) { + link, err := h.service.GetTicketLink(r.PathValue("linkId")) + if !h.respondServiceError(w, r, err) { + return + } + if !CheckItemPermission(w, r, h.itemRepo, h.permissionService, link.ItemID, models.PermissionItemEdit) { + return + } + link, err = h.service.SyncTicketLink(r.Context(), link.ID) + if !h.respondServiceError(w, r, err) { + return + } + h.audit(r, currentUser(r), logger.ActionZammadTicketRefresh, logger.ResourceZammadTicket, link.ID, link.TicketNumber, map[string]any{"item_id": link.ItemID, "connection_id": link.ProviderID, "ticket_id": link.TicketID, "status_id": link.LastStatusID}) + respondJSONOK(w, link.Response()) +} + +func (h *ZammadHandler) audit(r *http.Request, user *models.User, action, resourceType, externalID, name string, details map[string]any) { + if h.auditor == nil || user == nil { + return + } + if details == nil { + details = map[string]any{} + } + details["external_id"] = externalID + h.auditor.LogWithDetails(r, user, action, resourceType, nil, name, details) +} + +func currentUser(r *http.Request) *models.User { + return utils.GetCurrentUser(r) +} + +func (h *ZammadHandler) respondServiceError(w http.ResponseWriter, r *http.Request, err error) bool { + if err == nil { + return true + } + switch { + case errors.Is(err, repository.ErrNotFound), errors.Is(err, services.ErrCredentialScopeMismatch): + respondNotFound(w, r, "zammad_connection") + case errors.Is(err, repository.ErrDuplicateEntry): + respondConflict(w, r, "A Zammad connection or ticket link with these identifiers already exists") + case errors.Is(err, repository.ErrConcurrentUpdate): + respondConflict(w, r, "The Zammad connection changed while it was being updated. Reload and retry the action.") + case errors.Is(err, services.ErrZammadLinkReservationConflict): + respondConflict(w, r, "The Zammad connection or item changed while the ticket link was being prepared. Retry the action.") + case errors.Is(err, services.ErrZammadOAuthSuperseded): + w.Header().Set("Retry-After", "1") + respondConflict(w, r, "The Zammad authorization changed while the request was running. Reload and retry the action.") + case errors.Is(err, services.ErrZammadTicketGroupPolicyChanged): + respondConflict(w, r, "The Zammad connection no longer allows the ticket's group. Review the connection group policy before retrying.") + case errors.Is(err, services.ErrZammadConnectionBusy): + w.Header().Set("Retry-After", "1") + respondConflict(w, r, "A Zammad ticket operation is in progress. Retry the connection change shortly.") + default: + var apiErr *zammad.APIError + var upstreamErr *zammad.UpstreamError + var configurationErr *zammad.ConfigurationError + var validationErr *services.ZammadValidationError + var transitionErr *services.TransitionRejection + if errors.Is(err, services.ErrZammadReauthorizationRequired) { + respondError(w, r, restapi.NewAPIError(http.StatusConflict, "ZAMMAD_REAUTHORIZATION_REQUIRED", "Zammad authorization must be renewed by a system administrator")) + return false + } + if errors.Is(err, services.ErrZammadOAuthRefreshInProgress) { + w.Header().Set("Retry-After", "1") + respondServiceUnavailable(w, r, "Zammad authorization is being refreshed. Retry shortly.") + return false + } + switch { + case errors.As(err, &validationErr): + respondValidationError(w, r, validationErr.Error()) + case errors.As(err, &configurationErr): + respondBadRequest(w, r, configurationErr.Error()) + case errors.As(err, &apiErr) || errors.As(err, &upstreamErr): + respondError(w, r, restapi.NewAPIError(http.StatusBadGateway, "ZAMMAD_UPSTREAM_ERROR", "Zammad could not complete the request")) + case errors.As(err, &transitionErr): + respondBadRequest(w, r, "The configured Windshift completion transition is not currently allowed") + default: + respondInternalError(w, r, err) + } + } + return false +} diff --git a/internal/handlers/zammad_test.go b/internal/handlers/zammad_test.go new file mode 100644 index 000000000..a2545d89b --- /dev/null +++ b/internal/handlers/zammad_test.go @@ -0,0 +1,480 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + + "windshift/internal/database" + "windshift/internal/integrations/zammad" + "windshift/internal/logger" + "windshift/internal/middleware" + "windshift/internal/models" + "windshift/internal/repository" + "windshift/internal/services" + "windshift/internal/sso" +) + +func newZammadHandlerTest(t *testing.T) (*ZammadHandler, database.Database, *models.User, int) { + t.Helper() + db, err := database.NewSQLiteDB(t.TempDir() + "/windshift.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Initialize(); err != nil { + t.Fatal(err) + } + result, err := db.ExecWrite(`INSERT INTO users (email, username, first_name, last_name) VALUES (?, ?, ?, ?)`, "admin@example.test", "synthetic-admin", "Synthetic", "Admin") + if err != nil { + t.Fatal(err) + } + userID, _ := result.LastInsertId() + result, err = db.ExecWrite(`INSERT INTO workspaces (name, key) VALUES (?, ?)`, "Primary", "PRI") + if err != nil { + t.Fatal(err) + } + workspaceID, _ := result.LastInsertId() + credentialService := services.NewActionCredentialService(repository.NewActionCredentialRepository(db), "synthetic-handler-test-secret") + service := services.NewZammadService(db, repository.NewZammadRepository(db), credentialService, nil, nil, nil, nil) + return NewZammadHandler(repository.NewItemRepository(db), service, nil, logger.NewAuditor(db)), db, &models.User{ID: int(userID), Username: "synthetic-admin"}, int(workspaceID) +} + +func authenticatedZammadRequest(method, target string, body []byte, user *models.User) *http.Request { + req := httptest.NewRequest(method, target, bytes.NewReader(body)) + return req.WithContext(context.WithValue(req.Context(), middleware.ContextKeyUser, user)) +} + +func TestZammadHandlerCreateConnectionResponseOmitsSecret(t *testing.T) { + handler, db, user, workspaceID := newZammadHandlerTest(t) + body, _ := json.Marshal(models.CreateZammadConnectionRequest{ + Slug: "helpdesk", Name: "Synthetic helpdesk", BaseURL: "https://zammad.example.test", + APIToken: "handler-secret-token", DefaultGroupID: 7, DefaultGroupName: "Support", + DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{workspaceID}, + }) + recorder := httptest.NewRecorder() + handler.CreateConnection(recorder, authenticatedZammadRequest(http.MethodPost, "/api/admin/zammad-connections", body, user)) + if recorder.Code != http.StatusCreated || recorder.Header().Get("Content-Type") != "application/json" { + t.Fatalf("unexpected response status or type: %d %q", recorder.Code, recorder.Header().Get("Content-Type")) + } + var response map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response["has_api_token"] != true || response["id"] == "" || response["base_url"] != "https://zammad.example.test" { + t.Fatalf("unexpected response shape: %#v", response) + } + if _, exists := response["api_token"]; exists { + t.Fatalf("response disclosed api_token field: %s", recorder.Body.String()) + } + if _, exists := response["credential_id"]; exists { + t.Fatalf("response disclosed credential_id field: %s", recorder.Body.String()) + } + if strings.Contains(recorder.Body.String(), "handler-secret-token") { + t.Fatalf("response disclosed token: %s", recorder.Body.String()) + } + var encrypted string + if err := db.QueryRow("SELECT encrypted_secret FROM action_credentials").Scan(&encrypted); err != nil || encrypted == "handler-secret-token" { + t.Fatalf("credential was not encrypted: err=%v", err) + } + genericRepo := repository.NewIntegrationProviderRepository(db) + providers, err := genericRepo.List() + if err != nil { + t.Fatal(err) + } + if len(providers) != 0 { + t.Fatalf("provider-specific Zammad connection leaked into generic OAuth CRUD: %#v", providers) + } + if err := genericRepo.Delete(response["id"].(string)); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("generic provider delete was not blocked: %v", err) + } +} + +func TestZammadHandlerReturnsStructuredValidationAndNotFoundErrors(t *testing.T) { + handler, _, user, workspaceID := newZammadHandlerTest(t) + body, _ := json.Marshal(models.CreateZammadConnectionRequest{ + Slug: "helpdesk", Name: "Synthetic helpdesk", BaseURL: "http://zammad.example.test", + APIToken: "handler-secret-token", DefaultCustomer: "robot@example.test", + WorkspaceIDs: []int{workspaceID}, + }) + recorder := httptest.NewRecorder() + handler.CreateConnection(recorder, authenticatedZammadRequest(http.MethodPost, "/api/admin/zammad-connections", body, user)) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("expected validation status 400, got %d: %s", recorder.Code, recorder.Body.String()) + } + var validation map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &validation); err != nil { + t.Fatal(err) + } + if validation["code"] != "VALIDATION_FAILED" { + t.Fatalf("unexpected validation response: %#v", validation) + } + + request := httptest.NewRequest(http.MethodGet, "/api/admin/zammad-connections/missing", nil) + request.SetPathValue("id", "missing") + recorder = httptest.NewRecorder() + handler.GetConnection(recorder, request) + if recorder.Code != http.StatusNotFound { + t.Fatalf("expected not-found status 404, got %d: %s", recorder.Code, recorder.Body.String()) + } +} + +func TestZammadHandlerRefreshAllTicketsQueuesBackgroundRun(t *testing.T) { + handler, _, user, _ := newZammadHandlerTest(t) + handler.SetSyncAllTrigger(func() bool { return true }) + recorder := httptest.NewRecorder() + request := authenticatedZammadRequest(http.MethodPost, "/api/admin/zammad-ticket-links/refresh", nil, user) + handler.RefreshAllTickets(recorder, request) + if recorder.Code != http.StatusAccepted { + t.Fatalf("unexpected status: %d %s", recorder.Code, recorder.Body.String()) + } + var result map[string]bool + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if !result["started"] { + t.Fatalf("unexpected refresh result: %#v", result) + } +} + +func TestZammadWorkspaceOwnersRequireItemEditPermission(t *testing.T) { + handler, db, user, workspaceID := newZammadHandlerTest(t) + if _, err := db.ExecWrite(`INSERT INTO user_workspace_roles (user_id, workspace_id, role_id, granted_by) + SELECT ?, ?, id, ? FROM workspace_roles WHERE name = 'Viewer'`, user.ID, workspaceID, user.ID); err != nil { + t.Fatal(err) + } + config := services.DefaultPermissionCacheConfig() + config.WarmupOnStartup = false + config.PreWarmActive = false + permissionService, err := services.NewPermissionService(db, config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = permissionService.Close() }) + handler.permissionService = permissionService + + workspacePathID := strconv.Itoa(workspaceID) + request := authenticatedZammadRequest(http.MethodGet, "/api/workspaces/"+workspacePathID+"/zammad-connections/helpdesk/owners?group_id=7", nil, user) + request.SetPathValue("workspaceId", workspacePathID) + request.SetPathValue("id", "helpdesk") + recorder := httptest.NewRecorder() + handler.GetWorkspaceOwners(recorder, request) + if recorder.Code != http.StatusForbidden { + t.Fatalf("viewer could enumerate Zammad owners: status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestZammadTicketLinkResolverRequiresViewPermission(t *testing.T) { + handler, db, user, workspaceID := newZammadHandlerTest(t) + var statusID int + if err := db.QueryRow(`SELECT id FROM statuses ORDER BY id LIMIT 1`).Scan(&statusID); err != nil { + t.Fatal(err) + } + itemResult, err := db.ExecWrite(`INSERT INTO items + (workspace_id, workspace_item_number, title, description, frac_index, status_id, creator_id, last_active_at) + VALUES (?, 1, 'Resolver target', '', 'a0', ?, ?, CURRENT_TIMESTAMP)`, workspaceID, statusID, user.ID) + if err != nil { + t.Fatal(err) + } + itemID, _ := itemResult.LastInsertId() + if _, err := db.ExecWrite(`INSERT INTO action_credentials + (id, name, credential_type, applies_to_all_workspaces, encrypted_secret, is_enabled) + VALUES (1, 'Resolver credential', 'custom_header', true, 'test-ciphertext', true)`); err != nil { + t.Fatal(err) + } + if _, err := db.ExecWrite(`INSERT INTO integration_providers + (id, slug, name, provider_type, enabled) + VALUES ('zammad-resolver', 'zammad-resolver', 'Zammad resolver', 'zammad', true)`); err != nil { + t.Fatal(err) + } + if _, err := db.ExecWrite(`INSERT INTO zammad_connections + (provider_id, credential_id, base_url, default_customer) + VALUES ('zammad-resolver', 1, 'https://zammad.example.test', 'robot@example.test')`); err != nil { + t.Fatal(err) + } + correlationKey := "windshift:zammad-resolver:PRI-1" + if _, err := db.ExecWrite(`INSERT INTO zammad_ticket_links + (id, item_id, provider_id, correlation_key, sync_state, created_by) + VALUES ('resolver-link', ?, 'zammad-resolver', ?, ?, ?)`, itemID, correlationKey, models.ZammadSyncLinked, user.ID); err != nil { + t.Fatal(err) + } + movedWorkspaceResult, err := db.ExecWrite(`INSERT INTO workspaces (name, key) VALUES ('Moved target', 'MOV')`) + if err != nil { + t.Fatal(err) + } + movedWorkspaceID, _ := movedWorkspaceResult.LastInsertId() + if _, err := db.ExecWrite(`UPDATE items SET workspace_id = ? WHERE id = ?`, movedWorkspaceID, itemID); err != nil { + t.Fatal(err) + } + if _, err := db.ExecWrite(`INSERT INTO user_workspace_roles (user_id, workspace_id, role_id, granted_by) + SELECT ?, ?, id, ? FROM workspace_roles WHERE name = 'Viewer'`, user.ID, movedWorkspaceID, user.ID); err != nil { + t.Fatal(err) + } + config := services.DefaultPermissionCacheConfig() + config.WarmupOnStartup = false + config.PreWarmActive = false + permissionService, err := services.NewPermissionService(db, config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = permissionService.Close() }) + handler.permissionService = permissionService + + request := authenticatedZammadRequest(http.MethodGet, "/api/zammad-ticket-links/resolve/"+correlationKey, nil, user) + request.SetPathValue("correlationKey", correlationKey) + recorder := httptest.NewRecorder() + handler.ResolveTicketLink(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("viewer could not resolve linked item: status=%d body=%s", recorder.Code, recorder.Body.String()) + } + var destination struct { + WorkspaceID int `json:"workspace_id"` + ItemID int `json:"item_id"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &destination); err != nil { + t.Fatal(err) + } + if destination.WorkspaceID != int(movedWorkspaceID) || destination.ItemID != int(itemID) { + t.Fatalf("unexpected resolver destination: %#v", destination) + } + + deniedResult, err := db.ExecWrite(`INSERT INTO users (email, username, first_name, last_name) + VALUES ('denied@example.test', 'denied', 'Denied', 'User')`) + if err != nil { + t.Fatal(err) + } + deniedID, _ := deniedResult.LastInsertId() + deniedRequest := authenticatedZammadRequest(http.MethodGet, "/api/zammad-ticket-links/resolve/"+correlationKey, nil, &models.User{ID: int(deniedID)}) + deniedRequest.SetPathValue("correlationKey", correlationKey) + deniedRecorder := httptest.NewRecorder() + handler.ResolveTicketLink(deniedRecorder, deniedRequest) + if deniedRecorder.Code != http.StatusNotFound { + t.Fatalf("resolver disclosed an item without view permission: status=%d body=%s", deniedRecorder.Code, deniedRecorder.Body.String()) + } + + missingKey := "windshift:zammad-resolver:missing" + missingRequest := authenticatedZammadRequest(http.MethodGet, "/api/zammad-ticket-links/resolve/"+missingKey, nil, user) + missingRequest.SetPathValue("correlationKey", missingKey) + missingRecorder := httptest.NewRecorder() + handler.ResolveTicketLink(missingRecorder, missingRequest) + if missingRecorder.Code != deniedRecorder.Code || missingRecorder.Body.String() != deniedRecorder.Body.String() { + t.Fatalf("resolver distinguished an unauthorized target from a missing one: denied=%d %s missing=%d %s", + deniedRecorder.Code, deniedRecorder.Body.String(), missingRecorder.Code, missingRecorder.Body.String()) + } +} + +func TestZammadHandlerMapsRemoteAuthenticationFailureToBadGateway(t *testing.T) { + handler, _, _, _ := newZammadHandlerTest(t) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/admin/zammad-connections/example/test", nil) + if handler.respondServiceError(recorder, request, &zammad.APIError{StatusCode: http.StatusUnauthorized}) { + t.Fatal("expected handler to write an error response") + } + if recorder.Code != http.StatusBadGateway || strings.Contains(recorder.Body.String(), "401") { + t.Fatalf("unexpected upstream response: %d %s", recorder.Code, recorder.Body.String()) + } +} + +func TestZammadHandlerMapsOAuthRefreshContentionToRetryableResponse(t *testing.T) { + handler, _, _, _ := newZammadHandlerTest(t) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/admin/zammad-connections/example/test", nil) + if handler.respondServiceError(recorder, request, services.ErrZammadOAuthRefreshInProgress) { + t.Fatal("expected handler to write an error response") + } + if recorder.Code != http.StatusServiceUnavailable || recorder.Header().Get("Retry-After") != "1" { + t.Fatalf("unexpected refresh contention response: status=%d retry-after=%q body=%s", recorder.Code, recorder.Header().Get("Retry-After"), recorder.Body.String()) + } +} + +func TestZammadHandlerMapsSupersededOAuthOperationToRetryableConflict(t *testing.T) { + handler, _, _, _ := newZammadHandlerTest(t) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/admin/zammad-connections/example/test", nil) + if handler.respondServiceError(recorder, request, services.ErrZammadOAuthSuperseded) { + t.Fatal("expected handler to write an error response") + } + if recorder.Code != http.StatusConflict || recorder.Header().Get("Retry-After") != "1" { + t.Fatalf("unexpected superseded OAuth response: status=%d retry-after=%q body=%s", recorder.Code, recorder.Header().Get("Retry-After"), recorder.Body.String()) + } +} + +func TestZammadHandlerMapsReservationRaceToConflict(t *testing.T) { + handler, _, _, _ := newZammadHandlerTest(t) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/items/1/zammad-tickets", nil) + if handler.respondServiceError(recorder, request, services.ErrZammadLinkReservationConflict) { + t.Fatal("expected handler to write an error response") + } + if recorder.Code != http.StatusConflict { + t.Fatalf("unexpected reservation conflict response: status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestZammadHandlerMapsConcurrentConnectionUpdateToConflict(t *testing.T) { + handler, _, _, _ := newZammadHandlerTest(t) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPatch, "/api/admin/zammad-connections/example", nil) + if handler.respondServiceError(recorder, request, repository.ErrConcurrentUpdate) { + t.Fatal("expected handler to write an error response") + } + if recorder.Code != http.StatusConflict { + t.Fatalf("unexpected connection conflict response: status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestZammadHandlerMapsTicketGroupPolicyRaceToConflict(t *testing.T) { + handler, _, _, _ := newZammadHandlerTest(t) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPatch, "/api/integrations/zammad/ticket-links/example", nil) + if handler.respondServiceError(recorder, request, services.ErrZammadTicketGroupPolicyChanged) { + t.Fatal("expected handler to write an error response") + } + if recorder.Code != http.StatusConflict { + t.Fatalf("unexpected group policy conflict response: status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestZammadHandlerMapsBusyConnectionToRetryableConflict(t *testing.T) { + handler, _, _, _ := newZammadHandlerTest(t) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPatch, "/api/admin/zammad-connections/example", nil) + if handler.respondServiceError(recorder, request, services.ErrZammadConnectionBusy) { + t.Fatal("expected handler to write an error response") + } + if recorder.Code != http.StatusConflict || recorder.Header().Get("Retry-After") != "1" { + t.Fatalf("unexpected busy connection response: status=%d retry-after=%q body=%s", recorder.Code, recorder.Header().Get("Retry-After"), recorder.Body.String()) + } +} + +func TestZammadConnectionReadyRequiresLocallyUsableDefaultGroup(t *testing.T) { + tests := []struct { + name string + connection models.ZammadConnection + want bool + }{ + {name: "numeric default without allowlist", connection: models.ZammadConnection{DefaultGroupID: 7}, want: true}, + {name: "numeric default inside allowlist", connection: models.ZammadConnection{DefaultGroupID: 7, AllowedGroups: []models.ZammadGroupRef{{ID: 7, Name: "Support"}, {ID: 8, Name: "Escalations"}}}, want: true}, + {name: "numeric default outside allowlist", connection: models.ZammadConnection{DefaultGroupID: 7, AllowedGroups: []models.ZammadGroupRef{{ID: 8, Name: "Escalations"}}}, want: false}, + {name: "name default without allowlist", connection: models.ZammadConnection{DefaultGroupName: "Support"}, want: true}, + {name: "name default with allowlist is unresolved", connection: models.ZammadConnection{DefaultGroupName: "Support", AllowedGroups: []models.ZammadGroupRef{{ID: 8, Name: "Escalations"}}}, want: false}, + {name: "missing default", connection: models.ZammadConnection{}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := zammadConnectionHasUsableDefaultGroup(&tt.connection); got != tt.want { + t.Fatalf("zammadConnectionHasUsableDefaultGroup(%#v) = %v, want %v", tt.connection, got, tt.want) + } + }) + } +} + +func TestZammadOAuthCallbackRedirectsToIntegrationsAdminTab(t *testing.T) { + handler, db, _, _ := newZammadHandlerTest(t) + oauthHandler := NewIntegrationOAuthHandler(db, sso.NewSecretEncryption("synthetic-handler-test-secret"), "https://windshift.example.test") + oauthHandler.RegisterSystemOAuthFlow(models.IntegrationProviderZammad, handler.service, logger.NewAuditor(db)) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/integrations/oauth/system/zammad/callback?error=access_denied&state=missing", nil) + request.SetPathValue("providerType", string(models.IntegrationProviderZammad)) + request.SetPathValue("slug", "zammad") + oauthHandler.SystemOAuthCallback(recorder, request) + if recorder.Code != http.StatusFound || recorder.Header().Get("Location") != "/admin/integration-providers?tab=zammad&oauth=error" { + t.Fatalf("unexpected OAuth callback redirect: status=%d location=%q", recorder.Code, recorder.Header().Get("Location")) + } +} + +func TestUserOAuthSlugCannotCollideWithSystemCallback(t *testing.T) { + _, db, _, _ := newZammadHandlerTest(t) + oauthHandler := NewIntegrationOAuthHandler(db, sso.NewSecretEncryption("synthetic-handler-test-secret"), "https://windshift.example.test") + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/integrations/oauth/zammad/callback?error=access_denied", nil) + request.SetPathValue("slug", "zammad") + oauthHandler.OAuthCallback(recorder, request) + if recorder.Code != http.StatusFound || !strings.HasPrefix(recorder.Header().Get("Location"), "/profile?tab=connected-accounts&oauth=error") { + t.Fatalf("user callback was dispatched as a system callback: status=%d location=%q", recorder.Code, recorder.Header().Get("Location")) + } +} + +func TestZammadOAuthCallbackAuditsInitiatorSuccessAndFailureWithoutSecrets(t *testing.T) { + handler, db, user, workspaceID := newZammadHandlerTest(t) + encryption := sso.NewSecretEncryption("synthetic-handler-test-secret") + handler.service.SetOAuthEncryption(encryption) + oauthHandler := NewIntegrationOAuthHandler(db, encryption, "https://windshift.example.test") + oauthHandler.RegisterSystemOAuthFlow(models.IntegrationProviderZammad, handler.service, logger.NewAuditor(db)) + handler.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, body []byte, _ map[string]string) (*zammad.Response, error) { + if strings.Contains(string(body), "failure-code") { + return &zammad.Response{StatusCode: http.StatusBadGateway, Body: []byte(`{"error":"upstream_failure"}`)}, nil + } + return &zammad.Response{StatusCode: http.StatusOK, Body: []byte(`{"access_token":"audit-access-secret","refresh_token":"audit-refresh-secret","expires_in":3600}`)}, nil + })) + connection, err := handler.service.CreateConnection(models.CreateZammadConnectionRequest{ + Slug: "audit-oauth", Name: "Audit OAuth", BaseURL: "https://audit-oauth.example.test", + AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "audit-client", OAuthClientSecret: "audit-client-secret", + DefaultGroupID: 7, DefaultGroupName: "Support", AllowedGroups: []models.ZammadGroupRef{{ID: 7, Name: "Support"}}, + DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{workspaceID}, + }, user.ID) + if err != nil { + t.Fatal(err) + } + + callback := func(code string) { + t.Helper() + authURL, err := handler.service.StartOAuth(context.Background(), connection.ProviderID, user.ID, "https://windshift.example.test") + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(authURL) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/api/integrations/oauth/system/zammad/callback?state="+url.QueryEscape(parsed.Query().Get("state"))+"&code="+url.QueryEscape(code), nil) + request.SetPathValue("providerType", string(models.IntegrationProviderZammad)) + request.SetPathValue("slug", "zammad") + recorder := httptest.NewRecorder() + oauthHandler.SystemOAuthCallback(recorder, request) + if recorder.Code != http.StatusFound { + t.Fatalf("callback status = %d", recorder.Code) + } + } + callback("success-code") + callback("failure-code") + + rows, err := db.Query(`SELECT user_id, username, success, COALESCE(error_message, ''), COALESCE(details, '') + FROM audit_logs WHERE action_type = ? ORDER BY id`, logger.ActionIntegrationProviderOAuthCredentialSet) + if err != nil { + t.Fatal(err) + } + defer func() { _ = rows.Close() }() + type auditRow struct { + userID int + username, error, data string + success bool + } + var audits []auditRow + for rows.Next() { + var row auditRow + if err := rows.Scan(&row.userID, &row.username, &row.success, &row.error, &row.data); err != nil { + t.Fatal(err) + } + audits = append(audits, row) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if len(audits) != 2 || !audits[0].success || audits[1].success || audits[0].userID != user.ID || audits[1].userID != user.ID || audits[0].username != user.Username || audits[1].error != "oauth_callback_failed" { + t.Fatalf("unexpected callback audits: %#v", audits) + } + for _, audit := range audits { + if strings.Contains(audit.data, "success-code") || strings.Contains(audit.data, "failure-code") || strings.Contains(audit.data, "audit-access-secret") || strings.Contains(audit.data, "audit-refresh-secret") || strings.Contains(audit.data, "audit-client-secret") { + t.Fatalf("callback audit disclosed OAuth material: %s", audit.data) + } + } +} diff --git a/internal/integrations/catalog.go b/internal/integrations/catalog.go new file mode 100644 index 000000000..d08e2fd65 --- /dev/null +++ b/internal/integrations/catalog.go @@ -0,0 +1,90 @@ +package integrations + +import "windshift/internal/models" + +// CredentialOwner describes who owns the authorization established for an +// integration provider. User-owned credentials are stored per user, while +// system-owned credentials back an administrator-managed connection. +type CredentialOwner string + +const ( + CredentialOwnerUser CredentialOwner = "user" + CredentialOwnerSystem CredentialOwner = "system" +) + +// ProviderCapabilities declares which parts of the shared integration core a +// provider can safely use. Provider-specific synchronization remains outside +// this catalog. +type ProviderCapabilities struct { + Type models.IntegrationProviderType + CredentialOwner CredentialOwner + AdminProviderCRUD bool + OAuth bool + GenericItemLinks bool + GenericItemRefresh bool +} + +var providerCatalog = map[models.IntegrationProviderType]ProviderCapabilities{ + models.IntegrationProviderNotion: { + Type: models.IntegrationProviderNotion, + CredentialOwner: CredentialOwnerUser, + AdminProviderCRUD: true, + OAuth: true, + GenericItemLinks: true, + GenericItemRefresh: true, + }, + models.IntegrationProviderTodoist: { + Type: models.IntegrationProviderTodoist, + CredentialOwner: CredentialOwnerUser, + AdminProviderCRUD: true, + OAuth: true, + GenericItemLinks: true, + }, + models.IntegrationProviderZammad: { + Type: models.IntegrationProviderZammad, + CredentialOwner: CredentialOwnerSystem, + OAuth: true, + }, +} + +// Capabilities returns the declared capabilities for a known provider type. +// Unknown types fail closed. +func Capabilities(providerType models.IntegrationProviderType) (ProviderCapabilities, bool) { + capabilities, ok := providerCatalog[providerType] + return capabilities, ok +} + +// IsKnown reports whether a provider type is registered with the integration +// core. +func IsKnown(providerType models.IntegrationProviderType) bool { + _, ok := Capabilities(providerType) + return ok +} + +// SupportsAdminProviderCRUD reports whether the generic provider settings API +// owns the provider row. +func SupportsAdminProviderCRUD(providerType models.IntegrationProviderType) bool { + capabilities, ok := Capabilities(providerType) + return ok && capabilities.AdminProviderCRUD +} + +// SupportsUserOAuth reports whether the shared user connection flow owns the +// provider's OAuth token. +func SupportsUserOAuth(providerType models.IntegrationProviderType) bool { + capabilities, ok := Capabilities(providerType) + return ok && capabilities.OAuth && capabilities.CredentialOwner == CredentialOwnerUser +} + +// SupportsGenericItemLinks reports whether callers may create and delete this +// provider's links through the generic item-link endpoints. +func SupportsGenericItemLinks(providerType models.IntegrationProviderType) bool { + capabilities, ok := Capabilities(providerType) + return ok && capabilities.GenericItemLinks +} + +// SupportsGenericItemRefresh reports whether the generic refresh endpoint has +// an implementation for the provider. +func SupportsGenericItemRefresh(providerType models.IntegrationProviderType) bool { + capabilities, ok := Capabilities(providerType) + return ok && capabilities.GenericItemRefresh +} diff --git a/internal/integrations/oauth.go b/internal/integrations/oauth.go new file mode 100644 index 000000000..94fec4821 --- /dev/null +++ b/internal/integrations/oauth.go @@ -0,0 +1,25 @@ +package integrations + +import ( + "context" + + "windshift/internal/models" +) + +// SystemOAuthCallbackResult is the provider-neutral audit context returned by +// a system-owned OAuth flow. +type SystemOAuthCallbackResult struct { + ProviderID string + ProviderName string + Initiator *models.User + Generation int64 +} + +// SystemOAuthFlow extends the shared OAuth handler for administrator-managed +// connections. Provider-specific token exchange and concurrency guarantees +// remain behind this interface. +type SystemOAuthFlow interface { + StartOAuth(ctx context.Context, providerID string, actorID int, publicBaseURL string) (string, error) + CompleteOAuth(ctx context.Context, state, code, publicBaseURL string) (*SystemOAuthCallbackResult, error) + ConsumeFailedOAuthCallback(state string) (*SystemOAuthCallbackResult, error) +} diff --git a/internal/integrations/zammad/client.go b/internal/integrations/zammad/client.go new file mode 100644 index 000000000..2a13aa6e6 --- /dev/null +++ b/internal/integrations/zammad/client.go @@ -0,0 +1,348 @@ +package zammad + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "windshift/internal/models" +) + +type Response struct { + StatusCode int + Body []byte +} + +type Transport interface { + Do(ctx context.Context, method, targetURL string, body []byte, headers map[string]string) (*Response, error) +} + +type TransportFunc func(context.Context, string, string, []byte, map[string]string) (*Response, error) + +func (f TransportFunc) Do(ctx context.Context, method, targetURL string, body []byte, headers map[string]string) (*Response, error) { + return f(ctx, method, targetURL, body, headers) +} + +type APIError struct { + StatusCode int +} + +func (e *APIError) Error() string { + return fmt.Sprintf("Zammad API returned HTTP %d", e.StatusCode) +} + +// UpstreamError represents a network or response-shape failure without +// exposing the remote response or request credentials through Error(). +type UpstreamError struct{ Cause error } + +func (e *UpstreamError) Error() string { return "Zammad request failed" } +func (e *UpstreamError) Unwrap() error { return e.Cause } + +type ConfigurationError struct{ Message string } + +func (e *ConfigurationError) Error() string { return e.Message } + +type Client struct { + baseURL string + authValue string + transport Transport +} + +func NewClient(baseURL, apiToken string, transport Transport) *Client { + return &Client{baseURL: strings.TrimRight(baseURL, "/"), authValue: "Token token=" + apiToken, transport: transport} +} + +// NewOAuthClient uses OAuth bearer authentication. Keeping NewClient unchanged +// preserves the API-token wire format used by existing Zammad connections. +func NewOAuthClient(baseURL, accessToken string, transport Transport) *Client { + return &Client{baseURL: strings.TrimRight(baseURL, "/"), authValue: "Bearer " + accessToken, transport: transport} +} + +type Ticket struct { + ID int + Number string + GroupID int + GroupName string + StateID int + StateName string + OwnerID int + OwnerName string + Attributes map[string]json.RawMessage +} + +type Owner struct { + ID int + Name string +} + +type objectAttribute struct { + Name string `json:"name"` + Object string `json:"object"` + Active bool `json:"active"` +} + +// States returns the active ticket states. Zammad permits this endpoint for +// ordinary ticket agents, so it is safe for runtime use. +func (c *Client) States(ctx context.Context) ([]models.ZammadState, error) { + states := []models.ZammadState{} + if err := c.getJSON(ctx, "/api/v1/ticket_states", &states); err != nil { + return nil, err + } + activeStates := states[:0] + for _, state := range states { + if state.Active { + activeStates = append(activeStates, state) + } + } + return activeStates, nil +} + +func (c *Client) ValidateCorrelationField(ctx context.Context, field string) error { + attributes := []objectAttribute{} + if err := c.getJSON(ctx, "/api/v1/object_manager_attributes", &attributes); err != nil { + return err + } + for _, attribute := range attributes { + if attribute.Active && attribute.Name == field && strings.EqualFold(attribute.Object, "Ticket") { + return nil + } + } + return &ConfigurationError{Message: fmt.Sprintf("Zammad ticket field %q is missing or inactive", field)} +} + +func (c *Client) FindByCorrelation(ctx context.Context, field, value string) (*Ticket, error) { + query := url.Values{} + query.Set("query", field+":"+strconv.Quote(value)) + query.Set("per_page", "10") + var rawTickets []map[string]json.RawMessage + if err := c.getJSON(ctx, "/api/v1/tickets/search?"+query.Encode(), &rawTickets); err != nil { + return nil, err + } + for _, raw := range rawTickets { + var correlation string + if fieldValue, ok := raw[field]; ok { + _ = json.Unmarshal(fieldValue, &correlation) + } + if correlation != value { + continue + } + ticket, err := decodeTicket(raw) + if err != nil { + return nil, &UpstreamError{Cause: err} + } + return ticket, nil + } + return nil, nil +} + +// FindByNumber performs an exact match after asking Zammad to narrow the +// result set. The local check is essential because the search endpoint is not +// an exact-match API. +func (c *Client) FindByNumber(ctx context.Context, number string) (*Ticket, error) { + query := url.Values{} + query.Set("query", "number:"+strconv.Quote(number)) + query.Set("per_page", "10") + var rawTickets []map[string]json.RawMessage + if err := c.getJSON(ctx, "/api/v1/tickets/search?"+query.Encode(), &rawTickets); err != nil { + return nil, err + } + for _, raw := range rawTickets { + ticket, err := decodeTicket(raw) + if err != nil { + return nil, &UpstreamError{Cause: err} + } + if ticket.Number == number { + return ticket, nil + } + } + return nil, nil +} + +func (c *Client) CreateTicket(ctx context.Context, title, body, customer, groupName, correlationField, correlationValue string) (*Ticket, error) { + payload := map[string]any{ + "title": title, + "group": groupName, + "customer": customer, + "article": map[string]any{ + "subject": title, + "body": body, + "type": "note", + "sender": "Agent", + "internal": true, + }, + correlationField: correlationValue, + } + encoded, err := json.Marshal(payload) + if err != nil { + return nil, err + } + var raw map[string]json.RawMessage + if err := c.requestJSON(ctx, http.MethodPost, "/api/v1/tickets", encoded, &raw); err != nil { + return nil, err + } + ticket, err := decodeTicket(raw) + if err != nil { + return nil, &UpstreamError{Cause: err} + } + return ticket, nil +} + +func (c *Client) GetTicket(ctx context.Context, ticketID int) (*Ticket, error) { + var raw map[string]json.RawMessage + path := "/api/v1/tickets/" + strconv.Itoa(ticketID) + "?expand=true" + if err := c.getJSON(ctx, path, &raw); err != nil { + return nil, err + } + ticket, err := decodeTicket(raw) + if err != nil { + return nil, &UpstreamError{Cause: err} + } + return ticket, nil +} + +func (c *Client) UpdateTicket(ctx context.Context, ticketID int, stateID, groupID, ownerID *int, correlationField, correlationValue string) (*Ticket, error) { + payload := map[string]any{} + if stateID != nil { + payload["state_id"] = *stateID + } + if groupID != nil { + payload["group_id"] = *groupID + } + if ownerID != nil { + payload["owner_id"] = *ownerID + } + if correlationField != "" { + payload[correlationField] = correlationValue + } + encoded, err := json.Marshal(payload) + if err != nil { + return nil, err + } + var raw map[string]json.RawMessage + if err := c.requestJSON(ctx, http.MethodPut, "/api/v1/tickets/"+strconv.Itoa(ticketID), encoded, &raw); err != nil { + return nil, err + } + ticket, err := decodeTicket(raw) + if err != nil { + return nil, &UpstreamError{Cause: err} + } + return ticket, nil +} + +// Owners searches on the effective target group. Zammad only keeps an +// assignment when the selected agent has full access to the ticket group. +func (c *Client) Owners(ctx context.Context, groupID int) ([]Owner, error) { + const pageSize = 100 + owners := make([]Owner, 0) + for page := 1; ; page++ { + query := url.Values{} + query.Set("query", "*") + query.Add("permissions[]", "ticket.agent") + query.Set(fmt.Sprintf("group_ids[%d]", groupID), "full") + query.Set("page", strconv.Itoa(page)) + query.Set("per_page", strconv.Itoa(pageSize)) + var rawUsers []map[string]json.RawMessage + if err := c.getJSON(ctx, "/api/v1/users/search?"+query.Encode(), &rawUsers); err != nil { + return nil, err + } + for _, raw := range rawUsers { + var id int + var active bool + var first, last, login string + if json.Unmarshal(raw["id"], &id) != nil || id <= 0 { + continue + } + if value, ok := raw["active"]; ok { + if err := json.Unmarshal(value, &active); err != nil || !active { + continue + } + } + var groups map[string][]string + groupValue, ok := raw["group_ids"] + if !ok || json.Unmarshal(groupValue, &groups) != nil || !containsAccess(groups[strconv.Itoa(groupID)], "full") { + continue + } + _ = json.Unmarshal(raw["firstname"], &first) + _ = json.Unmarshal(raw["lastname"], &last) + _ = json.Unmarshal(raw["login"], &login) + name := strings.TrimSpace(first + " " + last) + if name == "" { + name = login + } + owners = append(owners, Owner{ID: id, Name: name}) + } + if len(rawUsers) < pageSize { + break + } + } + return owners, nil +} + +func containsAccess(values []string, accepted ...string) bool { + for _, value := range values { + for _, want := range accepted { + if value == want { + return true + } + } + } + return false +} + +func (c *Client) getJSON(ctx context.Context, path string, target any) error { + return c.requestJSON(ctx, http.MethodGet, path, nil, target) +} + +func (c *Client) requestJSON(ctx context.Context, method, path string, body []byte, target any) error { + if c.transport == nil { + return errors.New("zammad transport is not configured") + } + response, err := c.transport.Do(ctx, method, c.baseURL+path, body, map[string]string{ + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": c.authValue, + }) + if err != nil { + return &UpstreamError{Cause: err} + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return &APIError{StatusCode: response.StatusCode} + } + if err := json.Unmarshal(response.Body, target); err != nil { + return &UpstreamError{Cause: errors.New("zammad API returned invalid JSON")} + } + return nil +} + +func decodeTicket(raw map[string]json.RawMessage) (*Ticket, error) { + ticket := &Ticket{Attributes: raw} + if err := decodeRequired(raw, "id", &ticket.ID); err != nil { + return nil, err + } + if err := decodeRequired(raw, "number", &ticket.Number); err != nil { + return nil, err + } + _ = json.Unmarshal(raw["group_id"], &ticket.GroupID) + _ = json.Unmarshal(raw["group"], &ticket.GroupName) + _ = json.Unmarshal(raw["state_id"], &ticket.StateID) + _ = json.Unmarshal(raw["state"], &ticket.StateName) + _ = json.Unmarshal(raw["owner_id"], &ticket.OwnerID) + _ = json.Unmarshal(raw["owner"], &ticket.OwnerName) + return ticket, nil +} + +func decodeRequired(raw map[string]json.RawMessage, field string, target any) error { + value, ok := raw[field] + if !ok { + return fmt.Errorf("zammad ticket response is missing %s", field) + } + if err := json.Unmarshal(value, target); err != nil { + return fmt.Errorf("zammad ticket response has invalid %s", field) + } + return nil +} diff --git a/internal/integrations/zammad/client_test.go b/internal/integrations/zammad/client_test.go new file mode 100644 index 000000000..c3103a7bb --- /dev/null +++ b/internal/integrations/zammad/client_test.go @@ -0,0 +1,246 @@ +package zammad + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type httpTestTransport struct{ client *http.Client } + +func (t httpTestTransport) Do(ctx context.Context, method, targetURL string, body []byte, headers map[string]string) (*Response, error) { + req, err := http.NewRequestWithContext(ctx, method, targetURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + for key, value := range headers { + req.Header.Set(key, value) + } + resp, err := t.client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return &Response{StatusCode: resp.StatusCode, Body: responseBody}, nil +} + +func TestClientStatesCorrelationAndTicketCreation(t *testing.T) { + t.Parallel() + const token = "synthetic-secret-token" + postCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Token token="+token { + t.Fatalf("unexpected authorization header: %q", got) + } + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/api/v1/ticket_states": + _, _ = w.Write([]byte(`[{"id":4,"name":"closed","state_type_id":5,"active":true},{"id":5,"name":"inactive","active":false}]`)) + case r.URL.Path == "/api/v1/object_manager_attributes": + _, _ = w.Write([]byte(`[{"name":"windshift_item_key","object":"Ticket","active":true}]`)) + case r.URL.Path == "/api/v1/tickets/search": + _, _ = w.Write([]byte(`[{"id":9,"number":"42009","group_id":1,"state_id":2,"state":"open","windshift_item_key":"windshift:abc:ITEM-49"}]`)) + case r.URL.Path == "/api/v1/tickets" && r.Method == http.MethodPost: + postCount++ + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), `"windshift_item_key":"windshift:abc:ITEM-50"`) { + t.Fatalf("ticket payload lacks correlation field: %s", body) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":10,"number":"42010","group_id":1,"state_id":2,"state":"open"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := NewClient(server.URL, token, httpTestTransport{client: server.Client()}) + states, err := client.States(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(states) != 1 || states[0].Name != "closed" { + t.Fatalf("unexpected states: %#v", states) + } + if err := client.ValidateCorrelationField(context.Background(), "windshift_item_key"); err != nil { + t.Fatal(err) + } + found, err := client.FindByCorrelation(context.Background(), "windshift_item_key", "windshift:abc:ITEM-49") + if err != nil || found == nil || found.ID != 9 { + t.Fatalf("unexpected search result: ticket=%#v err=%v", found, err) + } + created, err := client.CreateTicket(context.Background(), "ITEM-50", "Synthetic body", "robot@example.test", "Support", "windshift_item_key", "windshift:abc:ITEM-50") + if err != nil || created.ID != 10 || postCount != 1 { + t.Fatalf("unexpected create result: ticket=%#v posts=%d err=%v", created, postCount, err) + } +} + +func TestClientRedactsAPIErrorBodyAndToken(t *testing.T) { + t.Parallel() + const token = "must-not-appear" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"token must-not-appear is invalid"}`)) + })) + defer server.Close() + + client := NewClient(server.URL, token, httpTestTransport{client: server.Client()}) + _, err := client.States(context.Background()) + if err == nil { + t.Fatal("expected authentication error") + } + if strings.Contains(err.Error(), token) || strings.Contains(err.Error(), "invalid") { + t.Fatalf("API error disclosed response content: %v", err) + } +} + +func TestClientHonorsContextTimeout(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(200 * time.Millisecond) + _, _ = w.Write([]byte(`[]`)) + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + client := NewClient(server.URL, "token", httpTestTransport{client: server.Client()}) + _, err := client.States(ctx) + if err == nil { + t.Fatal("expected timeout") + } +} + +func TestClientFindByNumberRequiresExactMatch(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/tickets/search" { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("query"); got != `number:"42009"` { + t.Fatalf("unexpected ticket-number query: %q", got) + } + _, _ = w.Write([]byte(`[{"id":9,"number":"420090"},{"id":10,"number":"42008"}]`)) + })) + defer server.Close() + + client := NewClient(server.URL, "token", httpTestTransport{client: server.Client()}) + found, err := client.FindByNumber(context.Background(), "42009") + if err != nil { + t.Fatal(err) + } + if found != nil { + t.Fatalf("non-exact search result was accepted: %#v", found) + } +} + +func TestClientOwnersUsesGroupPermissionQueryAndFiltersIneligibleUsers(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/users/search" { + http.NotFound(w, r) + return + } + query := r.URL.Query() + if query.Get("query") != "*" || query.Get("permissions[]") != "ticket.agent" || query.Get("group_ids[7]") != "full" || query.Get("page") != "1" || query.Get("per_page") != "100" { + t.Fatalf("unexpected owner query: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`[ + {"id":2,"active":true,"firstname":"Ada","lastname":"Lovelace","group_ids":{"7":["full"]}}, + {"id":6,"active":true,"login":"change-only","group_ids":{"7":["change"]}}, + {"id":3,"active":true,"login":"read-only","group_ids":{"7":["read"]}}, + {"id":4,"active":false,"login":"inactive","group_ids":{"7":["full"]}}, + {"id":5,"active":true,"login":"fallback","group_ids":{"8":["full"]}} + ]`)) + })) + defer server.Close() + + client := NewClient(server.URL, "token", httpTestTransport{client: server.Client()}) + owners, err := client.Owners(context.Background(), 7) + if err != nil { + t.Fatal(err) + } + if len(owners) != 1 || owners[0] != (Owner{ID: 2, Name: "Ada Lovelace"}) { + t.Fatalf("unexpected eligible owners: %#v", owners) + } +} + +func TestClientOwnersPagesUntilShortPage(t *testing.T) { + t.Parallel() + pageOne := make([]string, 100) + for i := range pageOne { + pageOne[i] = fmt.Sprintf(`{"id":%d,"active":true,"login":"user-%d","group_ids":{"7":["full"]}}`, i+1, i+1) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/users/search" { + http.NotFound(w, r) + return + } + query := r.URL.Query() + if query.Get("query") != "*" || query.Get("permissions[]") != "ticket.agent" || query.Get("group_ids[7]") != "full" || query.Get("per_page") != "100" { + t.Fatalf("unexpected owner query: %s", r.URL.RawQuery) + } + switch query.Get("page") { + case "1": + _, _ = fmt.Fprintf(w, "[%s]", strings.Join(pageOne, ",")) + case "2": + _, _ = w.Write([]byte(`[{"id":101,"active":true,"firstname":"Grace","lastname":"Hopper","group_ids":{"7":["full"]}}]`)) + default: + t.Fatalf("unexpected page request: %s", query.Get("page")) + } + })) + defer server.Close() + + client := NewClient(server.URL, "token", httpTestTransport{client: server.Client()}) + owners, err := client.Owners(context.Background(), 7) + if err != nil { + t.Fatal(err) + } + if len(owners) != 101 || owners[0] != (Owner{ID: 1, Name: "user-1"}) || owners[100] != (Owner{ID: 101, Name: "Grace Hopper"}) { + t.Fatalf("unexpected paged owners: len=%d first=%#v last=%#v", len(owners), owners[0], owners[len(owners)-1]) + } +} + +func TestClientOwnersPagingHonorsContext(t *testing.T) { + t.Parallel() + pageOne := make([]string, 100) + for i := range pageOne { + pageOne[i] = fmt.Sprintf(`{"id":%d,"active":true,"login":"user-%d","group_ids":{"7":["full"]}}`, i+1, i+1) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + requests := 0 + transport := TransportFunc(func(requestCtx context.Context, _ string, _ string, _ []byte, _ map[string]string) (*Response, error) { + requests++ + if requests == 1 { + cancel() + body := fmt.Sprintf("[%s]", strings.Join(pageOne, ",")) + return &Response{StatusCode: http.StatusOK, Body: []byte(body)}, nil + } + if err := requestCtx.Err(); err != nil { + return nil, err + } + return &Response{StatusCode: http.StatusOK, Body: []byte(`[]`)}, nil + }) + + client := NewClient("https://zammad.example.test", "token", transport) + _, err := client.Owners(ctx, 7) + if err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } + if requests != 2 { + t.Fatalf("expected second page request to receive canceled context, got %d requests", requests) + } +} diff --git a/internal/integrations/zammad/oauth.go b/internal/integrations/zammad/oauth.go new file mode 100644 index 000000000..2bf7bffb3 --- /dev/null +++ b/internal/integrations/zammad/oauth.go @@ -0,0 +1,62 @@ +package zammad + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +var ErrInvalidGrant = errors.New("zammad OAuth authorization is no longer valid") + +type OAuthTokens struct { + AccessToken string + RefreshToken string + ExpiresIn time.Duration +} + +// ExchangeOAuthCode and RefreshOAuthToken keep OAuth responses out of logs and +// return only a generic upstream error for malformed remote replies. +func ExchangeOAuthCode(ctx context.Context, transport Transport, tokenURL, clientID, clientSecret, code, redirectURI string) (*OAuthTokens, error) { + values := url.Values{"grant_type": {"authorization_code"}, "client_id": {clientID}, "client_secret": {clientSecret}, "code": {code}, "redirect_uri": {redirectURI}} + return requestOAuthTokens(ctx, transport, tokenURL, values) +} + +func RefreshOAuthToken(ctx context.Context, transport Transport, tokenURL, clientID, clientSecret, refreshToken string) (*OAuthTokens, error) { + values := url.Values{"grant_type": {"refresh_token"}, "client_id": {clientID}, "client_secret": {clientSecret}, "refresh_token": {refreshToken}} + return requestOAuthTokens(ctx, transport, tokenURL, values) +} + +func requestOAuthTokens(ctx context.Context, transport Transport, tokenURL string, values url.Values) (*OAuthTokens, error) { + response, err := transport.Do(ctx, http.MethodPost, tokenURL, []byte(values.Encode()), map[string]string{"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"}) + if err != nil { + return nil, &UpstreamError{Cause: err} + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + var problem struct { + Error string `json:"error"` + } + _ = json.Unmarshal(response.Body, &problem) + if problem.Error == "invalid_grant" { + return nil, ErrInvalidGrant + } + return nil, &APIError{StatusCode: response.StatusCode} + } + var payload struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn json.RawMessage `json:"expires_in"` + } + if err := json.Unmarshal(response.Body, &payload); err != nil || strings.TrimSpace(payload.AccessToken) == "" || strings.TrimSpace(payload.RefreshToken) == "" { + return nil, &UpstreamError{Cause: errors.New("invalid OAuth token response")} + } + seconds, err := strconv.ParseInt(strings.Trim(string(payload.ExpiresIn), `"`), 10, 64) + if err != nil || seconds <= 0 { + return nil, &UpstreamError{Cause: errors.New("invalid OAuth token expiry")} + } + return &OAuthTokens{AccessToken: payload.AccessToken, RefreshToken: payload.RefreshToken, ExpiresIn: time.Duration(seconds) * time.Second}, nil +} diff --git a/internal/logger/audit.go b/internal/logger/audit.go index fc557e5e0..4455f7f01 100644 --- a/internal/logger/audit.go +++ b/internal/logger/audit.go @@ -540,11 +540,23 @@ const ( ActionLLMConnectionDelete = "llm_connection.delete" // Integration Providers - ActionIntegrationProviderCreate = "integration_provider.create" - ActionIntegrationProviderUpdate = "integration_provider.update" - ActionIntegrationProviderDelete = "integration_provider.delete" - ActionIntegrationItemLinkCreate = "integration_item_link.create" - ActionIntegrationItemLinkDelete = "integration_item_link.delete" + ActionIntegrationProviderCreate = "integration_provider.create" + ActionIntegrationProviderUpdate = "integration_provider.update" + ActionIntegrationProviderDelete = "integration_provider.delete" + ActionIntegrationItemLinkCreate = "integration_item_link.create" + ActionIntegrationItemLinkDelete = "integration_item_link.delete" + ActionZammadConnectionCreate = "zammad_connection.create" + ActionZammadConnectionUpdate = "zammad_connection.update" + ActionZammadConnectionDelete = "zammad_connection.delete" + ActionZammadConnectionTest = "zammad_connection.test" + ActionIntegrationProviderOAuthCredentialSet = "integration_provider.oauth_credential_set" // #nosec G101 -- audit action name, not a credential + ActionZammadTicketCreate = "zammad_ticket.create" + ActionZammadTicketLinkExisting = "zammad_ticket.link_existing" + ActionZammadTicketUpdate = "zammad_ticket.update" + ActionZammadTicketUnlink = "zammad_ticket.unlink" + ActionZammadTicketDetachLocal = "zammad_ticket.detach_local" + ActionZammadTicketRefresh = "zammad_ticket.refresh" + ActionZammadTicketRefreshAll = "zammad_ticket.refresh_all" // Issue sync configuration ActionIssueSyncConfigCreate = "issue_sync_config.create" @@ -825,6 +837,8 @@ const ( ResourceLLMConnection = "llm_connection" ResourceIntegrationProvider = "integration_provider" ResourceIntegrationItemLink = "integration_item_link" + ResourceZammadConnection = "zammad_connection" + ResourceZammadTicket = "zammad_ticket" ResourceIssueSyncConfig = "issue_sync_config" ResourceWorkItemStaleness = "work_item_staleness" ResourceEmailProvider = "email_provider" diff --git a/internal/models/zammad.go b/internal/models/zammad.go new file mode 100644 index 000000000..59234e6ae --- /dev/null +++ b/internal/models/zammad.go @@ -0,0 +1,221 @@ +package models + +import "time" + +const IntegrationProviderZammad IntegrationProviderType = "zammad" + +type ZammadAuthMethod string + +const ( + ZammadAuthMethodAPIToken ZammadAuthMethod = "api_token" + ZammadAuthMethodOAuth ZammadAuthMethod = "oauth" +) + +type ZammadSyncState string + +const ( + ZammadSyncPending ZammadSyncState = "pending" + ZammadSyncCreating ZammadSyncState = "creating" + ZammadSyncLinked ZammadSyncState = "linked" + ZammadSyncFailed ZammadSyncState = "sync_failed" + ZammadSyncUncertain ZammadSyncState = "creation_uncertain" +) + +// ZammadConnection is the provider-specific extension of an +// integration_providers row. The API token stays in action_credentials and is +// never serialized through this model. +type ZammadConnection struct { + ProviderID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + BaseURL string `json:"base_url"` + CredentialID int `json:"-"` + AuthMethod ZammadAuthMethod `json:"auth_method"` + OAuthGeneration int64 `json:"-"` + ConfigRevision int64 `json:"-"` + OAuthAttemptID string `json:"-"` + OAuthClientID string `json:"oauth_client_id,omitempty"` + OAuthClientSecretEncrypted string `json:"-"` + HasOAuthClientSecret bool `json:"has_oauth_client_secret,omitempty"` + OAuthConnected bool `json:"oauth_connected,omitempty"` + ReauthorizationRequired bool `json:"reauthorization_required,omitempty"` + DefaultGroupID int `json:"default_group_id,omitempty"` + DefaultGroupName string `json:"default_group_name,omitempty"` + AllowedGroups []ZammadGroupRef `json:"allowed_groups"` + DefaultCustomer string `json:"default_customer"` + CorrelationField string `json:"correlation_field"` + ClosedStateIDs []int `json:"closed_state_ids"` + CompletionStatusID *int `json:"completion_status_id,omitempty"` + AppliesToAllWorkspaces bool `json:"applies_to_all_workspaces"` + WorkspaceIDs []int `json:"workspace_ids,omitempty"` + HasAPIToken bool `json:"has_api_token"` + LastTestedAt *time.Time `json:"last_tested_at,omitempty"` + LastTestError string `json:"last_test_error,omitempty"` + CreatedBy *int `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ZammadWorkspaceConnection struct { + ProviderID string `json:"id"` + Name string `json:"name"` + AuthMethod ZammadAuthMethod `json:"auth_method"` + Ready bool `json:"ready"` + OAuthConnected bool `json:"oauth_connected,omitempty"` + ReauthorizationRequired bool `json:"reauthorization_required,omitempty"` + DefaultGroupID int `json:"default_group_id,omitempty"` + DefaultGroupName string `json:"default_group_name,omitempty"` + AllowedGroups []ZammadGroupRef `json:"allowed_groups"` +} + +type CreateZammadConnectionRequest struct { + Slug string `json:"slug"` + Name string `json:"name"` + Enabled *bool `json:"enabled,omitempty"` + BaseURL string `json:"base_url"` + APIToken string `json:"api_token"` + AuthMethod ZammadAuthMethod `json:"auth_method,omitempty"` + OAuthClientID string `json:"oauth_client_id,omitempty"` + OAuthClientSecret string `json:"oauth_client_secret,omitempty"` + DefaultGroupID int `json:"default_group_id,omitempty"` + DefaultGroupName string `json:"default_group_name,omitempty"` + AllowedGroups []ZammadGroupRef `json:"allowed_groups,omitempty"` + AllowedGroupIDs []int `json:"allowed_group_ids,omitempty"` + DefaultCustomer string `json:"default_customer"` + CorrelationField string `json:"correlation_field,omitempty"` + ClosedStateIDs []int `json:"closed_state_ids,omitempty"` + CompletionStatusID *int `json:"completion_status_id,omitempty"` + AppliesToAllWorkspaces *bool `json:"applies_to_all_workspaces,omitempty"` + WorkspaceIDs []int `json:"workspace_ids,omitempty"` +} + +type UpdateZammadConnectionRequest struct { + Slug *string `json:"slug,omitempty"` + Name *string `json:"name,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + BaseURL *string `json:"base_url,omitempty"` + APIToken *string `json:"api_token,omitempty"` + AuthMethod *ZammadAuthMethod `json:"auth_method,omitempty"` + OAuthClientID *string `json:"oauth_client_id,omitempty"` + OAuthClientSecret *string `json:"oauth_client_secret,omitempty"` + DefaultGroupID *int `json:"default_group_id,omitempty"` + DefaultGroupName *string `json:"default_group_name,omitempty"` + AllowedGroups *[]ZammadGroupRef `json:"allowed_groups,omitempty"` + AllowedGroupIDs *[]int `json:"allowed_group_ids,omitempty"` + DefaultCustomer *string `json:"default_customer,omitempty"` + CorrelationField *string `json:"correlation_field,omitempty"` + ClosedStateIDs *[]int `json:"closed_state_ids,omitempty"` + CompletionStatusID *int `json:"completion_status_id,omitempty"` + ClearCompletionStatus bool `json:"clear_completion_status,omitempty"` + AppliesToAllWorkspaces *bool `json:"applies_to_all_workspaces,omitempty"` + WorkspaceIDs *[]int `json:"workspace_ids,omitempty"` +} + +type ZammadGroup struct { + ID int `json:"id"` + Name string `json:"name"` + Active bool `json:"active"` +} + +// ZammadGroupRef is the persisted group policy used at runtime. Keeping both +// the stable ID and display name avoids requiring Zammad's admin-only groups +// endpoint after connection setup. +type ZammadGroupRef struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type ZammadState struct { + ID int `json:"id"` + Name string `json:"name"` + StateTypeID int `json:"state_type_id"` + Active bool `json:"active"` +} + +type ZammadConnectionMetadata struct { + Groups []ZammadGroup `json:"groups"` + States []ZammadState `json:"states"` + GroupCatalogVerified bool `json:"group_catalog_verified"` + CorrelationFieldVerified bool `json:"correlation_field_verified"` +} + +type CreateZammadTicketRequest struct { + ConnectionID string `json:"connection_id"` + GroupID int `json:"group_id,omitempty"` +} + +type LinkZammadTicketRequest struct { + ConnectionID string `json:"connection_id"` + TicketNumber string `json:"ticket_number"` +} + +type UpdateZammadTicketLinkRequest struct { + StateID *int `json:"state_id,omitempty"` + GroupID *int `json:"group_id,omitempty"` + OwnerID *int `json:"owner_id,omitempty"` +} + +type ZammadOwner struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type ZammadTicketLink struct { + ID string `json:"id"` + ItemID int `json:"item_id"` + ProviderID string `json:"connection_id"` + ProviderName string `json:"connection_name,omitempty"` + ItemIntegrationLinkID string `json:"item_integration_link_id,omitempty"` + TicketID int `json:"ticket_id,omitempty"` + TicketNumber string `json:"ticket_number,omitempty"` + TicketURL string `json:"ticket_url,omitempty"` + GroupID int `json:"group_id,omitempty"` + GroupName string `json:"group_name,omitempty"` + OwnerID int `json:"owner_id,omitempty"` + OwnerName string `json:"owner_name,omitempty"` + CorrelationKey string `json:"correlation_key"` + SyncState ZammadSyncState `json:"sync_state"` + LastStatusID int `json:"last_status_id,omitempty"` + LastStatusName string `json:"last_status_name,omitempty"` + LastSyncedAt *time.Time `json:"last_synced_at,omitempty"` + LastAttemptAt *time.Time `json:"-"` + NextAttemptAt *time.Time `json:"-"` + LastError string `json:"last_error,omitempty"` + CompletionApplied bool `json:"-"` + CreatedBy *int `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ZammadTicketLinkResponse struct { + ID string `json:"id"` + ProviderID string `json:"connection_id"` + ProviderName string `json:"connection_name,omitempty"` + TicketID int `json:"ticket_id,omitempty"` + TicketNumber string `json:"ticket_number,omitempty"` + TicketURL string `json:"ticket_url,omitempty"` + GroupID int `json:"group_id,omitempty"` + GroupName string `json:"group_name,omitempty"` + OwnerID int `json:"owner_id,omitempty"` + OwnerName string `json:"owner_name,omitempty"` + SyncState ZammadSyncState `json:"sync_state"` + LastStatusID int `json:"last_status_id,omitempty"` + LastStatusName string `json:"last_status_name,omitempty"` + LastSyncedAt *time.Time `json:"last_synced_at,omitempty"` + LastError string `json:"last_error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (link *ZammadTicketLink) Response() ZammadTicketLinkResponse { + return ZammadTicketLinkResponse{ + ID: link.ID, ProviderID: link.ProviderID, ProviderName: link.ProviderName, + TicketID: link.TicketID, TicketNumber: link.TicketNumber, TicketURL: link.TicketURL, + GroupID: link.GroupID, GroupName: link.GroupName, SyncState: link.SyncState, + OwnerID: link.OwnerID, OwnerName: link.OwnerName, + LastStatusID: link.LastStatusID, LastStatusName: link.LastStatusName, + LastSyncedAt: link.LastSyncedAt, LastError: link.LastError, + CreatedAt: link.CreatedAt, UpdatedAt: link.UpdatedAt, + } +} diff --git a/internal/repository/action_credential_repository.go b/internal/repository/action_credential_repository.go index 65f81e4f2..2c95e9e25 100644 --- a/internal/repository/action_credential_repository.go +++ b/internal/repository/action_credential_repository.go @@ -251,6 +251,41 @@ func (r *ActionCredentialRepository) UpdateActionCredentialMetadataWithWorkspace }) } +// UpdateManagedCredentialTx updates a provider-owned credential inside the +// caller's transaction so provider policy and credential scope cannot diverge. +func (r *ActionCredentialRepository) UpdateManagedCredentialTx(tx database.Tx, c *models.ActionCredential, workspaceIDs []int, encryptedSecret, prefix *string) error { + if err := updateActionCredentialMetadata(tx, c); err != nil { + return err + } + if err := setCredentialWorkspaces(tx, c.ID, workspaceIDs); err != nil { + return err + } + if encryptedSecret != nil && prefix != nil { + _, err := tx.ExecWrite(`UPDATE action_credentials + SET encrypted_secret = ?, secret_prefix = ?, updated_at = ? WHERE id = ?`, + *encryptedSecret, nullableString(*prefix), time.Now(), c.ID) + return err + } + return nil +} + +// UpdateManagedCredentialSecretTx rotates only secret material. The metadata +// predicate keeps the provider purpose/owner check valid at the write point, +// while deliberately leaving name, enabled state, and workspace scope alone. +func (r *ActionCredentialRepository) UpdateManagedCredentialSecretTx(tx database.Tx, id int, expectedMetadata, encryptedSecret, prefix string) (bool, error) { + if strings.TrimSpace(encryptedSecret) == "" { + return false, errors.New("managed action credential: encrypted_secret is required") + } + result, err := tx.ExecWrite(`UPDATE action_credentials + SET encrypted_secret = ?, secret_prefix = ?, updated_at = ? + WHERE id = ? AND secret_metadata = ?`, encryptedSecret, nullableString(prefix), time.Now(), id, expectedMetadata) + if err != nil { + return false, fmt.Errorf("failed to rotate managed action credential: %w", err) + } + rows, err := result.RowsAffected() + return rows == 1, err +} + // RotateActionCredential replaces the encrypted secret and prefix on an // existing credential row. func (r *ActionCredentialRepository) RotateActionCredential(id int, encryptedSecret, prefix string) error { diff --git a/internal/repository/errors.go b/internal/repository/errors.go index c9aaac1bc..705acb478 100644 --- a/internal/repository/errors.go +++ b/internal/repository/errors.go @@ -17,6 +17,9 @@ var ( // ErrDuplicateEntry is returned when a unique constraint is violated ErrDuplicateEntry = errors.New("duplicate entry") + // ErrConcurrentUpdate is returned when an optimistic write loses a race. + ErrConcurrentUpdate = errors.New("concurrent update") + // ErrDefaultChannel is returned when a delete is rejected because the // channel became (or remained) the default for its route. ErrDefaultChannel = errors.New("cannot delete default channel") diff --git a/internal/repository/integration_provider_repository.go b/internal/repository/integration_provider_repository.go index d4b180eef..b3f23cd75 100644 --- a/internal/repository/integration_provider_repository.go +++ b/internal/repository/integration_provider_repository.go @@ -8,6 +8,7 @@ import ( "time" "windshift/internal/database" + "windshift/internal/integrations" "windshift/internal/models" ) @@ -80,7 +81,9 @@ func (r *IntegrationProviderRepository) List() ([]IntegrationProvider, error) { if scanErr != nil { return nil, fmt.Errorf("scan integration_provider: %w", scanErr) } - providers = append(providers, p) + if integrations.SupportsAdminProviderCRUD(p.ProviderType) { + providers = append(providers, p) + } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate integration_providers: %w", err) @@ -98,12 +101,18 @@ func (r *IntegrationProviderRepository) GetByID(id string) (*IntegrationProvider if err != nil { return nil, fmt.Errorf("get integration_provider %s: %w", id, err) } + if !integrations.SupportsAdminProviderCRUD(p.ProviderType) { + return nil, ErrNotFound + } return &p, nil } // Create inserts a new integration_provider. Returns ErrDuplicateEntry when // the slug collides with an existing row. func (r *IntegrationProviderRepository) Create(req IntegrationProviderInsert) error { + if !integrations.SupportsAdminProviderCRUD(models.IntegrationProviderType(req.ProviderType)) { + return ErrInvalidInput + } _, err := r.db.ExecWrite(` INSERT INTO integration_providers ( id, slug, name, provider_type, enabled, @@ -127,6 +136,10 @@ func (r *IntegrationProviderRepository) Create(req IntegrationProviderInsert) er // updated_at is always bumped when at least one field is set. Returns // ErrNotFound when no row matches and ErrDuplicateEntry on slug collision. func (r *IntegrationProviderRepository) Update(id string, req IntegrationProviderUpdate) error { + if _, err := r.GetByID(id); err != nil { + return err + } + var sets []string var args []any @@ -156,11 +169,6 @@ func (r *IntegrationProviderRepository) Update(id string, req IntegrationProvide } if len(sets) == 0 { - // No fields to update — verify the row exists so callers still get - // ErrNotFound semantics for a missing id. - if _, err := r.GetByID(id); err != nil { - return err - } return nil } @@ -185,6 +193,9 @@ func (r *IntegrationProviderRepository) Update(id string, req IntegrationProvide // Delete removes a row. Returns ErrNotFound when no row matches. func (r *IntegrationProviderRepository) Delete(id string) error { + if _, err := r.GetByID(id); err != nil { + return err + } result, err := r.db.ExecWrite("DELETE FROM integration_providers WHERE id = ?", id) if err != nil { return fmt.Errorf("delete integration_provider %s: %w", id, err) diff --git a/internal/repository/item_repository.go b/internal/repository/item_repository.go index e95182db5..80ddaa79d 100644 --- a/internal/repository/item_repository.go +++ b/internal/repository/item_repository.go @@ -222,6 +222,32 @@ func (r *ItemRepository) FindByIDsForUpdateContext(ctx context.Context, tx datab return items, nil } +// LockWorkspaceItemsTx prevents new foreign-key references from being added to +// the workspace's current items while a destructive workspace operation runs. +// The caller must lock the workspace row first so concurrent item creation is +// fenced as well. +func (r *ItemRepository) LockWorkspaceItemsTx(tx database.Tx, workspaceID int) error { + query := "SELECT id FROM items WHERE workspace_id = ? ORDER BY id" + if r.db.GetDriverName() == "postgres" { + query += " FOR UPDATE" + } + rows, err := tx.Query(query, workspaceID) + if err != nil { + return fmt.Errorf("lock workspace items: %w", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var itemID int + if err := rows.Scan(&itemID); err != nil { + return fmt.Errorf("scan locked workspace item: %w", err) + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate locked workspace items: %w", err) + } + return nil +} + type ItemWithWorkspaceStatus struct { *models.Item WorkspaceActive bool diff --git a/internal/repository/workspace_repository.go b/internal/repository/workspace_repository.go index 92011ca95..7f64ab9d8 100644 --- a/internal/repository/workspace_repository.go +++ b/internal/repository/workspace_repository.go @@ -347,6 +347,15 @@ func (r *WorkspaceRepository) Delete(id int) error { return nil } +// DeleteTx removes a workspace inside the caller's transaction. Cache +// invalidation remains the caller's post-commit responsibility. +func (r *WorkspaceRepository) DeleteTx(tx database.Tx, id int) error { + if _, err := tx.ExecWrite("DELETE FROM workspaces WHERE id = ?", id); err != nil { + return err + } + return nil +} + // FindMissingOrPersonal accepts a set of workspace IDs and returns those that // don't exist or are flagged as personal — i.e. invalid as portal/form // submission targets. Used by UpdateChannelConfig to reject bogus IDs before @@ -401,6 +410,20 @@ func (r *WorkspaceRepository) Exists(id int) (bool, error) { return exists, err } +// LockForDeleteTx locks a workspace before its items are fenced and deleted. +func (r *WorkspaceRepository) LockForDeleteTx(tx database.Tx, id int) (bool, error) { + query := "SELECT id FROM workspaces WHERE id = ?" + if r.db.GetDriverName() == "postgres" { + query += " FOR UPDATE" + } + var lockedID int + err := tx.QueryRow(query, id).Scan(&lockedID) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err +} + // KeyExists checks if a workspace key exists func (r *WorkspaceRepository) KeyExists(key string) (bool, error) { var exists bool @@ -707,3 +730,14 @@ func (r *WorkspaceRepository) DropItemSequence(workspaceID int64) error { _, err := r.db.ExecWrite(fmt.Sprintf(`DROP SEQUENCE IF EXISTS %q`, seqName)) return err } + +// DropItemSequenceTx removes the per-workspace sequence inside the caller's +// transaction. No-op on SQLite. +func (r *WorkspaceRepository) DropItemSequenceTx(tx database.Tx, workspaceID int64) error { + if r.db.GetDriverName() != "postgres" { + return nil + } + seqName := fmt.Sprintf("workspace_%d_item_seq", workspaceID) + _, err := tx.ExecWrite(fmt.Sprintf(`DROP SEQUENCE IF EXISTS %q`, seqName)) + return err +} diff --git a/internal/repository/zammad_oauth_repository.go b/internal/repository/zammad_oauth_repository.go new file mode 100644 index 000000000..876a79c91 --- /dev/null +++ b/internal/repository/zammad_oauth_repository.go @@ -0,0 +1,264 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + "time" + + "windshift/internal/database" + "windshift/internal/models" +) + +// ZammadOAuthToken is private persistence state. It deliberately contains +// ciphertext only and is never returned by handlers. +type ZammadOAuthToken struct { + ProviderID string + OAuthGeneration int64 + ExpiresAt time.Time + ReauthorizationRequired bool +} + +type ZammadOAuthState struct { + ProviderID string + InitiatedBy int + OAuthGeneration int64 +} + +func (r *ZammadRepository) CreateOAuthState(state, providerID string, initiatedBy int, generation int64, expiresAt time.Time) error { + // Expired-state cleanup is deliberately outside the per-connection + // transaction. PostgreSQL must never hold an OAuth-state lock while waiting + // for the connection row, since reset paths lock in the opposite order. + if _, err := r.db.ExecWrite("DELETE FROM zammad_oauth_state WHERE expires_at <= CURRENT_TIMESTAMP"); err != nil { + return err + } + return database.WithTx(r.db, func(tx database.Tx) error { + claim, err := tx.Exec(`UPDATE zammad_connections SET oauth_attempt_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE provider_id = ? AND oauth_generation = ?`, state, providerID, generation) + if err != nil { + return err + } + rows, err := claim.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return ErrNotFound + } + result, err := tx.Exec(`INSERT INTO zammad_oauth_state(state, provider_id, initiated_by, oauth_generation, expires_at) + SELECT ?, provider_id, ?, oauth_generation, ? FROM zammad_connections + WHERE provider_id = ? AND oauth_generation = ? + ON CONFLICT(provider_id) DO UPDATE SET + state = excluded.state, initiated_by = excluded.initiated_by, + oauth_generation = excluded.oauth_generation, expires_at = excluded.expires_at, + created_at = CURRENT_TIMESTAMP`, state, initiatedBy, expiresAt, providerID, generation) + if err != nil { + return err + } + rows, err = result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return ErrNotFound + } + return nil + }) +} + +// ConsumeOAuthState atomically deletes a usable state. A second callback, an +// expired state, or a state for another connection cannot observe it. +func (r *ZammadRepository) ConsumeOAuthState(state string) (*ZammadOAuthState, error) { + consumed := &ZammadOAuthState{} + err := r.db.QueryRow(`DELETE FROM zammad_oauth_state + WHERE state = ? AND expires_at > CURRENT_TIMESTAMP + RETURNING provider_id, initiated_by, oauth_generation`, state).Scan(&consumed.ProviderID, &consumed.InitiatedBy, &consumed.OAuthGeneration) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("consume Zammad OAuth state: %w", err) + } + return consumed, nil +} + +// InvalidateOAuthState discards an abandoned provider callback without +// returning any state metadata. It is intentionally idempotent. +func (r *ZammadRepository) InvalidateOAuthState(state string) error { + var providerID string + var generation int64 + err := r.db.QueryRow(`SELECT provider_id, oauth_generation + FROM zammad_oauth_state WHERE state = ?`, state).Scan(&providerID, &generation) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + return database.WithTx(r.db, func(tx database.Tx) error { + // Keep the same connection -> state lock order as StartOAuth and reset. + if _, err := tx.Exec(`UPDATE zammad_connections SET oauth_attempt_id = NULL + WHERE provider_id = ? AND oauth_generation = ? AND oauth_attempt_id = ?`, providerID, generation, state); err != nil { + return err + } + _, err := tx.Exec("DELETE FROM zammad_oauth_state WHERE state = ?", state) + return err + }) +} + +func (r *ZammadRepository) UpsertOAuthToken(token ZammadOAuthToken) error { + if token.OAuthGeneration == 0 { + if err := r.db.QueryRow("SELECT oauth_generation FROM zammad_connections WHERE provider_id = ?", token.ProviderID).Scan(&token.OAuthGeneration); err != nil { + return err + } + } + _, err := r.db.ExecWrite(`INSERT INTO zammad_oauth_tokens + (provider_id, oauth_generation, expires_at, reauthorization_required, refresh_lock_until, refresh_claim_owner, updated_at) + VALUES (?, ?, ?, false, NULL, NULL, CURRENT_TIMESTAMP) + ON CONFLICT(provider_id) DO UPDATE SET + oauth_generation = excluded.oauth_generation, + expires_at = excluded.expires_at, + reauthorization_required = false, + refresh_lock_until = NULL, + refresh_claim_owner = NULL, + updated_at = CURRENT_TIMESTAMP`, token.ProviderID, token.OAuthGeneration, token.ExpiresAt) + return err +} + +func (r *ZammadRepository) UpsertOAuthTokenTx(tx database.Tx, token ZammadOAuthToken) error { + _, err := tx.Exec(`INSERT INTO zammad_oauth_tokens + (provider_id, oauth_generation, expires_at, reauthorization_required, refresh_lock_until, refresh_claim_owner, updated_at) + VALUES (?, ?, ?, false, NULL, NULL, CURRENT_TIMESTAMP) + ON CONFLICT(provider_id) DO UPDATE SET expires_at = excluded.expires_at, + oauth_generation = excluded.oauth_generation, reauthorization_required = false, + refresh_lock_until = NULL, refresh_claim_owner = NULL, updated_at = CURRENT_TIMESTAMP`, token.ProviderID, token.OAuthGeneration, token.ExpiresAt) + return err +} + +func (r *ZammadRepository) GetOAuthToken(providerID string) (*ZammadOAuthToken, error) { + t := &ZammadOAuthToken{ProviderID: providerID} + err := r.db.QueryRow(`SELECT oauth_generation, expires_at, reauthorization_required + FROM zammad_oauth_tokens WHERE provider_id = ?`, providerID).Scan(&t.OAuthGeneration, &t.ExpiresAt, &t.ReauthorizationRequired) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get Zammad OAuth token: %w", err) + } + return t, nil +} + +func (r *ZammadRepository) GetOAuthTokenForRefreshClaim(providerID string, generation int64, owner string) (*ZammadOAuthToken, error) { + t := &ZammadOAuthToken{ProviderID: providerID} + err := r.db.QueryRow(`SELECT oauth_generation, expires_at, reauthorization_required + FROM zammad_oauth_tokens WHERE provider_id = ? AND oauth_generation = ? + AND refresh_claim_owner = ? AND refresh_lock_until > CURRENT_TIMESTAMP`, providerID, generation, owner). + Scan(&t.OAuthGeneration, &t.ExpiresAt, &t.ReauthorizationRequired) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get claimed Zammad OAuth token: %w", err) + } + return t, nil +} + +func (r *ZammadRepository) ClaimOAuthRefresh(providerID string, generation int64, owner string, until time.Time) (bool, error) { + result, err := r.db.ExecWrite(`UPDATE zammad_oauth_tokens + SET refresh_lock_until = ?, refresh_claim_owner = ?, updated_at = CURRENT_TIMESTAMP + WHERE provider_id = ? AND oauth_generation = ? AND reauthorization_required = false + AND (refresh_lock_until IS NULL OR refresh_lock_until < CURRENT_TIMESTAMP) + AND EXISTS (SELECT 1 FROM zammad_connections zc WHERE zc.provider_id = zammad_oauth_tokens.provider_id AND zc.oauth_generation = ?)`, until, owner, providerID, generation, generation) + if err != nil { + return false, err + } + n, err := result.RowsAffected() + return n == 1, err +} + +func (r *ZammadRepository) ReleaseOAuthRefreshClaim(providerID string, generation int64, owner string) error { + _, err := r.db.ExecWrite(`UPDATE zammad_oauth_tokens + SET refresh_lock_until = NULL, refresh_claim_owner = NULL, updated_at = CURRENT_TIMESTAMP + WHERE provider_id = ? AND oauth_generation = ? AND refresh_claim_owner = ?`, providerID, generation, owner) + return err +} + +func (r *ZammadRepository) GuardOAuthGenerationTx(tx database.Tx, providerID string, generation int64) (bool, error) { + result, err := tx.Exec(`UPDATE zammad_connections SET oauth_generation = oauth_generation + WHERE provider_id = ? AND oauth_generation = ?`, providerID, generation) + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + return rows == 1, err +} + +func (r *ZammadRepository) GuardOAuthCallbackTx(tx database.Tx, providerID string, generation int64, attemptID string) (bool, error) { + result, err := tx.Exec(`UPDATE zammad_connections SET oauth_attempt_id = NULL + WHERE provider_id = ? AND oauth_generation = ? AND oauth_attempt_id = ? + AND EXISTS ( + SELECT 1 FROM integration_providers ip + WHERE ip.id = zammad_connections.provider_id AND ip.enabled = true + )`, providerID, generation, attemptID) + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + return rows == 1, err +} + +func (r *ZammadRepository) ClearOAuthAttempt(providerID string, generation int64, attemptID string) error { + _, err := r.db.ExecWrite(`UPDATE zammad_connections SET oauth_attempt_id = NULL + WHERE provider_id = ? AND oauth_generation = ? AND oauth_attempt_id = ?`, providerID, generation, attemptID) + return err +} + +func (r *ZammadRepository) GuardOAuthRefreshClaimTx(tx database.Tx, providerID string, generation int64, owner string) (bool, error) { + result, err := tx.Exec(`UPDATE zammad_oauth_tokens SET updated_at = updated_at + WHERE provider_id = ? AND oauth_generation = ? AND refresh_claim_owner = ? + AND refresh_lock_until > CURRENT_TIMESTAMP AND reauthorization_required = false`, providerID, generation, owner) + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + return rows == 1, err +} + +func (r *ZammadRepository) MarkOAuthReauthorizationRequiredTx(tx database.Tx, providerID string, generation int64, owner string) (bool, error) { + result, err := tx.Exec(`UPDATE zammad_oauth_tokens + SET reauthorization_required = true, refresh_lock_until = NULL, refresh_claim_owner = NULL, updated_at = CURRENT_TIMESTAMP + WHERE provider_id = ? AND oauth_generation = ? AND refresh_claim_owner = ?`, providerID, generation, owner) + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + return rows == 1, err +} + +// ResetOAuthAuthorizationTx removes all token metadata and in-flight states +// when OAuth client credentials change. The matching credential payload is +// reset by the service in the same transaction. +func (r *ZammadRepository) ResetOAuthAuthorizationTx(tx database.Tx, providerID string) error { + result, err := tx.Exec(`UPDATE zammad_connections + SET oauth_generation = oauth_generation + 1, oauth_attempt_id = NULL, updated_at = CURRENT_TIMESTAMP + WHERE provider_id = ?`, providerID) + if err != nil { + return err + } + if rows, err := result.RowsAffected(); err != nil || rows != 1 { + if err != nil { + return err + } + return ErrNotFound + } + if _, err := tx.Exec("DELETE FROM zammad_oauth_tokens WHERE provider_id = ?", providerID); err != nil { + return err + } + _, err = tx.Exec("DELETE FROM zammad_oauth_state WHERE provider_id = ?", providerID) + return err +} + +func (r *ZammadRepository) SetOAuthAuthMethod(connection *models.ZammadConnection) error { + _, err := r.db.ExecWrite(`UPDATE zammad_connections SET credential_id = ?, auth_method = ?, updated_at = CURRENT_TIMESTAMP WHERE provider_id = ?`, nullablePositiveInt(connection.CredentialID), connection.AuthMethod, connection.ProviderID) + return err +} diff --git a/internal/repository/zammad_repository.go b/internal/repository/zammad_repository.go new file mode 100644 index 000000000..3762002a9 --- /dev/null +++ b/internal/repository/zammad_repository.go @@ -0,0 +1,1170 @@ +package repository + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + "windshift/internal/database" + "windshift/internal/models" +) + +type ZammadRepository struct { + db database.Database +} + +type ZammadTicketLinkReservationSnapshot struct { + BaseURL string + CorrelationField string + ConnectionEnabled bool + DefaultGroupID int + DefaultGroupName string + AllowedGroups []models.ZammadGroupRef + DefaultCustomer string + WorkspaceID int + WorkspaceItemNumber int + WorkspaceKey string + WorkspaceAllowed bool +} + +type ZammadTicketLinkScope struct { + ItemID int + WorkspaceID int + GroupID int + GroupName string + SetupComplete bool + SyncLocked bool +} + +type ZammadConnectionMutationSnapshot struct { + BaseURL string + CorrelationField string + DefaultGroupID int + DefaultGroupName string + AllowedGroups []models.ZammadGroupRef + ClosedStateIDs []int + CompletionStatusID *int +} + +func NewZammadRepository(db database.Database) *ZammadRepository { + return &ZammadRepository{db: db} +} + +const zammadConnectionColumns = ` + zc.provider_id, ip.slug, ip.name, ip.enabled, zc.base_url, + zc.credential_id, zc.auth_method, zc.oauth_generation, zc.config_revision, COALESCE(zc.oauth_attempt_id, ''), ip.oauth_client_id, ip.oauth_client_secret_encrypted, + EXISTS(SELECT 1 FROM zammad_oauth_tokens zot WHERE zot.provider_id = zc.provider_id AND zot.reauthorization_required = false), + COALESCE((SELECT reauthorization_required FROM zammad_oauth_tokens zot WHERE zot.provider_id = zc.provider_id), false), + zc.default_group_id, zc.default_group_name, + zc.allowed_groups, zc.default_customer, zc.correlation_field, zc.closed_state_ids, + zc.completion_status_id, (SELECT applies_to_all_workspaces FROM action_credentials ac WHERE ac.id = zc.credential_id), + zc.last_tested_at, zc.last_test_error, zc.created_by, + zc.created_at, zc.updated_at` + +func (r *ZammadRepository) ListConnections() ([]*models.ZammadConnection, error) { + rows, err := r.db.Query(`SELECT ` + zammadConnectionColumns + ` + FROM zammad_connections zc + JOIN integration_providers ip ON ip.id = zc.provider_id + ORDER BY ip.name`) + if err != nil { + return nil, fmt.Errorf("list Zammad connections: %w", err) + } + defer func() { _ = rows.Close() }() + return r.scanConnections(rows) +} + +func (r *ZammadRepository) ListConnectionsForWorkspace(workspaceID int) ([]*models.ZammadConnection, error) { + rows, err := r.db.Query(`SELECT `+zammadConnectionColumns+` + FROM zammad_connections zc + JOIN integration_providers ip ON ip.id = zc.provider_id + WHERE ip.enabled = true AND ( + EXISTS (SELECT 1 FROM action_credentials ac WHERE ac.id = zc.credential_id AND ac.applies_to_all_workspaces = true) OR EXISTS ( + SELECT 1 FROM action_credential_workspaces acw + WHERE acw.credential_id = zc.credential_id AND acw.workspace_id = ? + ) + ) + ORDER BY ip.name`, workspaceID) + if err != nil { + return nil, fmt.Errorf("list workspace Zammad connections: %w", err) + } + defer func() { _ = rows.Close() }() + return r.scanConnections(rows) +} + +func (r *ZammadRepository) GetConnection(id string) (*models.ZammadConnection, error) { + row := r.db.QueryRow(`SELECT `+zammadConnectionColumns+` + FROM zammad_connections zc + JOIN integration_providers ip ON ip.id = zc.provider_id + WHERE zc.provider_id = ?`, id) + connection, err := scanZammadConnection(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get Zammad connection: %w", err) + } + connection.WorkspaceIDs, err = r.connectionWorkspaceIDs(id) + if err != nil { + return nil, err + } + return connection, nil +} + +func (r *ZammadRepository) IsConnectionAvailableToWorkspace(id string, workspaceID int) (bool, error) { + var available bool + err := r.db.QueryRow(`SELECT EXISTS( + SELECT 1 FROM zammad_connections zc + JOIN integration_providers ip ON ip.id = zc.provider_id + WHERE zc.provider_id = ? AND ip.enabled = true AND ( + EXISTS (SELECT 1 FROM action_credentials ac WHERE ac.id = zc.credential_id AND ac.applies_to_all_workspaces = true) OR EXISTS ( + SELECT 1 FROM action_credential_workspaces acw + WHERE acw.credential_id = zc.credential_id AND acw.workspace_id = ? + ) + ))`, id, workspaceID).Scan(&available) + return available, err +} + +// IsConnectionScopedToWorkspace checks only the configured workspace scope. +// Cleanup paths use it so an administrator can disable a connection without +// making its existing links impossible to remove. +func (r *ZammadRepository) IsConnectionScopedToWorkspace(id string, workspaceID int) (bool, error) { + var scoped bool + err := r.db.QueryRow(`SELECT EXISTS( + SELECT 1 FROM zammad_connections zc + WHERE zc.provider_id = ? AND ( + EXISTS (SELECT 1 FROM action_credentials ac WHERE ac.id = zc.credential_id AND ac.applies_to_all_workspaces = true) OR EXISTS ( + SELECT 1 FROM action_credential_workspaces acw + WHERE acw.credential_id = zc.credential_id AND acw.workspace_id = ? + ) + ))`, id, workspaceID).Scan(&scoped) + return scoped, err +} + +func (r *ZammadRepository) CreateConnection(connection *models.ZammadConnection) error { + closedJSON, err := json.Marshal(connection.ClosedStateIDs) + if err != nil { + return err + } + allowedGroupsJSON, err := json.Marshal(connection.AllowedGroups) + if err != nil { + return err + } + return database.WithTx(r.db, func(tx database.Tx) error { + if _, err := tx.Exec(`INSERT INTO integration_providers + (id, slug, name, provider_type, enabled, oauth_client_id, oauth_client_secret_encrypted, provider_config) + VALUES (?, ?, ?, ?, ?, ?, ?, '{}')`, connection.ProviderID, connection.Slug, + connection.Name, models.IntegrationProviderZammad, connection.Enabled, + nullableString(connection.OAuthClientID), nullableString(connection.OAuthClientSecretEncrypted)); err != nil { + if database.IsUniqueConstraintError(err) { + return ErrDuplicateEntry + } + return err + } + if _, err := tx.Exec(`INSERT INTO zammad_connections + (provider_id, credential_id, auth_method, base_url, default_group_id, + default_group_name, allowed_groups, default_customer, correlation_field, + closed_state_ids, completion_status_id, + created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, connection.ProviderID, + nullablePositiveInt(connection.CredentialID), connection.AuthMethod, connection.BaseURL, nullablePositiveInt(connection.DefaultGroupID), + connection.DefaultGroupName, string(allowedGroupsJSON), connection.DefaultCustomer, connection.CorrelationField, + string(closedJSON), connection.CompletionStatusID, connection.CreatedBy); err != nil { + return err + } + return nil + }) +} + +func (r *ZammadRepository) UpdateConnection(connection *models.ZammadConnection) error { + return database.WithTx(r.db, func(tx database.Tx) error { + return r.UpdateConnectionTx(tx, connection) + }) +} + +func (r *ZammadRepository) UpdateConnectionTx(tx database.Tx, connection *models.ZammadConnection) error { + closedJSON, err := json.Marshal(connection.ClosedStateIDs) + if err != nil { + return err + } + allowedGroupsJSON, err := json.Marshal(connection.AllowedGroups) + if err != nil { + return err + } + result, err := tx.Exec(`UPDATE zammad_connections SET + credential_id = ?, auth_method = ?, base_url = ?, default_group_id = ?, default_group_name = ?, allowed_groups = ?, + default_customer = ?, correlation_field = ?, closed_state_ids = ?, + completion_status_id = ?, config_revision = config_revision + 1, + updated_at = CURRENT_TIMESTAMP + WHERE provider_id = ? AND config_revision = ?`, nullablePositiveInt(connection.CredentialID), connection.AuthMethod, connection.BaseURL, nullablePositiveInt(connection.DefaultGroupID), + connection.DefaultGroupName, string(allowedGroupsJSON), connection.DefaultCustomer, connection.CorrelationField, + string(closedJSON), connection.CompletionStatusID, connection.ProviderID, connection.ConfigRevision) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return ErrConcurrentUpdate + } + connection.ConfigRevision++ + result, err = tx.Exec(`UPDATE integration_providers + SET slug = ?, name = ?, enabled = ?, oauth_client_id = ?, oauth_client_secret_encrypted = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND provider_type = ?`, connection.Slug, connection.Name, + connection.Enabled, nullableString(connection.OAuthClientID), nullableString(connection.OAuthClientSecretEncrypted), connection.ProviderID, models.IntegrationProviderZammad) + if err != nil { + if database.IsUniqueConstraintError(err) { + return ErrDuplicateEntry + } + return err + } + if rows, _ := result.RowsAffected(); rows == 0 { + return ErrNotFound + } + return nil +} + +func (r *ZammadRepository) DeleteConnectionTx(tx database.Tx, id string) error { + var credentialID sql.NullInt64 + if err := tx.QueryRow("SELECT credential_id FROM zammad_connections WHERE provider_id = ?", id).Scan(&credentialID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + if _, err := tx.Exec("DELETE FROM integration_providers WHERE id = ?", id); err != nil { + return err + } + if credentialID.Valid { + _, err := tx.Exec("DELETE FROM action_credentials WHERE id = ?", credentialID.Int64) + return err + } + return nil +} + +func (r *ZammadRepository) HasTicketLinksForConnectionTx(tx database.Tx, id string) (bool, error) { + var hasLinks bool + err := tx.QueryRow("SELECT EXISTS(SELECT 1 FROM zammad_ticket_links WHERE provider_id = ?)", id).Scan(&hasLinks) + return hasLinks, err +} + +func (r *ZammadRepository) HasTicketLinksForItemsTx(tx database.Tx, itemIDs []int) (bool, error) { + if len(itemIDs) == 0 { + return false, nil + } + placeholders, args := inPlaceholders(itemIDs) + var hasLinks bool + err := tx.QueryRow("SELECT EXISTS(SELECT 1 FROM zammad_ticket_links WHERE item_id IN ("+placeholders+"))", args...).Scan(&hasLinks) + if err != nil { + return false, fmt.Errorf("check Zammad ticket links for items: %w", err) + } + return hasLinks, nil +} + +func (r *ZammadRepository) HasTicketLinksForWorkspaceTx(tx database.Tx, workspaceID int) (bool, error) { + var hasLinks bool + err := tx.QueryRow(`SELECT EXISTS( + SELECT 1 + FROM zammad_ticket_links ztl + JOIN items i ON i.id = ztl.item_id + WHERE i.workspace_id = ? + )`, workspaceID).Scan(&hasLinks) + if err != nil { + return false, fmt.Errorf("check Zammad ticket links for workspace: %w", err) + } + return hasLinks, nil +} + +// LockConnectionTx is the first step of every connection mutation and ticket +// reservation. PostgreSQL uses an explicit row lock; SQLite write transactions +// are already started with _txlock=immediate. +func (r *ZammadRepository) LockConnectionTx(tx database.Tx, id string) error { + query := "SELECT provider_id FROM zammad_connections WHERE provider_id = ?" + if r.db.GetDriverName() == "postgres" { + query += " FOR UPDATE" + } + var providerID string + if err := tx.QueryRow(query, id).Scan(&providerID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + return nil +} + +// lockTicketLinkConnectionTx reads a ticket link's provider before acquiring +// the provider's connection lock. The preliminary read intentionally carries +// no row lock, so every actual lock acquisition remains connection first and +// no ticket-link row can be locked ahead of its connection on PostgreSQL. +func (r *ZammadRepository) lockTicketLinkConnectionTx(tx database.Tx, linkID string) error { + var providerID string + if err := tx.QueryRow("SELECT provider_id FROM zammad_ticket_links WHERE id = ?", linkID).Scan(&providerID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + if err := r.LockConnectionTx(tx, providerID); err != nil { + return err + } + return nil +} + +func (r *ZammadRepository) ConnectionMutationSnapshotTx(tx database.Tx, id string) (*ZammadConnectionMutationSnapshot, error) { + snapshot := &ZammadConnectionMutationSnapshot{} + var allowedGroupsJSON, closedStateIDsJSON string + var completionStatusID sql.NullInt64 + if err := tx.QueryRow(`SELECT base_url, correlation_field, COALESCE(default_group_id, 0), + default_group_name, allowed_groups, closed_state_ids, completion_status_id + FROM zammad_connections WHERE provider_id = ?`, id).Scan( + &snapshot.BaseURL, &snapshot.CorrelationField, &snapshot.DefaultGroupID, + &snapshot.DefaultGroupName, &allowedGroupsJSON, &closedStateIDsJSON, &completionStatusID, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if err := json.Unmarshal([]byte(allowedGroupsJSON), &snapshot.AllowedGroups); err != nil { + return nil, fmt.Errorf("decode Zammad allowed groups: %w", err) + } + if err := json.Unmarshal([]byte(closedStateIDsJSON), &snapshot.ClosedStateIDs); err != nil { + return nil, fmt.Errorf("decode Zammad closed state IDs: %w", err) + } + if completionStatusID.Valid { + value := int(completionStatusID.Int64) + snapshot.CompletionStatusID = &value + } + return snapshot, nil +} + +func (r *ZammadRepository) ConnectionGroupPolicyTx(tx database.Tx, id string) (defaultGroupID int, defaultGroupName string, allowedGroups []models.ZammadGroupRef, queryErr error) { + var allowedGroupsJSON string + if err := tx.QueryRow(`SELECT COALESCE(default_group_id, 0), default_group_name, allowed_groups + FROM zammad_connections WHERE provider_id = ?`, id).Scan(&defaultGroupID, &defaultGroupName, &allowedGroupsJSON); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, "", nil, ErrNotFound + } + return 0, "", nil, err + } + if err := json.Unmarshal([]byte(allowedGroupsJSON), &allowedGroups); err != nil { + return 0, "", nil, fmt.Errorf("decode Zammad allowed groups: %w", err) + } + return defaultGroupID, defaultGroupName, allowedGroups, nil +} + +func (r *ZammadRepository) LockTicketLinkReservationTx(tx database.Tx, providerID string, itemID int) (*ZammadTicketLinkReservationSnapshot, error) { + connectionQuery := `SELECT zc.base_url, zc.correlation_field, ip.enabled, + COALESCE(zc.default_group_id, 0), zc.default_group_name, zc.allowed_groups, zc.default_customer + FROM zammad_connections zc + JOIN integration_providers ip ON ip.id = zc.provider_id + WHERE zc.provider_id = ?` + if r.db.GetDriverName() == "postgres" { + connectionQuery += " FOR UPDATE OF zc" + } + snapshot := &ZammadTicketLinkReservationSnapshot{} + var allowedGroupsJSON string + if err := tx.QueryRow(connectionQuery, providerID).Scan( + &snapshot.BaseURL, &snapshot.CorrelationField, &snapshot.ConnectionEnabled, + &snapshot.DefaultGroupID, &snapshot.DefaultGroupName, &allowedGroupsJSON, &snapshot.DefaultCustomer, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if err := json.Unmarshal([]byte(allowedGroupsJSON), &snapshot.AllowedGroups); err != nil { + return nil, fmt.Errorf("decode Zammad allowed groups: %w", err) + } + itemQuery := `SELECT i.workspace_id, i.workspace_item_number, w.key + FROM items i JOIN workspaces w ON w.id = i.workspace_id WHERE i.id = ?` + if r.db.GetDriverName() == "postgres" { + itemQuery += " FOR UPDATE OF i" + } + if err := tx.QueryRow(itemQuery, itemID).Scan(&snapshot.WorkspaceID, &snapshot.WorkspaceItemNumber, &snapshot.WorkspaceKey); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if err := tx.QueryRow(`SELECT EXISTS( + SELECT 1 FROM zammad_connections zc + WHERE zc.provider_id = ? AND ( + EXISTS (SELECT 1 FROM action_credentials ac WHERE ac.id = zc.credential_id AND ac.applies_to_all_workspaces = true) OR EXISTS ( + SELECT 1 FROM action_credential_workspaces acw + WHERE acw.credential_id = zc.credential_id AND acw.workspace_id = ? + ) + ))`, providerID, snapshot.WorkspaceID).Scan(&snapshot.WorkspaceAllowed); err != nil { + return nil, err + } + return snapshot, nil +} + +func (r *ZammadRepository) ListTicketLinkScopesForUpdateTx(tx database.Tx, providerID string) ([]ZammadTicketLinkScope, error) { + query := `SELECT i.id, i.workspace_id, COALESCE(ztl.group_id, 0), COALESCE(ztl.group_name, ''), + ztl.item_integration_link_id IS NOT NULL, + ztl.sync_lock_until IS NOT NULL AND ztl.sync_lock_until > CURRENT_TIMESTAMP + FROM zammad_ticket_links ztl + JOIN items i ON i.id = ztl.item_id + WHERE ztl.provider_id = ? ORDER BY i.id` + if r.db.GetDriverName() == "postgres" { + query += " FOR UPDATE OF i" + } + rows, err := tx.Query(query, providerID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + scopes := []ZammadTicketLinkScope{} + for rows.Next() { + var scope ZammadTicketLinkScope + if err := rows.Scan(&scope.ItemID, &scope.WorkspaceID, &scope.GroupID, &scope.GroupName, &scope.SetupComplete, &scope.SyncLocked); err != nil { + return nil, err + } + scopes = append(scopes, scope) + } + return scopes, rows.Err() +} + +func (r *ZammadRepository) HasTicketLinkUnavailableInWorkspaceTx(tx database.Tx, itemID, workspaceID int) (bool, error) { + var unavailable bool + err := tx.QueryRow(`SELECT EXISTS( + SELECT 1 FROM zammad_ticket_links ztl + JOIN zammad_connections zc ON zc.provider_id = ztl.provider_id + WHERE ztl.item_id = ? AND EXISTS ( + SELECT 1 FROM action_credentials ac WHERE ac.id = zc.credential_id AND ac.applies_to_all_workspaces = false + ) + AND NOT EXISTS ( + SELECT 1 FROM action_credential_workspaces acw + WHERE acw.credential_id = zc.credential_id AND acw.workspace_id = ? + ))`, itemID, workspaceID).Scan(&unavailable) + return unavailable, err +} + +func (r *ZammadRepository) SetConnectionTestResult(id string, testedAt time.Time, testError string) error { + _, err := r.db.ExecWrite(`UPDATE zammad_connections + SET last_tested_at = ?, last_test_error = ?, updated_at = CURRENT_TIMESTAMP + WHERE provider_id = ?`, testedAt, testError, id) + return err +} + +func (r *ZammadRepository) scanConnections(rows *sql.Rows) ([]*models.ZammadConnection, error) { + connections := []*models.ZammadConnection{} + for rows.Next() { + connection, err := scanZammadConnection(rows) + if err != nil { + return nil, err + } + connection.WorkspaceIDs, err = r.connectionWorkspaceIDs(connection.ProviderID) + if err != nil { + return nil, err + } + connections = append(connections, connection) + } + return connections, rows.Err() +} + +func scanZammadConnection(scanner interface{ Scan(...any) error }) (*models.ZammadConnection, error) { + var connection models.ZammadConnection + var credentialID, groupID, completionStatusID, createdBy sql.NullInt64 + var groupName, lastError sql.NullString + var authMethod, oauthClientID, oauthClientSecret sql.NullString + var testedAt sql.NullTime + var oauthConnected, reauthorizationRequired bool + var allowedGroupsJSON, closedJSON string + err := scanner.Scan(&connection.ProviderID, &connection.Slug, &connection.Name, + &connection.Enabled, &connection.BaseURL, &credentialID, &authMethod, &connection.OAuthGeneration, &connection.ConfigRevision, &connection.OAuthAttemptID, &oauthClientID, &oauthClientSecret, + &oauthConnected, &reauthorizationRequired, &groupID, + &groupName, &allowedGroupsJSON, &connection.DefaultCustomer, &connection.CorrelationField, + &closedJSON, &completionStatusID, &connection.AppliesToAllWorkspaces, + &testedAt, &lastError, &createdBy, &connection.CreatedAt, &connection.UpdatedAt) + if err != nil { + return nil, err + } + if groupID.Valid { + connection.DefaultGroupID = int(groupID.Int64) + } + if credentialID.Valid { + v := int(credentialID.Int64) + connection.CredentialID = v + } + if authMethod.Valid { + connection.AuthMethod = models.ZammadAuthMethod(authMethod.String) + } + if oauthClientID.Valid { + connection.OAuthClientID = oauthClientID.String + } + connection.HasOAuthClientSecret = oauthClientSecret.Valid && oauthClientSecret.String != "" + if oauthClientSecret.Valid { + connection.OAuthClientSecretEncrypted = oauthClientSecret.String + } + connection.OAuthConnected = oauthConnected + connection.ReauthorizationRequired = reauthorizationRequired + if groupName.Valid { + connection.DefaultGroupName = groupName.String + } + if completionStatusID.Valid { + v := int(completionStatusID.Int64) + connection.CompletionStatusID = &v + } + if testedAt.Valid { + connection.LastTestedAt = &testedAt.Time + } + if lastError.Valid { + connection.LastTestError = lastError.String + } + if createdBy.Valid { + v := int(createdBy.Int64) + connection.CreatedBy = &v + } + if err := json.Unmarshal([]byte(closedJSON), &connection.ClosedStateIDs); err != nil { + return nil, fmt.Errorf("decode closed_state_ids: %w", err) + } + if err := json.Unmarshal([]byte(allowedGroupsJSON), &connection.AllowedGroups); err != nil { + return nil, fmt.Errorf("decode allowed_groups: %w", err) + } + connection.HasAPIToken = connection.AuthMethod == models.ZammadAuthMethodAPIToken && connection.CredentialID > 0 + return &connection, nil +} + +func (r *ZammadRepository) connectionWorkspaceIDs(providerID string) ([]int, error) { + rows, err := r.db.Query(`SELECT acw.workspace_id + FROM zammad_connections zc + JOIN action_credential_workspaces acw ON acw.credential_id = zc.credential_id + WHERE zc.provider_id = ? ORDER BY acw.workspace_id`, providerID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + ids := []int{} + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (r *ZammadRepository) GetTicketLinksForItem(itemID int) ([]*models.ZammadTicketLink, error) { + rows, err := r.db.Query(`SELECT `+zammadTicketLinkColumns+` + FROM zammad_ticket_links ztl + JOIN integration_providers ip ON ip.id = ztl.provider_id + JOIN zammad_connections zc ON zc.provider_id = ztl.provider_id + JOIN items i ON i.id = ztl.item_id + WHERE ztl.item_id = ? AND ( + EXISTS (SELECT 1 FROM action_credentials ac WHERE ac.id = zc.credential_id AND ac.applies_to_all_workspaces = true) OR EXISTS ( + SELECT 1 FROM action_credential_workspaces acw + WHERE acw.credential_id = zc.credential_id AND acw.workspace_id = i.workspace_id + ) + ) ORDER BY ztl.created_at DESC`, itemID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + links := []*models.ZammadTicketLink{} + for rows.Next() { + link, err := scanZammadTicketLink(rows) + if err != nil { + return nil, err + } + links = append(links, link) + } + return links, rows.Err() +} + +const zammadTicketLinkColumns = ` + ztl.id, ztl.item_id, ztl.provider_id, ip.name, + ztl.item_integration_link_id, ztl.ticket_id, ztl.ticket_number, + ztl.ticket_url, ztl.group_id, ztl.group_name, ztl.owner_id, ztl.owner_name, ztl.correlation_key, + ztl.sync_state, ztl.last_status_id, ztl.last_status_name, + ztl.last_synced_at, ztl.last_attempt_at, ztl.next_attempt_at, ztl.last_error, ztl.completion_applied, ztl.created_by, + ztl.created_at, ztl.updated_at` + +func (r *ZammadRepository) GetTicketLink(id string) (*models.ZammadTicketLink, error) { + link, err := scanZammadTicketLink(r.db.QueryRow(`SELECT `+zammadTicketLinkColumns+` + FROM zammad_ticket_links ztl + JOIN integration_providers ip ON ip.id = ztl.provider_id + WHERE ztl.id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return link, err +} + +func (r *ZammadRepository) GetTicketLinkForItem(itemID int, providerID string) (*models.ZammadTicketLink, error) { + link, err := scanZammadTicketLink(r.db.QueryRow(`SELECT `+zammadTicketLinkColumns+` + FROM zammad_ticket_links ztl + JOIN integration_providers ip ON ip.id = ztl.provider_id + WHERE ztl.item_id = ? AND ztl.provider_id = ?`, itemID, providerID)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return link, err +} + +// GetItemDestinationByCorrelationKey resolves the durable key and current +// workspace in one snapshot through the provider/key uniqueness boundary. +func (r *ZammadRepository) GetItemDestinationByCorrelationKey(providerID, correlationKey string) (itemID, workspaceID int, err error) { + err = r.db.QueryRow(`SELECT ztl.item_id, i.workspace_id + FROM zammad_ticket_links ztl + JOIN items i ON i.id = ztl.item_id + WHERE ztl.provider_id = ? AND ztl.correlation_key = ?`, providerID, correlationKey).Scan(&itemID, &workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return 0, 0, ErrNotFound + } + return itemID, workspaceID, err +} + +func (r *ZammadRepository) CreatePendingTicketLinkTx(tx database.Tx, link *models.ZammadTicketLink) error { + _, err := tx.Exec(`INSERT INTO zammad_ticket_links + (id, item_id, provider_id, group_id, group_name, correlation_key, + sync_state, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, link.ID, link.ItemID, link.ProviderID, + nullablePositiveInt(link.GroupID), link.GroupName, link.CorrelationKey, + models.ZammadSyncPending, link.CreatedBy) + if database.IsUniqueConstraintError(err) { + return ErrDuplicateEntry + } + return err +} + +// ReserveExistingTicketLinkTx claims the provider/ticket pair before changing +// the remote correlation field. The unique constraint makes competing item +// links fail closed rather than attaching one ticket to two Windshift items. +func (r *ZammadRepository) ReserveExistingTicketLinkTx(tx database.Tx, link *models.ZammadTicketLink) error { + _, err := tx.Exec(`INSERT INTO zammad_ticket_links + (id, item_id, provider_id, ticket_id, ticket_number, ticket_url, group_id, group_name, + owner_id, owner_name, correlation_key, sync_state, last_status_id, last_status_name, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + link.ID, link.ItemID, link.ProviderID, link.TicketID, link.TicketNumber, link.TicketURL, + nullablePositiveInt(link.GroupID), link.GroupName, nullablePositiveInt(link.OwnerID), link.OwnerName, + link.CorrelationKey, models.ZammadSyncCreating, nullablePositiveInt(link.LastStatusID), link.LastStatusName, link.CreatedBy) + if database.IsUniqueConstraintError(err) { + return ErrDuplicateEntry + } + return err +} + +// ClaimTicketCreation atomically claims a pending or retryable ticket +// creation. Newly reserved existing tickets use the creating state with no +// started timestamp and are eligible immediately; an older in-flight creation +// is eligible only once its creation marker is stale. The sync lease is set in +// the same update, so remote work has one owner from reservation to completion. +func (r *ZammadRepository) ClaimTicketCreation(linkID, syncOwner string, now, until time.Time) (claimed, wasUncertain bool, err error) { + if syncOwner == "" { + return false, false, ErrInvalidInput + } + staleBefore := now.Add(-2 * time.Minute) + err = database.WithTx(r.db, func(tx database.Tx) error { + if err := r.lockTicketLinkConnectionTx(tx, linkID); err != nil { + return err + } + stateQuery := "SELECT sync_state FROM zammad_ticket_links WHERE id = ?" + if r.db.GetDriverName() == "postgres" { + stateQuery += " FOR UPDATE" + } + var previousState string + if err := tx.QueryRow(stateQuery, linkID).Scan(&previousState); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + result, err := tx.Exec(`UPDATE zammad_ticket_links + SET sync_state = ?, creating_started_at = ?, sync_lock_until = ?, sync_lock_owner = ?, + last_error = '', updated_at = CURRENT_TIMESTAMP + WHERE id = ? + AND (sync_lock_until IS NULL OR sync_lock_until < CURRENT_TIMESTAMP) + AND (sync_state IN (?, ?, ?) + OR (sync_state = ? AND (creating_started_at IS NULL OR creating_started_at < ?)))`, + models.ZammadSyncCreating, now, until, syncOwner, linkID, + models.ZammadSyncPending, models.ZammadSyncFailed, models.ZammadSyncUncertain, + models.ZammadSyncCreating, staleBefore) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + claimed = rows == 1 + // A stale creating state may have crashed while the POST response was in + // flight. Treat it like an explicitly uncertain outcome so the next + // worker searches by correlation only instead of risking a duplicate. + wasUncertain = claimed && (previousState == string(models.ZammadSyncUncertain) || previousState == string(models.ZammadSyncCreating)) + return nil + }) + return claimed, wasUncertain, err +} + +func (r *ZammadRepository) CompleteTicketCreation(linkID, syncOwner string, ticketID int, number, ticketURL string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName string, linkedBy int) error { + return database.WithTx(r.db, func(tx database.Tx) error { + if err := r.lockTicketLinkConnectionTx(tx, linkID); err != nil { + return err + } + return r.CompleteTicketCreationTx(tx, linkID, syncOwner, ticketID, number, ticketURL, statusID, statusName, groupID, groupName, ownerID, ownerName, linkedBy) + }) +} + +// CompleteTicketCreationTx finalizes a created ticket inside the caller's +// connection-policy transaction. +func (r *ZammadRepository) CompleteTicketCreationTx(tx database.Tx, linkID, syncOwner string, ticketID int, number, ticketURL string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName string, linkedBy int) error { + if syncOwner == "" { + return ErrInvalidInput + } + genericLinkID := linkID + "-external" + metadata, _ := json.Marshal(map[string]any{ + "status_id": statusID, "status_name": statusName, + "group_id": groupID, "group_name": groupName, + "owner_id": ownerID, "owner_name": ownerName, + }) + result, err := tx.Exec(`INSERT INTO item_integration_links + (id, item_id, integration_provider_id, external_id, external_url, + title, icon, link_type, link_metadata, linked_by) + SELECT ?, item_id, provider_id, ?, ?, ?, ?, 'ticket', ?, ? + FROM zammad_ticket_links WHERE id = ? AND sync_lock_owner = ? + ON CONFLICT (item_id, integration_provider_id, external_id) DO UPDATE SET + external_url = excluded.external_url, + title = excluded.title, + link_metadata = excluded.link_metadata, + updated_at = CURRENT_TIMESTAMP`, + genericLinkID, strconv.Itoa(ticketID), ticketURL, "Zammad #"+number, + "ticket", string(metadata), strconv.Itoa(linkedBy), linkID, syncOwner) + if err != nil { + return err + } + if rows, err := result.RowsAffected(); err != nil { + return err + } else if rows == 0 { + return ErrConcurrentUpdate + } + if err := tx.QueryRow(`SELECT iil.id FROM item_integration_links iil + JOIN zammad_ticket_links ztl ON CAST(ztl.item_id AS TEXT) = iil.item_id + AND ztl.provider_id = iil.integration_provider_id + WHERE ztl.id = ? AND iil.external_id = ?`, linkID, strconv.Itoa(ticketID)).Scan(&genericLinkID); err != nil { + return err + } + result, err = tx.Exec(`UPDATE zammad_ticket_links SET + item_integration_link_id = ?, ticket_id = ?, ticket_number = ?, + ticket_url = ?, group_id = ?, group_name = ?, owner_id = ?, owner_name = ?, + sync_state = ?, creating_started_at = NULL, + last_status_id = ?, last_status_name = ?, last_synced_at = CURRENT_TIMESTAMP, + last_attempt_at = CURRENT_TIMESTAMP, next_attempt_at = NULL, + last_error = '', sync_lock_until = NULL, sync_lock_owner = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND sync_lock_owner = ?`, genericLinkID, ticketID, number, ticketURL, + nullablePositiveInt(groupID), groupName, nullablePositiveInt(ownerID), ownerName, + models.ZammadSyncLinked, nullablePositiveInt(statusID), statusName, linkID, syncOwner) + if err != nil { + return err + } + if rows, err := result.RowsAffected(); err != nil { + return err + } else if rows != 1 { + return ErrConcurrentUpdate + } + return nil +} + +func (r *ZammadRepository) CompleteExistingTicketLink(linkID, syncOwner, genericLinkID string, ticket *models.ZammadTicketLink, linkedBy int) error { + return database.WithTx(r.db, func(tx database.Tx) error { + if err := r.lockTicketLinkConnectionTx(tx, linkID); err != nil { + return err + } + return r.CompleteExistingTicketLinkTx(tx, linkID, syncOwner, genericLinkID, ticket, linkedBy) + }) +} + +// CompleteExistingTicketLinkTx finalizes an existing ticket link inside the +// caller's connection-policy transaction. +func (r *ZammadRepository) CompleteExistingTicketLinkTx(tx database.Tx, linkID, syncOwner, genericLinkID string, ticket *models.ZammadTicketLink, linkedBy int) error { + if syncOwner == "" { + return ErrInvalidInput + } + metadata, _ := json.Marshal(map[string]any{"status_id": ticket.LastStatusID, "status_name": ticket.LastStatusName, "group_id": ticket.GroupID, "group_name": ticket.GroupName, "owner_id": ticket.OwnerID, "owner_name": ticket.OwnerName}) + result, err := tx.Exec(`INSERT INTO item_integration_links + (id, item_id, integration_provider_id, external_id, external_url, title, icon, link_type, link_metadata, linked_by) + SELECT ?, item_id, provider_id, ?, ?, ?, ?, 'ticket', ?, ? FROM zammad_ticket_links WHERE id = ? AND sync_lock_owner = ? + ON CONFLICT (item_id, integration_provider_id, external_id) DO UPDATE SET external_url=excluded.external_url, title=excluded.title, link_metadata=excluded.link_metadata, updated_at=CURRENT_TIMESTAMP`, + genericLinkID, strconv.Itoa(ticket.TicketID), ticket.TicketURL, "Zammad #"+ticket.TicketNumber, "ticket", string(metadata), strconv.Itoa(linkedBy), linkID, syncOwner) + if err != nil { + return err + } + if rows, err := result.RowsAffected(); err != nil { + return err + } else if rows == 0 { + return ErrConcurrentUpdate + } + if err := tx.QueryRow(`SELECT iil.id FROM item_integration_links iil + JOIN zammad_ticket_links ztl ON CAST(ztl.item_id AS TEXT) = iil.item_id + AND ztl.provider_id = iil.integration_provider_id + WHERE ztl.id = ? AND ztl.sync_lock_owner = ? AND iil.external_id = ?`, + linkID, syncOwner, strconv.Itoa(ticket.TicketID)).Scan(&genericLinkID); err != nil { + return err + } + result, err = tx.Exec(`UPDATE zammad_ticket_links SET item_integration_link_id=?, ticket_id=?, ticket_number=?, ticket_url=?, group_id=?, group_name=?, owner_id=?, owner_name=?, sync_state=?, creating_started_at=NULL, last_status_id=?, last_status_name=?, last_synced_at=CURRENT_TIMESTAMP, last_attempt_at=CURRENT_TIMESTAMP, next_attempt_at=NULL, last_error='', sync_lock_until=NULL, sync_lock_owner=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=? AND sync_lock_owner=?`, + genericLinkID, ticket.TicketID, ticket.TicketNumber, ticket.TicketURL, nullablePositiveInt(ticket.GroupID), ticket.GroupName, nullablePositiveInt(ticket.OwnerID), ticket.OwnerName, models.ZammadSyncLinked, nullablePositiveInt(ticket.LastStatusID), ticket.LastStatusName, linkID, syncOwner) + if err != nil { + return err + } + if rows, err := result.RowsAffected(); err != nil { + return err + } else if rows != 1 { + return ErrConcurrentUpdate + } + return nil +} + +func (r *ZammadRepository) MarkTicketLinkFailed(id, syncOwner, safeError string) error { + return r.markTicketLinkWithClaim(id, syncOwner, `sync_state = ?, creating_started_at = NULL, last_error = ?, + sync_lock_until = NULL, sync_lock_owner = NULL`, models.ZammadSyncFailed, safeError) +} + +func (r *ZammadRepository) MarkTicketLinkUncertain(id, syncOwner, safeError string) error { + return r.markTicketLinkWithClaim(id, syncOwner, `sync_state = ?, creating_started_at = NULL, last_error = ?, + sync_lock_until = NULL, sync_lock_owner = NULL`, models.ZammadSyncUncertain, safeError) +} + +// MarkTicketLinkSetupError retains an existing-ticket reservation after an +// upstream correlation update failed. A retry can safely inspect the remote +// field and finish the same reservation without allowing background sync to +// treat it as a complete link. +func (r *ZammadRepository) MarkTicketLinkSetupError(id, syncOwner, safeError string) error { + return r.markTicketLinkWithClaim(id, syncOwner, `last_error = ?, + sync_lock_until = NULL, sync_lock_owner = NULL`, safeError) +} + +func (r *ZammadRepository) markTicketLinkWithClaim(id, syncOwner, setClause string, args ...any) error { + if syncOwner == "" { + return ErrInvalidInput + } + return database.WithTx(r.db, func(tx database.Tx) error { + if err := r.lockTicketLinkConnectionTx(tx, id); err != nil { + return err + } + args = append(args, id, syncOwner) + result, err := tx.Exec(`UPDATE zammad_ticket_links SET `+setClause+`, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND sync_lock_owner = ?`, args...) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return ErrConcurrentUpdate + } + return nil + }) +} + +func (r *ZammadRepository) ResetUncertainTicketCreation(id string) (bool, error) { + result, err := r.db.ExecWrite(`UPDATE zammad_ticket_links SET + sync_state = ?, last_error = '', updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND sync_state = ? AND ticket_id IS NULL`, + models.ZammadSyncFailed, id, models.ZammadSyncUncertain) + if err != nil { + return false, err + } + rows, _ := result.RowsAffected() + return rows == 1, nil +} + +func (r *ZammadRepository) UpdateTicketLinkSync(id, syncOwner string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName, safeError string, now time.Time, setCompletionApplied, completionApplied bool) error { + return database.WithTx(r.db, func(tx database.Tx) error { + if err := r.lockTicketLinkConnectionTx(tx, id); err != nil { + return err + } + return r.UpdateTicketLinkSyncTx(tx, id, syncOwner, statusID, statusName, groupID, groupName, ownerID, ownerName, safeError, now, setCompletionApplied, completionApplied) + }) +} + +func (r *ZammadRepository) UpdateTicketLinkSyncTx(tx database.Tx, id, syncOwner string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName, safeError string, now time.Time, setCompletionApplied, completionApplied bool) error { + if syncOwner == "" { + return ErrInvalidInput + } + state := models.ZammadSyncLinked + var nextAttempt any + if safeError != "" { + state = models.ZammadSyncFailed + nextAttempt = now.Add(time.Minute) + } + result, err := tx.ExecWrite(`UPDATE zammad_ticket_links SET + last_status_id = ?, last_status_name = ?, + group_id = ?, group_name = ?, owner_id = ?, owner_name = ?, + last_synced_at = CASE WHEN ? = '' THEN ? ELSE last_synced_at END, + last_attempt_at = ?, next_attempt_at = ?, + last_error = ?, sync_state = ?, sync_lock_until = NULL, sync_lock_owner = NULL, + completion_applied = CASE WHEN ? THEN ? ELSE completion_applied END, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND item_integration_link_id IS NOT NULL AND sync_lock_owner = ?`, nullablePositiveInt(statusID), statusName, + nullablePositiveInt(groupID), groupName, nullablePositiveInt(ownerID), ownerName, + safeError, now, now, nextAttempt, safeError, state, setCompletionApplied, completionApplied, id, syncOwner) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return ErrConcurrentUpdate + } + return nil +} + +func (r *ZammadRepository) ListDueTicketLinks(before time.Time, limit int) ([]*models.ZammadTicketLink, error) { + rows, err := r.db.Query(`SELECT `+zammadTicketLinkColumns+` + FROM zammad_ticket_links ztl + JOIN integration_providers ip ON ip.id = ztl.provider_id + JOIN zammad_connections zc ON zc.provider_id = ztl.provider_id + WHERE ip.enabled = true AND ztl.ticket_id IS NOT NULL + AND ztl.item_integration_link_id IS NOT NULL + AND (zc.auth_method != 'oauth' OR EXISTS ( + SELECT 1 FROM zammad_oauth_tokens zot + WHERE zot.provider_id = zc.provider_id AND zot.reauthorization_required = false + )) + AND (ztl.last_synced_at IS NULL OR ztl.last_synced_at < ?) + AND (ztl.next_attempt_at IS NULL OR ztl.next_attempt_at <= CURRENT_TIMESTAMP) + AND (ztl.sync_lock_until IS NULL OR ztl.sync_lock_until < CURRENT_TIMESTAMP) + ORDER BY COALESCE(ztl.last_attempt_at, ztl.last_synced_at, ztl.created_at) ASC LIMIT ?`, before, limit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + links := []*models.ZammadTicketLink{} + for rows.Next() { + link, err := scanZammadTicketLink(rows) + if err != nil { + return nil, err + } + links = append(links, link) + } + return links, rows.Err() +} + +// ListSyncableTicketLinks returns one stable snapshot of every complete ticket +// link covered by an enabled, authorized connection. Unlike the scheduler's +// due list, an explicit refresh ignores age, retry-delay, and claim gates so +// concurrent work can be reported as skipped instead of disappearing. +func (r *ZammadRepository) ListSyncableTicketLinks() ([]*models.ZammadTicketLink, error) { + rows, err := r.db.Query(`SELECT ` + zammadTicketLinkColumns + ` + FROM zammad_ticket_links ztl + JOIN integration_providers ip ON ip.id = ztl.provider_id + JOIN zammad_connections zc ON zc.provider_id = ztl.provider_id + WHERE ip.enabled = true AND ztl.ticket_id IS NOT NULL + AND ztl.item_integration_link_id IS NOT NULL + AND (zc.auth_method != 'oauth' OR EXISTS ( + SELECT 1 FROM zammad_oauth_tokens zot + WHERE zot.provider_id = zc.provider_id AND zot.reauthorization_required = false + )) + ORDER BY COALESCE(ztl.last_attempt_at, ztl.last_synced_at, ztl.created_at) ASC`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + links := []*models.ZammadTicketLink{} + for rows.Next() { + link, err := scanZammadTicketLink(rows) + if err != nil { + return nil, err + } + links = append(links, link) + } + return links, rows.Err() +} + +func (r *ZammadRepository) ClaimSync(id, syncOwner string, until time.Time) (bool, error) { + if syncOwner == "" { + return false, ErrInvalidInput + } + claimed := false + err := database.WithTx(r.db, func(tx database.Tx) error { + if err := r.lockTicketLinkConnectionTx(tx, id); err != nil { + return err + } + result, err := tx.Exec(`UPDATE zammad_ticket_links SET sync_lock_until = ?, sync_lock_owner = ? + WHERE id = ? AND (sync_lock_until IS NULL OR sync_lock_until < CURRENT_TIMESTAMP)`, until, syncOwner, id) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + claimed = rows == 1 + return nil + }) + return claimed, err +} + +// RenewSyncClaim extends a lease only if it is still owned by syncOwner. An +// expired lease may be renewed by its original owner until another worker +// takes it over, which prevents an idle scheduler from leaving work stranded. +func (r *ZammadRepository) RenewSyncClaim(id, syncOwner string, until time.Time) (bool, error) { + if syncOwner == "" { + return false, ErrInvalidInput + } + renewed := false + err := database.WithTx(r.db, func(tx database.Tx) error { + if err := r.lockTicketLinkConnectionTx(tx, id); err != nil { + return err + } + result, err := tx.Exec(`UPDATE zammad_ticket_links SET sync_lock_until = ? + WHERE id = ? AND sync_lock_owner = ?`, until, id, syncOwner) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + renewed = rows == 1 + return nil + }) + return renewed, err +} + +// ReleaseSyncClaim clears only the caller's own lease. A stale worker that +// lost a lease to a newer owner receives ErrConcurrentUpdate and cannot clear +// that newer owner's lock. +func (r *ZammadRepository) ReleaseSyncClaim(id, syncOwner string) error { + if syncOwner == "" { + return ErrInvalidInput + } + return database.WithTx(r.db, func(tx database.Tx) error { + if err := r.lockTicketLinkConnectionTx(tx, id); err != nil { + return err + } + result, err := tx.Exec(`UPDATE zammad_ticket_links + SET sync_lock_until = NULL, sync_lock_owner = NULL + WHERE id = ? AND sync_lock_owner = ?`, id, syncOwner) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return ErrConcurrentUpdate + } + return nil + }) +} + +func scanZammadTicketLink(scanner interface{ Scan(...any) error }) (*models.ZammadTicketLink, error) { + var link models.ZammadTicketLink + var genericLinkID, number, ticketURL, groupName, ownerName, statusName, lastError sql.NullString + var ticketID, groupID, ownerID, statusID, createdBy sql.NullInt64 + var lastSynced, lastAttempt, nextAttempt sql.NullTime + err := scanner.Scan(&link.ID, &link.ItemID, &link.ProviderID, &link.ProviderName, + &genericLinkID, &ticketID, &number, &ticketURL, &groupID, &groupName, &ownerID, &ownerName, + &link.CorrelationKey, &link.SyncState, &statusID, &statusName, + &lastSynced, &lastAttempt, &nextAttempt, &lastError, &link.CompletionApplied, &createdBy, &link.CreatedAt, &link.UpdatedAt) + if err != nil { + return nil, err + } + if genericLinkID.Valid { + link.ItemIntegrationLinkID = genericLinkID.String + } + if ticketID.Valid { + link.TicketID = int(ticketID.Int64) + } + if number.Valid { + link.TicketNumber = number.String + } + if ticketURL.Valid { + link.TicketURL = ticketURL.String + } + if groupID.Valid { + link.GroupID = int(groupID.Int64) + } + if groupName.Valid { + link.GroupName = groupName.String + } + if ownerID.Valid { + link.OwnerID = int(ownerID.Int64) + } + if ownerName.Valid { + link.OwnerName = ownerName.String + } + if statusID.Valid { + link.LastStatusID = int(statusID.Int64) + } + if statusName.Valid { + link.LastStatusName = statusName.String + } + if lastSynced.Valid { + link.LastSyncedAt = &lastSynced.Time + } + if lastAttempt.Valid { + link.LastAttemptAt = &lastAttempt.Time + } + if nextAttempt.Valid { + link.NextAttemptAt = &nextAttempt.Time + } + if lastError.Valid { + link.LastError = lastError.String + } + if createdBy.Valid { + v := int(createdBy.Int64) + link.CreatedBy = &v + } + return &link, nil +} + +// DeleteTicketLinkClaimed removes a ticket link and its generic item link only +// while the caller still owns the sync lease. This keeps unlinking from +// deleting a reservation or completed link after a different worker has taken +// over an expired lease. +func (r *ZammadRepository) DeleteTicketLinkClaimed(id, syncOwner string) error { + if syncOwner == "" { + return ErrInvalidInput + } + return database.WithTx(r.db, func(tx database.Tx) error { + if err := r.lockTicketLinkConnectionTx(tx, id); err != nil { + return err + } + var genericID sql.NullString + if err := tx.QueryRow(`SELECT item_integration_link_id + FROM zammad_ticket_links WHERE id = ? AND sync_lock_owner = ?`, id, syncOwner).Scan(&genericID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrConcurrentUpdate + } + return err + } + result, err := tx.Exec(`DELETE FROM zammad_ticket_links + WHERE id = ? AND sync_lock_owner = ?`, id, syncOwner) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return ErrConcurrentUpdate + } + if genericID.Valid { + _, err = tx.Exec(`DELETE FROM item_integration_links WHERE id = ?`, genericID.String) + return err + } + return nil + }) +} + +func nullablePositiveInt(value int) any { + if value <= 0 { + return nil + } + return value +} diff --git a/internal/repository/zammad_repository_test.go b/internal/repository/zammad_repository_test.go new file mode 100644 index 000000000..d9f919393 --- /dev/null +++ b/internal/repository/zammad_repository_test.go @@ -0,0 +1,252 @@ +package repository + +import ( + "errors" + "path/filepath" + "testing" + "time" + + "windshift/internal/database" + "windshift/internal/models" +) + +type zammadLeaseFixture struct { + db database.Database + repo *ZammadRepository + linkID string +} + +func newZammadLeaseFixture(t *testing.T) *zammadLeaseFixture { + t.Helper() + db, err := database.NewSQLiteDB(filepath.Join(t.TempDir(), "zammad-repository.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Initialize(); err != nil { + t.Fatal(err) + } + if _, err := db.ExecWrite(`INSERT INTO users (id, email, username, first_name, last_name) + VALUES (1, 'zammad-repository@example.test', 'zammad-repository', 'Zammad', 'Repository')`); err != nil { + t.Fatal(err) + } + if _, err := db.ExecWrite(`INSERT INTO workspaces (id, name, key) VALUES (1, 'Zammad tests', 'ZRT')`); err != nil { + t.Fatal(err) + } + var statusID int + if err := db.QueryRow(`SELECT id FROM statuses ORDER BY id LIMIT 1`).Scan(&statusID); err != nil { + t.Fatal(err) + } + if _, err := db.ExecWrite(`INSERT INTO items + (id, workspace_id, workspace_item_number, title, description, frac_index, status_id, creator_id, last_active_at) + VALUES (1, 1, 1, 'Zammad repository lease test', '', 'a0', ?, 1, CURRENT_TIMESTAMP)`, statusID); err != nil { + t.Fatal(err) + } + if _, err := db.ExecWrite(`INSERT INTO action_credentials + (id, name, credential_type, applies_to_all_workspaces, encrypted_secret, is_enabled) + VALUES (1, 'Zammad test credential', 'custom_header', true, 'test-ciphertext', true)`); err != nil { + t.Fatal(err) + } + if _, err := db.ExecWrite(`INSERT INTO integration_providers + (id, slug, name, provider_type, enabled) VALUES ('zammad-test', 'zammad-test', 'Zammad test', 'zammad', true)`); err != nil { + t.Fatal(err) + } + if _, err := db.ExecWrite(`INSERT INTO zammad_connections + (provider_id, credential_id, base_url, default_customer) VALUES ('zammad-test', 1, 'https://zammad.example.test', 'robot@example.test')`); err != nil { + t.Fatal(err) + } + if _, err := db.ExecWrite(`INSERT INTO zammad_ticket_links + (id, item_id, provider_id, correlation_key, sync_state, created_by) + VALUES ('zammad-link', 1, 'zammad-test', 'ZRT-1', ?, 1)`, models.ZammadSyncPending); err != nil { + t.Fatal(err) + } + return &zammadLeaseFixture{db: db, repo: NewZammadRepository(db), linkID: "zammad-link"} +} + +func (f *zammadLeaseFixture) makeComplete(t *testing.T) { + t.Helper() + if _, err := f.db.ExecWrite(`INSERT INTO item_integration_links + (id, item_id, integration_provider_id, external_id, external_url, title, link_type, linked_by) + VALUES ('zammad-link-external', '1', 'zammad-test', '901', 'https://zammad.example.test/#ticket/zoom/901', 'Zammad #901', 'ticket', '1')`); err != nil { + t.Fatal(err) + } + if _, err := f.db.ExecWrite(`UPDATE zammad_ticket_links + SET item_integration_link_id = 'zammad-link-external', ticket_id = 901, ticket_number = '901', + sync_state = ? WHERE id = ?`, models.ZammadSyncLinked, f.linkID); err != nil { + t.Fatal(err) + } +} + +func TestZammadCorrelationKeyResolvesCurrentItem(t *testing.T) { + f := newZammadLeaseFixture(t) + + itemID, workspaceID, err := f.repo.GetItemDestinationByCorrelationKey("zammad-test", "ZRT-1") + if err != nil || itemID != 1 || workspaceID != 1 { + t.Fatalf("resolve correlation key: item=%d workspace=%d err=%v", itemID, workspaceID, err) + } + if _, _, err := f.repo.GetItemDestinationByCorrelationKey("zammad-test", "missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing correlation key must fail closed: %v", err) + } +} + +func TestZammadSyncLeaseOwnerTakeoverProtectsSnapshotReleaseAndDelete(t *testing.T) { + f := newZammadLeaseFixture(t) + f.makeComplete(t) + now := time.Now().UTC() + + claimed, err := f.repo.ClaimSync(f.linkID, "owner-a", now.Add(-time.Minute)) + if err != nil || !claimed { + t.Fatalf("claim owner A: claimed=%v err=%v", claimed, err) + } + claimed, err = f.repo.ClaimSync(f.linkID, "owner-b", now.Add(time.Minute)) + if err != nil || !claimed { + t.Fatalf("take over expired lease as owner B: claimed=%v err=%v", claimed, err) + } + + if err := f.repo.ReleaseSyncClaim(f.linkID, "owner-a"); !errors.Is(err, ErrConcurrentUpdate) { + t.Fatalf("owner A must not release owner B lease: %v", err) + } + if err := f.repo.UpdateTicketLinkSync(f.linkID, "owner-a", 4, "closed", 7, "Support", 0, "", "", now, true, true); !errors.Is(err, ErrConcurrentUpdate) { + t.Fatalf("owner A must not persist a snapshot after takeover: %v", err) + } + if err := f.repo.DeleteTicketLinkClaimed(f.linkID, "owner-a"); !errors.Is(err, ErrConcurrentUpdate) { + t.Fatalf("owner A must not delete owner B link: %v", err) + } + renewed, err := f.repo.RenewSyncClaim(f.linkID, "owner-a", now.Add(2*time.Minute)) + if err != nil || renewed { + t.Fatalf("owner A must not renew owner B lease: renewed=%v err=%v", renewed, err) + } + + var lockOwner string + var ticketID, statusID int + if err := f.db.QueryRow(`SELECT sync_lock_owner, ticket_id, COALESCE(last_status_id, 0) + FROM zammad_ticket_links WHERE id = ?`, f.linkID).Scan(&lockOwner, &ticketID, &statusID); err != nil { + t.Fatal(err) + } + if lockOwner != "owner-b" || ticketID != 901 || statusID != 0 { + t.Fatalf("stale owner changed stored link: owner=%q ticket=%d status=%d", lockOwner, ticketID, statusID) + } + var genericCount int + if err := f.db.QueryRow(`SELECT COUNT(*) FROM item_integration_links WHERE id = 'zammad-link-external'`).Scan(&genericCount); err != nil { + t.Fatal(err) + } + if genericCount != 1 { + t.Fatalf("stale owner deleted generic link: count=%d", genericCount) + } + if err := f.repo.ReleaseSyncClaim(f.linkID, "owner-b"); err != nil { + t.Fatalf("release current lease owner: %v", err) + } +} + +func TestZammadTicketCompletionRequiresCurrentLeaseOwner(t *testing.T) { + f := newZammadLeaseFixture(t) + now := time.Now().UTC() + + claimed, wasUncertain, err := f.repo.ClaimTicketCreation(f.linkID, "owner-a", now, now.Add(-time.Minute)) + if err != nil || !claimed { + t.Fatalf("claim ticket creation as owner A: claimed=%v err=%v", claimed, err) + } + if wasUncertain { + t.Fatal("pending ticket creation must not report uncertain state") + } + claimed, err = f.repo.ClaimSync(f.linkID, "owner-b", now.Add(time.Minute)) + if err != nil || !claimed { + t.Fatalf("take over ticket creation lease as owner B: claimed=%v err=%v", claimed, err) + } + + if err := f.repo.CompleteTicketCreation(f.linkID, "owner-a", 902, "902", "https://zammad.example.test/#ticket/zoom/902", 2, "open", 7, "Support", 0, "", 1); !errors.Is(err, ErrConcurrentUpdate) { + t.Fatalf("stale owner must not complete ticket creation: %v", err) + } + var genericCount int + if err := f.db.QueryRow(`SELECT COUNT(*) FROM item_integration_links WHERE id = 'zammad-link-external'`).Scan(&genericCount); err != nil { + t.Fatal(err) + } + if genericCount != 0 { + t.Fatalf("stale owner created generic item link: count=%d", genericCount) + } + + if err := f.repo.CompleteTicketCreation(f.linkID, "owner-b", 902, "902", "https://zammad.example.test/#ticket/zoom/902", 2, "open", 7, "Support", 0, "", 1); err != nil { + t.Fatalf("current owner completes ticket creation: %v", err) + } + var ticketID int + var lockOwner any + if err := f.db.QueryRow(`SELECT ticket_id, sync_lock_owner FROM zammad_ticket_links WHERE id = ?`, f.linkID).Scan(&ticketID, &lockOwner); err != nil { + t.Fatal(err) + } + if ticketID != 902 || lockOwner != nil { + t.Fatalf("completion did not atomically store ticket and clear lease: ticket=%d owner=%v", ticketID, lockOwner) + } +} + +func TestZammadExistingTicketCompletionRequiresCurrentLeaseOwner(t *testing.T) { + f := newZammadLeaseFixture(t) + now := time.Now().UTC() + claimed, _, err := f.repo.ClaimTicketCreation(f.linkID, "owner-a", now, now.Add(-time.Minute)) + if err != nil || !claimed { + t.Fatalf("claim existing-ticket reservation as owner A: claimed=%v err=%v", claimed, err) + } + claimed, err = f.repo.ClaimSync(f.linkID, "owner-b", now.Add(time.Minute)) + if err != nil || !claimed { + t.Fatalf("take over existing-ticket reservation as owner B: claimed=%v err=%v", claimed, err) + } + ticket := &models.ZammadTicketLink{ + TicketID: 903, TicketNumber: "903", TicketURL: "https://zammad.example.test/#ticket/zoom/903", + GroupID: 7, GroupName: "Support", LastStatusID: 2, LastStatusName: "open", + } + if err := f.repo.CompleteExistingTicketLink(f.linkID, "owner-a", f.linkID+"-external", ticket, 1); !errors.Is(err, ErrConcurrentUpdate) { + t.Fatalf("stale owner must not complete existing ticket link: %v", err) + } + if err := f.repo.CompleteExistingTicketLink(f.linkID, "owner-b", f.linkID+"-external", ticket, 1); err != nil { + t.Fatalf("current owner completes existing ticket link: %v", err) + } +} + +func TestZammadExistingTicketCompletionReusesPreexistingGenericLinkID(t *testing.T) { + f := newZammadLeaseFixture(t) + if _, err := f.db.ExecWrite(`INSERT INTO item_integration_links + (id, item_id, integration_provider_id, external_id, external_url, title, link_type, linked_by) + VALUES ('preexisting-generic', '1', 'zammad-test', '903', 'https://old.example.test', 'Old title', 'ticket', '1')`); err != nil { + t.Fatal(err) + } + claimed, err := f.repo.ClaimSync(f.linkID, "owner", time.Now().Add(time.Minute)) + if err != nil || !claimed { + t.Fatalf("claim existing-ticket reservation: claimed=%v err=%v", claimed, err) + } + ticket := &models.ZammadTicketLink{ + TicketID: 903, TicketNumber: "903", TicketURL: "https://zammad.example.test/#ticket/zoom/903", + GroupID: 7, GroupName: "Support", LastStatusID: 2, LastStatusName: "open", + } + if err := f.repo.CompleteExistingTicketLink(f.linkID, "owner", f.linkID+"-external", ticket, 1); err != nil { + t.Fatalf("complete against preexisting generic link: %v", err) + } + var genericID string + if err := f.db.QueryRow(`SELECT item_integration_link_id FROM zammad_ticket_links WHERE id = ?`, f.linkID).Scan(&genericID); err != nil { + t.Fatal(err) + } + if genericID != "preexisting-generic" { + t.Fatalf("completion stored generated generic ID %q instead of existing row", genericID) + } +} + +func TestZammadTicketCreationClaimReturnsAtomicUncertainState(t *testing.T) { + for _, tt := range []struct { + name string + state models.ZammadSyncState + startedAt any + }{ + {name: "explicit uncertain", state: models.ZammadSyncUncertain}, + {name: "stale in-flight create", state: models.ZammadSyncCreating, startedAt: time.Now().UTC().Add(-3 * time.Minute)}, + } { + t.Run(tt.name, func(t *testing.T) { + f := newZammadLeaseFixture(t) + if _, err := f.db.ExecWrite(`UPDATE zammad_ticket_links SET sync_state = ?, creating_started_at = ? WHERE id = ?`, tt.state, tt.startedAt, f.linkID); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + claimed, wasUncertain, err := f.repo.ClaimTicketCreation(f.linkID, "owner-a", now, now.Add(time.Minute)) + if err != nil || !claimed || !wasUncertain { + t.Fatalf("unsafe retry claim must report uncertain state: claimed=%v uncertain=%v err=%v", claimed, wasUncertain, err) + } + }) + } +} diff --git a/internal/restapi/v1/handlers/items.go b/internal/restapi/v1/handlers/items.go index e3f6c8f79..99133b059 100644 --- a/internal/restapi/v1/handlers/items.go +++ b/internal/restapi/v1/handlers/items.go @@ -885,6 +885,7 @@ func (h *ItemHandler) Transition(w http.ResponseWriter, r *http.Request) { // @Failure 401 {object} handlers.ErrorResponse // @Failure 403 {object} handlers.ErrorResponse "Token lacks the items:delete scope" // @Failure 404 {object} handlers.ErrorResponse "Item not found or not visible to caller" +// @Failure 409 {object} handlers.ErrorResponse "Item or descendant still has a protected integration link" // @Failure 500 {object} handlers.ErrorResponse // @Router /items/{id} [delete] func (h *ItemHandler) Delete(w http.ResponseWriter, r *http.Request) { @@ -908,6 +909,14 @@ func (h *ItemHandler) Delete(w http.ResponseWriter, r *http.Request) { h.RespondError(w, r, restapi.ErrItemNotFound) return } + if errors.Is(err, services.ErrItemHasProtectedIntegrationLinks) { + h.RespondError(w, r, restapi.NewAPIError( + http.StatusConflict, + restapi.ErrCodeConflict, + "Remove all protected integration links from the affected items before deleting them.", + )) + return + } h.RespondInternalError(w, r) return } diff --git a/internal/restapi/v1/handlers/workspaces.go b/internal/restapi/v1/handlers/workspaces.go index a472bd1b5..1192846d3 100644 --- a/internal/restapi/v1/handlers/workspaces.go +++ b/internal/restapi/v1/handlers/workspaces.go @@ -393,6 +393,7 @@ func (h *WorkspaceHandler) Update(w http.ResponseWriter, r *http.Request) { // @Failure 401 {object} handlers.ErrorResponse // @Failure 403 {object} handlers.ErrorResponse "Token lacks the workspaces:delete scope" // @Failure 404 {object} handlers.ErrorResponse "Workspace not found or caller cannot delete it" +// @Failure 409 {object} handlers.ErrorResponse "Workspace still has externally linked items" // @Failure 500 {object} handlers.ErrorResponse // @Router /workspaces/{id} [delete] func (h *WorkspaceHandler) Delete(w http.ResponseWriter, r *http.Request) { @@ -420,6 +421,14 @@ func (h *WorkspaceHandler) Delete(w http.ResponseWriter, r *http.Request) { h.RespondError(w, r, restapi.ErrWorkspaceNotFound) return } + if errors.Is(err, services.ErrWorkspaceHasProtectedIntegrationLinks) { + h.RespondError(w, r, restapi.NewAPIError( + http.StatusConflict, + restapi.ErrCodeConflict, + "Remove all protected integration links from this workspace before deleting it.", + )) + return + } h.RespondInternalError(w, r) return } diff --git a/internal/routes/integrations.go b/internal/routes/integrations.go index d5ed871ce..58585ac1b 100644 --- a/internal/routes/integrations.go +++ b/internal/routes/integrations.go @@ -12,6 +12,7 @@ type IntegrationHandlers struct { OAuth *handlers.IntegrationOAuthHandler ItemLinks *handlers.IntegrationItemLinksHandler TodoistSync *handlers.TodoistSyncHandler + Zammad *handlers.ZammadHandler } // RegisterIntegrationRoutes registers integration provider, OAuth, and item link routes. @@ -26,10 +27,12 @@ func RegisterIntegrationRoutes(deps *Deps) { api.HandleH("GET /admin/integration-providers/{id}", admin(http.HandlerFunc(deps.Integrations.Provider.GetProvider))) api.HandleH("PUT /admin/integration-providers/{id}", admin(http.HandlerFunc(deps.Integrations.Provider.UpdateProvider))) api.HandleH("DELETE /admin/integration-providers/{id}", admin(http.HandlerFunc(deps.Integrations.Provider.DeleteProvider))) + api.HandleH("POST /admin/integration-providers/{id}/oauth/start", admin(http.HandlerFunc(deps.Integrations.OAuth.StartSystemOAuth))) // OAuth flow api.HandleH("GET /integrations/oauth/{slug}/start", auth(http.HandlerFunc(deps.Integrations.OAuth.StartOAuth))) api.Handle("GET /integrations/oauth/{slug}/callback", http.HandlerFunc(deps.Integrations.OAuth.OAuthCallback)) + api.Handle("GET /integrations/oauth/system/{providerType}/callback", http.HandlerFunc(deps.Integrations.OAuth.SystemOAuthCallback)) // User connections api.HandleH("GET /users/me/integration-connections", auth(http.HandlerFunc(deps.Integrations.OAuth.GetUserConnections))) @@ -42,6 +45,27 @@ func RegisterIntegrationRoutes(deps *Deps) { api.HandleH("GET /users/me/todoist-sync/projects", auth(http.HandlerFunc(deps.Integrations.TodoistSync.GetProjects))) api.HandleH("POST /users/me/todoist-sync/run", auth(http.HandlerFunc(deps.Integrations.TodoistSync.RunSync))) + // Zammad system connections and workspace-scoped ticket operations. + api.HandleH("GET /admin/zammad-connections", admin(http.HandlerFunc(deps.Integrations.Zammad.ListConnections))) + api.HandleH("POST /admin/zammad-connections", admin(http.HandlerFunc(deps.Integrations.Zammad.CreateConnection))) + api.HandleH("GET /admin/zammad-connections/{id}", admin(http.HandlerFunc(deps.Integrations.Zammad.GetConnection))) + api.HandleH("PUT /admin/zammad-connections/{id}", admin(http.HandlerFunc(deps.Integrations.Zammad.UpdateConnection))) + api.HandleH("DELETE /admin/zammad-connections/{id}", admin(http.HandlerFunc(deps.Integrations.Zammad.DeleteConnection))) + api.HandleH("POST /admin/zammad-connections/{id}/test", admin(http.HandlerFunc(deps.Integrations.Zammad.TestConnection))) + api.HandleH("POST /admin/zammad-ticket-links/refresh", admin(http.HandlerFunc(deps.Integrations.Zammad.RefreshAllTickets))) + api.HandleH("POST /admin/zammad-ticket-links/{linkId}/retry-create", admin(http.HandlerFunc(deps.Integrations.Zammad.RetryUncertainTicketCreation))) + api.HandleH("POST /admin/zammad-ticket-links/{linkId}/detach-local", admin(http.HandlerFunc(deps.Integrations.Zammad.DetachTicketLinkLocally))) + api.HandleH("GET /workspaces/{workspaceId}/zammad-connections", auth(http.HandlerFunc(deps.Integrations.Zammad.ListWorkspaceConnections))) + api.HandleH("GET /workspaces/{workspaceId}/zammad-connections/{id}/metadata", auth(http.HandlerFunc(deps.Integrations.Zammad.GetWorkspaceMetadata))) + api.HandleH("GET /workspaces/{workspaceId}/zammad-connections/{id}/owners", auth(http.HandlerFunc(deps.Integrations.Zammad.GetWorkspaceOwners))) + api.HandleH("GET /items/{id}/zammad-links", auth(http.HandlerFunc(deps.Integrations.Zammad.GetItemLinks))) + api.HandleH("GET /zammad-ticket-links/resolve/{correlationKey}", auth(http.HandlerFunc(deps.Integrations.Zammad.ResolveTicketLink))) + api.HandleH("POST /items/{id}/zammad-tickets", auth(http.HandlerFunc(deps.Integrations.Zammad.CreateTicket))) + api.HandleH("POST /items/{id}/zammad-ticket-links", auth(http.HandlerFunc(deps.Integrations.Zammad.LinkExistingTicket))) + api.HandleH("PUT /zammad-ticket-links/{linkId}", auth(http.HandlerFunc(deps.Integrations.Zammad.UpdateTicketLink))) + api.HandleH("DELETE /zammad-ticket-links/{linkId}", auth(http.HandlerFunc(deps.Integrations.Zammad.DeleteTicketLink))) + api.HandleH("POST /zammad-ticket-links/{linkId}/refresh", auth(http.HandlerFunc(deps.Integrations.Zammad.RefreshTicket))) + // Item links api.HandleH("GET /items/{id}/integration-links", auth(http.HandlerFunc(deps.Integrations.ItemLinks.GetItemLinks))) api.HandleH("POST /items/{id}/integration-links", auth(http.HandlerFunc(deps.Integrations.ItemLinks.CreateItemLink))) diff --git a/internal/scheduler/zammad_sync_scheduler.go b/internal/scheduler/zammad_sync_scheduler.go new file mode 100644 index 000000000..1721c6f0d --- /dev/null +++ b/internal/scheduler/zammad_sync_scheduler.go @@ -0,0 +1,123 @@ +package scheduler + +import ( + "context" + "log/slog" + "sync" + "time" + + "windshift/internal/services" +) + +type ZammadSyncScheduler struct { + service zammadSyncService + interval time.Duration + timer *time.Timer + triggerSyncAll chan struct{} + cancel context.CancelFunc + wg sync.WaitGroup + mu sync.Mutex + running bool + syncAllPending bool +} + +type zammadSyncService interface { + SyncDue(context.Context, int) error + SyncAllTicketLinks(context.Context) (services.ZammadSyncSummary, error) +} + +func NewZammadSyncScheduler(service *services.ZammadService) *ZammadSyncScheduler { + return &ZammadSyncScheduler{service: service, interval: 2 * time.Minute, triggerSyncAll: make(chan struct{}, 1)} +} + +func (s *ZammadSyncScheduler) Start() { + s.mu.Lock() + defer s.mu.Unlock() + if s.running { + return + } + // A shutdown can win the select race after a system-wide refresh was + // queued. Drop that stale request before starting a fresh loop so future + // refreshes are not permanently coalesced with work from the previous run. + s.syncAllPending = false + for { + select { + case <-s.triggerSyncAll: + continue + default: + goto triggerDrained + } + } + +triggerDrained: + s.timer = time.NewTimer(s.interval) + ctx, cancel := context.WithCancel(context.Background()) + s.cancel = cancel + s.running = true + s.wg.Add(1) + go s.loop(ctx, s.timer) +} + +// TriggerSyncAll queues one immediate system-wide refresh. A second request is +// coalesced while the first is queued or running. +func (s *ZammadSyncScheduler) TriggerSyncAll() bool { + s.mu.Lock() + defer s.mu.Unlock() + if !s.running || s.syncAllPending { + return false + } + s.syncAllPending = true + s.triggerSyncAll <- struct{}{} + return true +} + +func (s *ZammadSyncScheduler) Stop() { + s.mu.Lock() + if !s.running { + s.mu.Unlock() + return + } + s.running = false + s.timer.Stop() + s.cancel() + s.mu.Unlock() + s.wg.Wait() +} + +func (s *ZammadSyncScheduler) loop(ctx context.Context, timer *time.Timer) { + defer s.wg.Done() + defer timer.Stop() + for { + select { + case <-timer.C: + if err := s.service.SyncDue(ctx, 50); err != nil && ctx.Err() == nil { + slog.Warn("Zammad ticket synchronization failed", slog.String("component", "zammad-sync"), slog.Any("error", err)) + } + timer.Reset(s.interval) + case <-s.triggerSyncAll: + if ctx.Err() != nil { + return + } + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + summary, err := s.service.SyncAllTicketLinks(ctx) + if err != nil && ctx.Err() == nil { + slog.Warn("System-wide Zammad ticket synchronization failed", slog.String("component", "zammad-sync"), slog.Any("error", err)) + } else if ctx.Err() == nil { + slog.Info("System-wide Zammad ticket synchronization completed", slog.String("component", "zammad-sync"), + slog.Int("selected", summary.Selected), slog.Int("succeeded", summary.Succeeded), + slog.Int("failed", summary.Failed), slog.Int("skipped", summary.Skipped)) + } + s.mu.Lock() + s.syncAllPending = false + s.mu.Unlock() + timer.Reset(s.interval) + case <-ctx.Done(): + return + } + } +} diff --git a/internal/scheduler/zammad_sync_scheduler_test.go b/internal/scheduler/zammad_sync_scheduler_test.go new file mode 100644 index 000000000..d08546ad6 --- /dev/null +++ b/internal/scheduler/zammad_sync_scheduler_test.go @@ -0,0 +1,140 @@ +package scheduler + +import ( + "context" + "sync" + "testing" + "time" + + "windshift/internal/services" +) + +type fakeZammadSyncService struct { + mu sync.Mutex + dueCalls int + dueStarted chan int + releaseDue chan struct{} + dueDone chan struct{} + allStarted chan struct{} + releaseAll chan struct{} + allDone chan struct{} + allDoneOnce sync.Once +} + +func (f *fakeZammadSyncService) SyncDue(context.Context, int) error { + f.mu.Lock() + f.dueCalls++ + call := f.dueCalls + f.mu.Unlock() + f.dueStarted <- call + if call == 1 && f.releaseDue != nil { + <-f.releaseDue + close(f.dueDone) + } + return nil +} + +func (f *fakeZammadSyncService) SyncAllTicketLinks(context.Context) (services.ZammadSyncSummary, error) { + select { + case f.allStarted <- struct{}{}: + default: + } + if f.releaseAll != nil { + <-f.releaseAll + } + f.allDoneOnce.Do(func() { close(f.allDone) }) + return services.ZammadSyncSummary{Selected: 1, Succeeded: 1}, nil +} + +func TestZammadSchedulerWaitsFullIntervalAfterSlowRun(t *testing.T) { + fake := &fakeZammadSyncService{ + dueStarted: make(chan int, 4), releaseDue: make(chan struct{}), dueDone: make(chan struct{}), + allStarted: make(chan struct{}, 1), allDone: make(chan struct{}), + } + s := &ZammadSyncScheduler{service: fake, interval: 20 * time.Millisecond, triggerSyncAll: make(chan struct{}, 1)} + s.Start() + t.Cleanup(s.Stop) + + select { + case <-fake.dueStarted: + case <-time.After(200 * time.Millisecond): + t.Fatal("first scheduled synchronization did not start") + } + time.Sleep(50 * time.Millisecond) + close(fake.releaseDue) + select { + case <-fake.dueDone: + case <-time.After(200 * time.Millisecond): + t.Fatal("first scheduled synchronization did not finish") + } + select { + case <-fake.dueStarted: + t.Fatal("ticker backlog started another synchronization without waiting") + case <-time.After(10 * time.Millisecond): + } + select { + case <-fake.dueStarted: + case <-time.After(100 * time.Millisecond): + t.Fatal("next synchronization did not start after the configured interval") + } +} + +func TestZammadSchedulerCoalescesSystemRefreshRequests(t *testing.T) { + fake := &fakeZammadSyncService{ + dueStarted: make(chan int, 1), dueDone: make(chan struct{}), + allStarted: make(chan struct{}, 2), releaseAll: make(chan struct{}), allDone: make(chan struct{}), + } + s := &ZammadSyncScheduler{service: fake, interval: time.Hour, triggerSyncAll: make(chan struct{}, 1)} + s.Start() + t.Cleanup(s.Stop) + + if !s.TriggerSyncAll() || s.TriggerSyncAll() { + t.Fatal("queued system refresh was not coalesced") + } + select { + case <-fake.allStarted: + case <-time.After(200 * time.Millisecond): + t.Fatal("system refresh did not start") + } + if s.TriggerSyncAll() { + t.Fatal("running system refresh accepted a duplicate trigger") + } + close(fake.releaseAll) + select { + case <-fake.allDone: + case <-time.After(200 * time.Millisecond): + t.Fatal("system refresh did not finish") + } + deadline := time.Now().Add(200 * time.Millisecond) + for !s.TriggerSyncAll() { + if time.Now().After(deadline) { + t.Fatal("system refresh trigger remained stuck after completion") + } + time.Sleep(time.Millisecond) + } +} + +func TestZammadSchedulerStartDropsStaleSystemRefresh(t *testing.T) { + fake := &fakeZammadSyncService{ + dueStarted: make(chan int, 1), dueDone: make(chan struct{}), + allStarted: make(chan struct{}, 1), releaseAll: make(chan struct{}), allDone: make(chan struct{}), + } + trigger := make(chan struct{}, 1) + trigger <- struct{}{} + s := &ZammadSyncScheduler{ + service: fake, interval: time.Hour, triggerSyncAll: trigger, + syncAllPending: true, + } + s.Start() + t.Cleanup(s.Stop) + + if !s.TriggerSyncAll() { + t.Fatal("fresh system refresh was coalesced with a stale request") + } + select { + case <-fake.allStarted: + case <-time.After(200 * time.Millisecond): + t.Fatal("fresh system refresh did not start") + } + close(fake.releaseAll) +} diff --git a/internal/server/server.go b/internal/server/server.go index a8abf3f72..0874ac0ff 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -78,6 +78,7 @@ type Server struct { recurrenceScheduler *scheduler.RecurrenceScheduler cfvCleanupScheduler *scheduler.CFVCleanupScheduler todoistSyncScheduler *scheduler.TodoistSyncScheduler + zammadSyncScheduler *scheduler.ZammadSyncScheduler runnerLeaseReaper *scheduler.RunnerLeaseReaper globalRankMigrationScheduler *scheduler.GlobalRankMigrationScheduler codingRunService *services.RunService @@ -661,11 +662,7 @@ func (s *Server) initialize() error { // Wire credential resolution into the action runtime so HTTP capabilities // can reference tokens by ID. The service shares the same SSO_SECRET via // a domain-separated HKDF label (ActionCredentialEncryptionInfo). - credentialSvc := services.NewActionCredentialService( - repository.NewActionCredentialRepository(s.db), - cfg.Auth.SessionSecret, - ) - s.actionService.SetCredentialService(credentialSvc) + s.actionService.SetCredentialService(actionCredentialService) // Lets container_run nodes dispatch to a remote runner pool (WI-146). s.actionService.SetAgentRunRepository(repository.NewAgentRunRepository(s.db)) // One-shot scanner: warn about any legacy capability whose @@ -890,7 +887,7 @@ func (s *Server) initialize() error { workspaceBootstrapHandler := handlers.NewWorkspaceBootstrapHandler(workspaceHandler, userHandler, milestoneHandler, iterationHandler, timeProjectHandler) // Secretless access layer (WI-144): brokers a granted credential to a // running job without it ever living on the runner host. - runnerBrokerHandler := handlers.NewRunnerBrokerHandler(tokenManager, repository.NewAgentRunRepository(s.db), credentialSvc, llmManager, &scmCredsAdapter{cr: scmCredResolver}) + runnerBrokerHandler := handlers.NewRunnerBrokerHandler(tokenManager, repository.NewAgentRunRepository(s.db), actionCredentialService, llmManager, &scmCredsAdapter{cr: scmCredResolver}) runnerBrokerHandler.SetUsageRepository(repository.NewLLMUsageRepository(s.db)) // meter LLM token/cost at the broker (WI-493) if bindingSvc != nil { // Registers the coding-agent assignee trigger inside the item @@ -1037,6 +1034,23 @@ func (s *Server) initialize() error { s.actionService.SetApprovalService(approvalService) workspaceRoleHandler.SetApprovalService(approvalService) + zammadService := services.NewZammadService( + s.db, + repository.NewZammadRepository(s.db), + actionCredentialService, + permService, + workflowService, + conditionService, + approvalService, + ) + zammadService.SetEventCoordinator(eventCoordinator) + zammadService.SetOAuthEncryption(scmProviderHandler.GetEncryption()) + zammadHandler := handlers.NewZammadHandler(repository.NewItemRepository(s.db), zammadService, permService, logger.NewAuditor(s.db)) + integrationOAuthHandler.RegisterSystemOAuthFlow(models.IntegrationProviderZammad, zammadService, logger.NewAuditor(s.db)) + s.zammadSyncScheduler = scheduler.NewZammadSyncScheduler(zammadService) + s.zammadSyncScheduler.Start() + zammadHandler.SetSyncAllTrigger(s.zammadSyncScheduler.TriggerSyncAll) + // Standard profiles execute in-process through the canonical aitools // registry. Wiring is intentionally late because their final comments and // approval tools use the fully configured application services above. @@ -1616,6 +1630,7 @@ func (s *Server) initialize() error { OAuth: integrationOAuthHandler, ItemLinks: integrationItemLinksHandler, TodoistSync: todoistSyncHandler, + Zammad: zammadHandler, }, Pages: routes.PageHandlers{ Page: pageHandler, @@ -1987,6 +2002,11 @@ func (s *Server) Shutdown(ctx context.Context) error { s.todoistSyncScheduler.Stop() } + if s.zammadSyncScheduler != nil { + slog.Info("stopping Zammad sync scheduler") + s.zammadSyncScheduler.Stop() + } + if s.runnerLeaseReaper != nil { slog.Info("stopping runner lease reaper") s.runnerLeaseReaper.Stop() @@ -2120,6 +2140,9 @@ func (s *Server) cleanup() { if s.globalRankMigrationScheduler != nil { s.globalRankMigrationScheduler.Stop() } + if s.zammadSyncScheduler != nil { + s.zammadSyncScheduler.Stop() + } // Stop rate limiters if s.loginRateLimiter != nil { s.loginRateLimiter.Stop() diff --git a/internal/services/action_credential_service.go b/internal/services/action_credential_service.go index b664e5a7b..e0504819a 100644 --- a/internal/services/action_credential_service.go +++ b/internal/services/action_credential_service.go @@ -10,6 +10,7 @@ import ( "log/slog" "strings" + "windshift/internal/database" "windshift/internal/models" "windshift/internal/repository" "windshift/internal/sso" @@ -42,6 +43,18 @@ var ErrCredentialScopeMismatch = errors.New("action credential not in scope") // is_enabled = false. var ErrCredentialDisabled = errors.New("action credential disabled") +// ErrCredentialPurposeMismatch prevents provider-managed credentials from +// being reused by generic HTTP capabilities or another provider instance. +var ErrCredentialPurposeMismatch = errors.New("action credential purpose mismatch") + +type managedCredentialMetadata struct { + Marker string `json:"_windshift_managed_credential"` + ManagedBy string `json:"managed_by"` + OwnerID string `json:"owner_id"` +} + +const managedCredentialMetadataMarker = "v1" + // validCredentialTypes enumerates the credential_type values the API accepts. var validCredentialTypes = map[models.ActionCredentialType]struct{}{ models.CredentialBearerToken: {}, @@ -54,6 +67,27 @@ var validCredentialTypes = map[models.ActionCredentialType]struct{}{ // returned model has EncryptedSecret populated but never plaintext; callers // should immediately Sanitize() before returning to clients. func (s *ActionCredentialService) Create(req models.CreateActionCredentialRequest, createdBy *int) (*models.ActionCredential, error) { + if isManagedCredentialMetadata(req.SecretMetadata) { + return nil, errors.New("managed credential metadata is reserved") + } + return s.create(req, createdBy) +} + +// CreateManaged creates a credential that only its owning provider service can +// resolve or mutate. It is omitted from generic credential management APIs. +func (s *ActionCredentialService) CreateManaged(req models.CreateActionCredentialRequest, createdBy *int, purpose, ownerID string) (*models.ActionCredential, error) { + if strings.TrimSpace(purpose) == "" || strings.TrimSpace(ownerID) == "" { + return nil, errors.New("managed credential purpose and owner are required") + } + metadata, err := json.Marshal(managedCredentialMetadata{Marker: managedCredentialMetadataMarker, ManagedBy: purpose, OwnerID: ownerID}) + if err != nil { + return nil, err + } + req.SecretMetadata = string(metadata) + return s.create(req, createdBy) +} + +func (s *ActionCredentialService) create(req models.CreateActionCredentialRequest, createdBy *int) (*models.ActionCredential, error) { if strings.TrimSpace(req.Name) == "" { return nil, errors.New("name is required") } @@ -124,6 +158,101 @@ func (s *ActionCredentialService) UpdateMetadata(id int, req models.UpdateAction if err != nil { return nil, err } + if isManagedCredential(c) || (req.SecretMetadata != nil && isManagedCredentialMetadata(*req.SecretMetadata)) { + return nil, repository.ErrNotFound + } + return s.updateMetadata(c, req, false) +} + +func (s *ActionCredentialService) UpdateManagedMetadata(id int, req models.UpdateActionCredentialRequest, purpose, ownerID string) (*models.ActionCredential, error) { + c, err := s.repo.GetActionCredentialByID(id) + if err != nil { + return nil, err + } + if !credentialMatchesPurpose(c, purpose, ownerID) { + return nil, ErrCredentialPurposeMismatch + } + return s.updateMetadata(c, req, true) +} + +// PrepareManagedUpdate applies provider-owned secret and metadata changes and +// returns a rollback closure for the caller's adjacent provider-table update. +// This keeps the credential restorable when the second persistence step fails. +func (s *ActionCredentialService) PrepareManagedUpdate(id int, name string, appliesToAll bool, workspaceIDs []int, secret *string, purpose, ownerID string) (func(database.Tx) error, error) { + existing, err := s.repo.GetActionCredentialByID(id) + if err != nil { + return nil, err + } + if !credentialMatchesPurpose(existing, purpose, ownerID) { + return nil, ErrCredentialPurposeMismatch + } + normalizedWorkspaceIDs, err := normalizeCredentialWorkspaceIDs(workspaceIDs) + if err != nil { + return nil, err + } + if !appliesToAll && len(normalizedWorkspaceIDs) == 0 { + return nil, errors.New("workspace_ids must contain at least one workspace when applies_to_all_workspaces is false") + } + if appliesToAll { + normalizedWorkspaceIDs = nil + } + existing.Name = name + existing.AppliesToAllWorkspaces = appliesToAll + var encryptedSecret, prefix *string + if secret != nil && strings.TrimSpace(*secret) != "" { + if err := validateCredentialSecret(existing.CredentialType, *secret); err != nil { + return nil, err + } + ciphertext, err := s.encryption.Encrypt(*secret) + if err != nil { + return nil, err + } + secretPrefix := models.SecretPrefixFor(*secret) + encryptedSecret = &ciphertext + prefix = &secretPrefix + } + return func(tx database.Tx) error { + return s.repo.UpdateManagedCredentialTx(tx, existing, normalizedWorkspaceIDs, encryptedSecret, prefix) + }, nil +} + +// PrepareManagedSecretUpdate encrypts a replacement secret and returns a +// transaction closure that changes only secret material. Long-running OAuth +// operations use this so a stale connection snapshot cannot roll back a +// concurrent credential rename or workspace-scope update. +func (s *ActionCredentialService) PrepareManagedSecretUpdate(id int, secret, purpose, ownerID string) (func(database.Tx) error, error) { + existing, err := s.repo.GetActionCredentialByID(id) + if err != nil { + return nil, err + } + if !credentialMatchesPurpose(existing, purpose, ownerID) { + return nil, ErrCredentialPurposeMismatch + } + if strings.TrimSpace(secret) == "" { + return nil, errors.New("secret is required") + } + if err := validateCredentialSecret(existing.CredentialType, secret); err != nil { + return nil, err + } + ciphertext, err := s.encryption.Encrypt(secret) + if err != nil { + return nil, fmt.Errorf("encrypt credential: %w", err) + } + prefix := models.SecretPrefixFor(secret) + expectedMetadata := existing.SecretMetadata + return func(tx database.Tx) error { + updated, err := s.repo.UpdateManagedCredentialSecretTx(tx, id, expectedMetadata, ciphertext, prefix) + if err != nil { + return err + } + if !updated { + return ErrCredentialPurposeMismatch + } + return nil + }, nil +} + +func (s *ActionCredentialService) updateMetadata(c *models.ActionCredential, req models.UpdateActionCredentialRequest, preserveManagedMetadata bool) (*models.ActionCredential, error) { if req.Name != nil { if strings.TrimSpace(*req.Name) == "" { return nil, errors.New("name cannot be empty") @@ -131,6 +260,9 @@ func (s *ActionCredentialService) UpdateMetadata(id int, req models.UpdateAction c.Name = *req.Name } if req.SecretMetadata != nil { + if preserveManagedMetadata { + return nil, errors.New("managed credential metadata cannot be changed") + } if err := validateSecretMetadata(*req.SecretMetadata); err != nil { return nil, err } @@ -147,6 +279,7 @@ func (s *ActionCredentialService) UpdateMetadata(id int, req models.UpdateAction nextAppliesAll = *req.AppliesToAllWorkspaces } var nextWorkspaceIDs []int + var err error scopeTouched := req.AppliesToAllWorkspaces != nil || req.WorkspaceIDs != nil if scopeTouched { if req.WorkspaceIDs != nil { @@ -179,13 +312,31 @@ func (s *ActionCredentialService) UpdateMetadata(id int, req models.UpdateAction // Rotate re-encrypts the secret for an existing credential. func (s *ActionCredentialService) Rotate(id int, req models.RotateActionCredentialRequest) (*models.ActionCredential, error) { - if strings.TrimSpace(req.Secret) == "" { - return nil, errors.New("secret is required") + existing, err := s.repo.GetActionCredentialByID(id) + if err != nil { + return nil, err + } + if isManagedCredential(existing) { + return nil, repository.ErrNotFound } + return s.rotate(existing, req) +} + +func (s *ActionCredentialService) RotateManaged(id int, req models.RotateActionCredentialRequest, purpose, ownerID string) (*models.ActionCredential, error) { existing, err := s.repo.GetActionCredentialByID(id) if err != nil { return nil, err } + if !credentialMatchesPurpose(existing, purpose, ownerID) { + return nil, ErrCredentialPurposeMismatch + } + return s.rotate(existing, req) +} + +func (s *ActionCredentialService) rotate(existing *models.ActionCredential, req models.RotateActionCredentialRequest) (*models.ActionCredential, error) { + if strings.TrimSpace(req.Secret) == "" { + return nil, errors.New("secret is required") + } if err := validateCredentialSecret(existing.CredentialType, req.Secret); err != nil { return nil, err } @@ -194,7 +345,7 @@ func (s *ActionCredentialService) Rotate(id int, req models.RotateActionCredenti return nil, fmt.Errorf("encrypt credential: %w", err) } prefix := models.SecretPrefixFor(req.Secret) - if err := s.repo.RotateActionCredential(id, ciphertext, prefix); err != nil { + if err := s.repo.RotateActionCredential(existing.ID, ciphertext, prefix); err != nil { return nil, err } existing.EncryptedSecret = ciphertext @@ -211,13 +362,38 @@ func validateCredentialSecret(credentialType models.ActionCredentialType, secret // Delete removes a credential. Callers must enforce permission/scope first. func (s *ActionCredentialService) Delete(id int) error { + c, err := s.repo.GetActionCredentialByID(id) + if err != nil { + return err + } + if isManagedCredential(c) { + return repository.ErrNotFound + } + return s.repo.DeleteActionCredential(id) +} + +func (s *ActionCredentialService) DeleteManaged(id int, purpose, ownerID string) error { + c, err := s.repo.GetActionCredentialByID(id) + if err != nil { + return err + } + if !credentialMatchesPurpose(c, purpose, ownerID) { + return ErrCredentialPurposeMismatch + } return s.repo.DeleteActionCredential(id) } // Get returns a credential record (ciphertext + metadata). The handler is // responsible for Sanitize() before sending to clients. func (s *ActionCredentialService) Get(id int) (*models.ActionCredential, error) { - return s.repo.GetActionCredentialByID(id) + c, err := s.repo.GetActionCredentialByID(id) + if err != nil { + return nil, err + } + if isManagedCredential(c) { + return nil, repository.ErrNotFound + } + return c, nil } // ListForWorkspace returns credentials usable in the given workspace: those @@ -225,17 +401,20 @@ func (s *ActionCredentialService) Get(id int) (*models.ActionCredential, error) // The execution engine uses this to validate that a credential reference is // in-scope. func (s *ActionCredentialService) ListForWorkspace(workspaceID int) ([]*models.ActionCredential, error) { - return s.repo.ListActionCredentialsForWorkspace(workspaceID) + credentials, err := s.repo.ListActionCredentialsForWorkspace(workspaceID) + return filterManagedCredentials(credentials), err } // ListGlobal returns credentials that apply to all workspaces. func (s *ActionCredentialService) ListGlobal() ([]*models.ActionCredential, error) { - return s.repo.ListActionCredentialsGlobal() + credentials, err := s.repo.ListActionCredentialsGlobal() + return filterManagedCredentials(credentials), err } // ListAll returns every credential (system-admin view). func (s *ActionCredentialService) ListAll() ([]*models.ActionCredential, error) { - return s.repo.ListAllActionCredentials() + credentials, err := s.repo.ListAllActionCredentials() + return filterManagedCredentials(credentials), err } // Resolve loads a credential and returns the plaintext secret, but only if @@ -250,6 +429,24 @@ func (s *ActionCredentialService) Resolve(_ context.Context, credentialID, works if err != nil { return "", nil, err } + if isManagedCredential(c) { + return "", nil, ErrCredentialPurposeMismatch + } + return s.resolve(c, workspaceID) +} + +func (s *ActionCredentialService) ResolveManaged(_ context.Context, credentialID, workspaceID int, purpose, ownerID string) (string, *models.ActionCredential, error) { + c, err := s.repo.GetActionCredentialByID(credentialID) + if err != nil { + return "", nil, err + } + if !credentialMatchesPurpose(c, purpose, ownerID) { + return "", nil, ErrCredentialPurposeMismatch + } + return s.resolve(c, workspaceID) +} + +func (s *ActionCredentialService) resolve(c *models.ActionCredential, workspaceID int) (string, *models.ActionCredential, error) { if !c.IsEnabled { return "", c, ErrCredentialDisabled } @@ -269,6 +466,44 @@ func (s *ActionCredentialService) Resolve(_ context.Context, credentialID, works return plaintext, c, nil } +func isManagedCredentialMetadata(raw string) bool { + var metadata managedCredentialMetadata + return json.Unmarshal([]byte(raw), &metadata) == nil && + metadata.Marker == managedCredentialMetadataMarker && + strings.TrimSpace(metadata.ManagedBy) != "" && + strings.TrimSpace(metadata.OwnerID) != "" +} + +func isManagedCredential(c *models.ActionCredential) bool { + return c != nil && isManagedCredentialMetadata(c.SecretMetadata) +} + +func credentialMatchesPurpose(c *models.ActionCredential, purpose, ownerID string) bool { + if c == nil { + return false + } + var metadata managedCredentialMetadata + if json.Unmarshal([]byte(c.SecretMetadata), &metadata) != nil { + return false + } + return isManagedCredentialMetadata(c.SecretMetadata) && + metadata.ManagedBy == purpose && metadata.OwnerID == ownerID && + strings.TrimSpace(purpose) != "" && strings.TrimSpace(ownerID) != "" +} + +func filterManagedCredentials(credentials []*models.ActionCredential) []*models.ActionCredential { + if len(credentials) == 0 { + return credentials + } + filtered := credentials[:0] + for _, credential := range credentials { + if !isManagedCredential(credential) { + filtered = append(filtered, credential) + } + } + return filtered +} + // CanCapabilityReference returns whether a given capability scope is allowed // to reference a credential of the given scope. Used by capability validation. // diff --git a/internal/services/action_credential_service_test.go b/internal/services/action_credential_service_test.go new file mode 100644 index 000000000..8a9717e71 --- /dev/null +++ b/internal/services/action_credential_service_test.go @@ -0,0 +1,44 @@ +package services + +import ( + "testing" + + "windshift/internal/models" +) + +func TestIsManagedCredentialMetadataRequiresReservedVersionedMarkerAndOwnership(t *testing.T) { + tests := []struct { + name string + metadata string + managed bool + }{ + {name: "provider metadata", metadata: `{"_windshift_managed_credential":"v1","managed_by":"zammad","owner_id":"provider-1"}`, managed: true}, + {name: "managed by only remains generic", metadata: `{"managed_by":"zammad"}`}, + {name: "owner only remains generic", metadata: `{"owner_id":"provider-1"}`}, + {name: "missing marker remains generic", metadata: `{"managed_by":"zammad","owner_id":"provider-1"}`}, + {name: "unknown marker version remains generic", metadata: `{"_windshift_managed_credential":"v2","managed_by":"zammad","owner_id":"provider-1"}`}, + {name: "blank ownership remains generic", metadata: `{"_windshift_managed_credential":"v1","managed_by":" ","owner_id":"provider-1"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isManagedCredentialMetadata(tt.metadata); got != tt.managed { + t.Fatalf("isManagedCredentialMetadata(%s) = %v, want %v", tt.metadata, got, tt.managed) + } + }) + } +} + +func TestCredentialMatchesPurposeRequiresManagedMetadata(t *testing.T) { + credential := &models.ActionCredential{ + SecretMetadata: `{"_windshift_managed_credential":"v1","managed_by":"zammad","owner_id":"provider-1"}`, + } + if !credentialMatchesPurpose(credential, "zammad", "provider-1") { + t.Fatal("expected matching provider-managed metadata") + } + + credential.SecretMetadata = `{"managed_by":"zammad","owner_id":"provider-1"}` + if credentialMatchesPurpose(credential, "zammad", "provider-1") { + t.Fatal("generic metadata must not match a managed credential purpose") + } +} diff --git a/internal/services/integration_link_guards.go b/internal/services/integration_link_guards.go new file mode 100644 index 000000000..d8f93696a --- /dev/null +++ b/internal/services/integration_link_guards.go @@ -0,0 +1,72 @@ +package services + +import ( + "windshift/internal/database" + "windshift/internal/repository" +) + +// IntegrationLinkGuard protects provider-managed links whose lifecycle needs +// more than the generic item_integration_links cascade can provide. +type IntegrationLinkGuard interface { + HasLinksForItemsTx(tx database.Tx, itemIDs []int) (bool, error) + HasLinksForWorkspaceTx(tx database.Tx, workspaceID int) (bool, error) + HasLinkUnavailableInWorkspaceTx(tx database.Tx, itemID, workspaceID int) (bool, error) +} + +// IntegrationLinkGuards composes all provider lifecycle extensions. Core item +// and workspace services depend only on this interface set, not on Zammad. +type IntegrationLinkGuards struct { + guards []IntegrationLinkGuard +} + +func NewIntegrationLinkGuards(db database.Database) *IntegrationLinkGuards { + return &IntegrationLinkGuards{guards: []IntegrationLinkGuard{ + zammadIntegrationLinkGuard{repo: repository.NewZammadRepository(db)}, + }} +} + +func (g *IntegrationLinkGuards) HasLinksForItemsTx(tx database.Tx, itemIDs []int) (bool, error) { + for _, guard := range g.guards { + blocked, err := guard.HasLinksForItemsTx(tx, itemIDs) + if err != nil || blocked { + return blocked, err + } + } + return false, nil +} + +func (g *IntegrationLinkGuards) HasLinksForWorkspaceTx(tx database.Tx, workspaceID int) (bool, error) { + for _, guard := range g.guards { + blocked, err := guard.HasLinksForWorkspaceTx(tx, workspaceID) + if err != nil || blocked { + return blocked, err + } + } + return false, nil +} + +func (g *IntegrationLinkGuards) HasLinkUnavailableInWorkspaceTx(tx database.Tx, itemID, workspaceID int) (bool, error) { + for _, guard := range g.guards { + blocked, err := guard.HasLinkUnavailableInWorkspaceTx(tx, itemID, workspaceID) + if err != nil || blocked { + return blocked, err + } + } + return false, nil +} + +type zammadIntegrationLinkGuard struct { + repo *repository.ZammadRepository +} + +func (g zammadIntegrationLinkGuard) HasLinksForItemsTx(tx database.Tx, itemIDs []int) (bool, error) { + return g.repo.HasTicketLinksForItemsTx(tx, itemIDs) +} + +func (g zammadIntegrationLinkGuard) HasLinksForWorkspaceTx(tx database.Tx, workspaceID int) (bool, error) { + return g.repo.HasTicketLinksForWorkspaceTx(tx, workspaceID) +} + +func (g zammadIntegrationLinkGuard) HasLinkUnavailableInWorkspaceTx(tx database.Tx, itemID, workspaceID int) (bool, error) { + return g.repo.HasTicketLinkUnavailableInWorkspaceTx(tx, itemID, workspaceID) +} diff --git a/internal/services/item_change_publisher.go b/internal/services/item_change_publisher.go index 69cbca8dd..bb5af1a0f 100644 --- a/internal/services/item_change_publisher.go +++ b/internal/services/item_change_publisher.go @@ -16,6 +16,7 @@ const ( ItemChangeDeleted ItemChangeKind = "deleted" ItemChangeComment ItemChangeKind = "comment" ItemChangeLink ItemChangeKind = "link" + ItemChangeZammad ItemChangeKind = "zammad" ) // ItemChangePublisher receives item-change notifications after a mutation has diff --git a/internal/services/item_crud_service.go b/internal/services/item_crud_service.go index fbd2c0d2b..f5ee94225 100644 --- a/internal/services/item_crud_service.go +++ b/internal/services/item_crud_service.go @@ -18,23 +18,26 @@ import ( ) var ( - ErrCollectionNotFound = errors.New("collection not found") - ErrQLQuery = errors.New("QL query error") + ErrCollectionNotFound = errors.New("collection not found") + ErrQLQuery = errors.New("QL query error") + ErrItemHasProtectedIntegrationLinks = errors.New("item has provider-managed integration links") ) // ItemCRUDService handles item CRUD operations type ItemCRUDService struct { - db database.Database - repo *repository.ItemRepository - workspaceRepo *repository.WorkspaceRepository + db database.Database + repo *repository.ItemRepository + workspaceRepo *repository.WorkspaceRepository + integrationLinkGuards *IntegrationLinkGuards } // NewItemCRUDService creates a new item CRUD service func NewItemCRUDService(db database.Database) *ItemCRUDService { return &ItemCRUDService{ - db: db, - repo: repository.NewItemRepository(db), - workspaceRepo: repository.NewWorkspaceRepository(db), + db: db, + repo: repository.NewItemRepository(db), + workspaceRepo: repository.NewWorkspaceRepository(db), + integrationLinkGuards: NewIntegrationLinkGuards(db), } } @@ -95,6 +98,13 @@ func (s *ItemCRUDService) DeleteSingleWithMetadata(itemID int, metadata itemeven if err != nil { return err } + hasProtectedLinks, err := s.integrationLinkGuards.HasLinksForItemsTx(tx, []int{itemID}) + if err != nil { + return err + } + if hasProtectedLinks { + return ErrItemHasProtectedIntegrationLinks + } if metadata.OccurredAt.IsZero() { metadata.OccurredAt = time.Now() } @@ -157,6 +167,13 @@ func (s *ItemCRUDService) DeleteWithMetadata(itemID int, metadata itemevents.Met if len(items) != len(allIDs) { return repository.ErrNotFound } + hasProtectedLinks, err := s.integrationLinkGuards.HasLinksForItemsTx(tx, allIDs) + if err != nil { + return err + } + if hasProtectedLinks { + return ErrItemHasProtectedIntegrationLinks + } if metadata.OccurredAt.IsZero() { metadata.OccurredAt = time.Now() } diff --git a/internal/services/item_workspace_move_service.go b/internal/services/item_workspace_move_service.go index 61a273934..bf997d00d 100644 --- a/internal/services/item_workspace_move_service.go +++ b/internal/services/item_workspace_move_service.go @@ -580,6 +580,16 @@ func (s *ItemWorkspaceMoveService) MoveContext(ctx context.Context, itemID, acto if err != nil { return nil, err } + integrationLinkUnavailable, err := NewIntegrationLinkGuards(s.db).HasLinkUnavailableInWorkspaceTx(tx, itemID, input.DestinationWorkspaceID) + if err != nil { + return nil, fmt.Errorf("validate integration links for workspace move: %w", err) + } + if integrationLinkUnavailable { + return nil, &validation.ValidationError{ + Field: "destination_workspace_id", + Message: "Destination workspace is not allowed by every provider-managed integration link", + } + } newNumber, err := itemRepo.GetNextWorkspaceItemNumber(tx, input.DestinationWorkspaceID) if err != nil { diff --git a/internal/services/user_offboard.go b/internal/services/user_offboard.go index 5bf9819ea..b96ff4284 100644 --- a/internal/services/user_offboard.go +++ b/internal/services/user_offboard.go @@ -17,6 +17,10 @@ type UserNotificationDeleter interface { DeleteUserNotifications(userID int) error } +// ErrUserOffboardingHasProtectedIntegrationLinks prevents deleting a personal +// workspace while provider-managed links still need explicit cleanup. +var ErrUserOffboardingHasProtectedIntegrationLinks = errors.New("user has provider-managed integration links in personal workspace") + // OffboardUser deactivates a user and anonymizes their PII while preserving // audit trails. The user row is kept (anonymized) so that FK references from // item_history, comments, time_worklogs, etc. remain valid. @@ -35,6 +39,34 @@ func OffboardUser(db database.Database, userID int, notificationDeleter UserNoti } defer func() { _ = tx.Rollback() }() + // Resolve the personal workspace and protect its external links before any + // mutation in this transaction. Offboarding must not orphan linked tickets. + var personalWsID *int + var wsID int + personalWorkspaceQuery := `SELECT id FROM workspaces WHERE is_personal = true AND owner_id = ?` + if db.GetDriverName() == "postgres" { + personalWorkspaceQuery += " FOR UPDATE" + } + row := tx.QueryRow(personalWorkspaceQuery, userID) + if err := row.Scan(&wsID); err == nil { + personalWsID = &wsID + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("failed to find personal workspace: %w", err) + } + itemRepo := repository.NewItemRepository(db) + if personalWsID != nil { + if err := itemRepo.LockWorkspaceItemsTx(tx, *personalWsID); err != nil { + return nil, err + } + hasLinks, err := NewIntegrationLinkGuards(db).HasLinksForWorkspaceTx(tx, *personalWsID) + if err != nil { + return nil, fmt.Errorf("failed to check personal workspace integration links: %w", err) + } + if hasLinks { + return nil, ErrUserOffboardingHasProtectedIntegrationLinks + } + } + // a) Anonymize user record if _, err := tx.Exec(` UPDATE users SET @@ -58,15 +90,6 @@ func OffboardUser(db database.Database, userID int, notificationDeleter UserNoti } // b) Delete personal workspace (items cascade via FK) - var personalWsID *int - row := tx.QueryRow(`SELECT id FROM workspaces WHERE is_personal = true AND owner_id = ?`, userID) - var wsID int - if err := row.Scan(&wsID); err == nil { - personalWsID = &wsID - } else if !errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("failed to load personal workspace: %w", err) - } - itemRepo := repository.NewItemRepository(db) if personalWsID != nil { if err := itemRepo.DeleteByWorkspaceTx(tx, *personalWsID); err != nil { return nil, fmt.Errorf("failed to delete personal workspace items: %w", err) diff --git a/internal/services/workspace_service.go b/internal/services/workspace_service.go index ec02dd6ae..211ce56ec 100644 --- a/internal/services/workspace_service.go +++ b/internal/services/workspace_service.go @@ -13,21 +13,27 @@ import ( "windshift/internal/repository" ) +var ErrWorkspaceHasProtectedIntegrationLinks = errors.New("workspace has provider-managed integration links") + // WorkspaceService encapsulates workspace business logic used by both HTTP handlers // and other services. type WorkspaceService struct { - db database.Database - repo *repository.WorkspaceRepository - templates *repository.WorkspaceTemplateRepository - access WorkspaceSourceAccess + db database.Database + repo *repository.WorkspaceRepository + itemRepo *repository.ItemRepository + templates *repository.WorkspaceTemplateRepository + integrationLinkGuards *IntegrationLinkGuards + access WorkspaceSourceAccess } // NewWorkspaceService creates a new WorkspaceService. func NewWorkspaceService(db database.Database) *WorkspaceService { return &WorkspaceService{ - db: db, - repo: repository.NewWorkspaceRepository(db), - templates: repository.NewWorkspaceTemplateRepository(db), + db: db, + repo: repository.NewWorkspaceRepository(db), + itemRepo: repository.NewItemRepository(db), + templates: repository.NewWorkspaceTemplateRepository(db), + integrationLinkGuards: NewIntegrationLinkGuards(db), } } @@ -342,21 +348,39 @@ func nullableUpdateValue[T any](update NullableUpdate[T]) any { // Delete removes a workspace by ID. func (s *WorkspaceService) Delete(id int) error { - // Check workspace exists - exists, err := s.repo.Exists(id) - if err != nil { - return fmt.Errorf("failed to check workspace existence: %w", err) - } - if !exists { - return fmt.Errorf("workspace not found: %d: %w", id, repository.ErrNotFound) - } + if err := database.WithTx(s.db, func(tx database.Tx) error { + exists, err := s.repo.LockForDeleteTx(tx, id) + if err != nil { + return fmt.Errorf("check workspace existence: %w", err) + } + if !exists { + return fmt.Errorf("workspace not found: %d: %w", id, repository.ErrNotFound) + } + if err := s.itemRepo.LockWorkspaceItemsTx(tx, id); err != nil { + return err + } - // Delete workspace (cascade will handle related records) - err = s.repo.Delete(id) - if err != nil { - return fmt.Errorf("failed to delete workspace: %w", err) + hasProtectedLinks, err := s.integrationLinkGuards.HasLinksForWorkspaceTx(tx, id) + if err != nil { + return err + } + if hasProtectedLinks { + return ErrWorkspaceHasProtectedIntegrationLinks + } + + if err := s.repo.DeleteTx(tx, id); err != nil { + return fmt.Errorf("delete workspace: %w", err) + } + if err := s.repo.DropItemSequenceTx(tx, int64(id)); err != nil { + return fmt.Errorf("drop workspace item sequence: %w", err) + } + return nil + }); err != nil { + return err } + repository.InvalidateItemListCountCache(s.db, id) + return nil } diff --git a/internal/services/zammad_service.go b/internal/services/zammad_service.go new file mode 100644 index 000000000..ef4e8ea5c --- /dev/null +++ b/internal/services/zammad_service.go @@ -0,0 +1,2301 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "windshift/internal/database" + coreintegrations "windshift/internal/integrations" + "windshift/internal/integrations/zammad" + "windshift/internal/itemevents" + "windshift/internal/models" + "windshift/internal/repository" + "windshift/internal/sso" + + "uuid" +) + +var zammadFieldNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,79}$`) +var zammadSlugPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,79}$`) + +var ErrZammadReauthorizationRequired = errors.New("zammad OAuth reauthorization is required") +var ErrZammadOAuthSuperseded = errors.New("zammad OAuth operation was superseded by a configuration change") +var ErrZammadOAuthRefreshInProgress = errors.New("zammad OAuth refresh is already in progress") +var ErrZammadLinkReservationConflict = errors.New("zammad connection or item changed while the ticket link was being prepared") +var ErrZammadTicketGroupPolicyChanged = errors.New("zammad connection no longer allows the ticket group") +var ErrZammadConnectionBusy = errors.New("zammad connection has a ticket operation in progress") + +const zammadOAuthRefreshLeaseDuration = 2 * zammadHTTPTimeout +const zammadOAuthRefreshWaitDuration = 5 * time.Second +const zammadOAuthRefreshPollInterval = 50 * time.Millisecond +const zammadSyncLeaseDuration = 5 * time.Minute + +type ZammadOAuthCallbackResult = coreintegrations.SystemOAuthCallbackResult + +type ZammadSyncSummary struct { + Selected int `json:"selected"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` +} + +// zammadOAuthCredential is encrypted as the secret of the provider-managed +// action credential. It is deliberately not a value that can be sent as an +// Authorization header, including while OAuth is pending. +type zammadOAuthCredential struct { + Version int `json:"version"` + Status string `json:"status"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` +} + +func pendingZammadOAuthCredential(status string) string { + payload, _ := json.Marshal(zammadOAuthCredential{Version: 1, Status: status}) + return string(payload) +} + +func activeZammadOAuthCredential(accessToken, refreshToken string) (string, error) { + payload, err := json.Marshal(zammadOAuthCredential{Version: 1, Status: "active", AccessToken: accessToken, RefreshToken: refreshToken}) + return string(payload), err +} + +func parseZammadOAuthCredential(raw string) (*zammadOAuthCredential, error) { + var payload zammadOAuthCredential + if err := json.Unmarshal([]byte(raw), &payload); err != nil || payload.Version != 1 || payload.Status != "active" || strings.TrimSpace(payload.AccessToken) == "" || strings.TrimSpace(payload.RefreshToken) == "" { + return nil, ErrZammadReauthorizationRequired + } + return &payload, nil +} + +type ZammadValidationError struct{ Message string } + +func (e *ZammadValidationError) Error() string { return e.Message } + +func zammadValidationError(message string) error { + return &ZammadValidationError{Message: message} +} + +type zammadWorkflowTransitioner interface { + PerformTransition(context.Context, PerformTransitionRequest, *repository.ItemRepository, *ConditionService, transitionApprovalService) (*PerformTransitionResult, error) +} + +type zammadPermissionChecker interface { + HasWorkspacePermission(userID, workspaceID int, permission string) (bool, error) +} + +type ZammadService struct { + db database.Database + repo *repository.ZammadRepository + credentials *ActionCredentialService + permission zammadPermissionChecker + workflow zammadWorkflowTransitioner + condition *ConditionService + approval *ApprovalService + events *EventCoordinator + transportOverride zammad.Transport + oauthTransportOverride zammad.Transport + encryption *sso.SecretEncryption + oauthBeforeRefreshClaim func() + updateBeforeLock func() + persistBeforeLock func() + updateBeforeRemoteWrite func() + completionBeforeTransition func() +} + +func NewZammadService(db database.Database, repo *repository.ZammadRepository, credentials *ActionCredentialService, permission zammadPermissionChecker, workflow zammadWorkflowTransitioner, condition *ConditionService, approval *ApprovalService) *ZammadService { + return &ZammadService{ + db: db, repo: repo, credentials: credentials, permission: permission, + workflow: workflow, condition: condition, approval: approval, + } +} + +func (s *ZammadService) SetEventCoordinator(events *EventCoordinator) { + s.events = events +} + +// SetTransportForTesting replaces the production SSRF-safe transport. Tests +// use it with httptest; production bootstrap never calls this method. +func (s *ZammadService) SetTransportForTesting(transport zammad.Transport) { + s.transportOverride = transport +} + +// SetOAuthEncryption supplies the system secret realm already used by +// integration_providers. It is set during server bootstrap, never exposed. +func (s *ZammadService) SetOAuthEncryption(encryption *sso.SecretEncryption) { + s.encryption = encryption +} + +func (s *ZammadService) SetOAuthTransportForTesting(transport zammad.Transport) { + s.oauthTransportOverride = transport +} + +func (s *ZammadService) SetOAuthBeforeRefreshClaimForTesting(hook func()) { + s.oauthBeforeRefreshClaim = hook +} + +func (s *ZammadService) SetUpdateBeforeConnectionLockForTesting(hook func()) { + s.updateBeforeLock = hook +} + +func (s *ZammadService) SetPersistBeforeConnectionLockForTesting(hook func()) { + s.persistBeforeLock = hook +} + +func (s *ZammadService) SetUpdateBeforeRemoteWriteForTesting(hook func()) { + s.updateBeforeRemoteWrite = hook +} + +func (s *ZammadService) SetCompletionBeforeTransitionForTesting(hook func()) { + s.completionBeforeTransition = hook +} + +func (s *ZammadService) ListConnections() ([]*models.ZammadConnection, error) { + return s.repo.ListConnections() +} + +func (s *ZammadService) ListConnectionsForWorkspace(workspaceID int) ([]*models.ZammadConnection, error) { + return s.repo.ListConnectionsForWorkspace(workspaceID) +} + +func (s *ZammadService) GetConnection(id string) (*models.ZammadConnection, error) { + return s.repo.GetConnection(id) +} + +func (s *ZammadService) CreateConnection(req models.CreateZammadConnectionRequest, actorID int) (*models.ZammadConnection, error) { + connection, err := validateNewZammadConnection(req, actorID) + if err != nil { + return nil, err + } + if connection.AuthMethod == models.ZammadAuthMethodOAuth { + if s.encryption == nil { + return nil, errors.New("zammad OAuth encryption is not configured") + } + connection.OAuthClientSecretEncrypted, err = s.encryption.Encrypt(req.OAuthClientSecret) + if err != nil { + return nil, fmt.Errorf("encrypt Zammad OAuth client secret: %w", err) + } + credential, err := s.credentials.CreateManaged(models.CreateActionCredentialRequest{ + Name: connection.Name + " Zammad OAuth credentials", CredentialType: models.CredentialCustomHeader, + Secret: pendingZammadOAuthCredential("pending"), AppliesToAllWorkspaces: boolPointer(connection.AppliesToAllWorkspaces), WorkspaceIDs: connection.WorkspaceIDs, + }, &actorID, string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil { + return nil, err + } + connection.CredentialID = credential.ID + if err := s.repo.CreateConnection(connection); err != nil { + _ = s.credentials.DeleteManaged(credential.ID, string(models.IntegrationProviderZammad), connection.ProviderID) + return nil, err + } + return s.repo.GetConnection(connection.ProviderID) + } + credential, err := s.credentials.CreateManaged(models.CreateActionCredentialRequest{ + Name: connection.Name + " Zammad API token", + CredentialType: models.CredentialCustomHeader, + Secret: req.APIToken, + AppliesToAllWorkspaces: boolPointer(connection.AppliesToAllWorkspaces), + WorkspaceIDs: connection.WorkspaceIDs, + }, &actorID, string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil { + return nil, err + } + connection.CredentialID = credential.ID + connection.HasAPIToken = true + if err := s.repo.CreateConnection(connection); err != nil { + _ = s.credentials.DeleteManaged(credential.ID, string(models.IntegrationProviderZammad), connection.ProviderID) + return nil, err + } + return s.repo.GetConnection(connection.ProviderID) +} + +func (s *ZammadService) UpdateConnection(id string, req models.UpdateZammadConnectionRequest) (*models.ZammadConnection, error) { + connection, err := s.repo.GetConnection(id) + if err != nil { + return nil, err + } + originalBaseURL := connection.BaseURL + originalOAuthClientID := connection.OAuthClientID + oauthSecretChanged := req.OAuthClientSecret != nil && strings.TrimSpace(*req.OAuthClientSecret) != "" + if req.Slug != nil { + connection.Slug = strings.TrimSpace(*req.Slug) + } + if req.Name != nil { + connection.Name = strings.TrimSpace(*req.Name) + } + if req.Enabled != nil { + connection.Enabled = *req.Enabled + } + if req.AuthMethod != nil && *req.AuthMethod != connection.AuthMethod { + return nil, zammadValidationError("auth_method cannot be changed after a connection is created") + } + if req.OAuthClientID != nil { + connection.OAuthClientID = strings.TrimSpace(*req.OAuthClientID) + } + if oauthSecretChanged { + if s.encryption == nil { + return nil, errors.New("zammad OAuth encryption is not configured") + } + connection.OAuthClientSecretEncrypted, err = s.encryption.Encrypt(*req.OAuthClientSecret) + if err != nil { + return nil, fmt.Errorf("encrypt Zammad OAuth client secret: %w", err) + } + } + if req.BaseURL != nil { + connection.BaseURL, err = NormalizeZammadBaseURL(*req.BaseURL) + if err != nil { + return nil, err + } + } + if req.DefaultGroupID != nil { + connection.DefaultGroupID = *req.DefaultGroupID + } + if req.DefaultGroupName != nil { + connection.DefaultGroupName = strings.TrimSpace(*req.DefaultGroupName) + } + if req.AllowedGroups != nil { + normalizedGroups := normalizeZammadGroupRefs(*req.AllowedGroups) + if err := validateZammadGroupRefs(*req.AllowedGroups); err != nil && !slices.Equal(normalizedGroups, connection.AllowedGroups) { + return nil, err + } + connection.AllowedGroups = normalizedGroups + } else if req.AllowedGroupIDs != nil { + if hasNonPositiveIDs(*req.AllowedGroupIDs) { + return nil, zammadValidationError("allowed_group_ids must contain positive IDs") + } + connection.AllowedGroups = legacyZammadGroupRefs(*req.AllowedGroupIDs, connection.DefaultGroupID, connection.DefaultGroupName) + } + syncZammadDefaultGroupName(connection) + if req.DefaultCustomer != nil { + connection.DefaultCustomer = strings.TrimSpace(*req.DefaultCustomer) + } + if req.CorrelationField != nil { + connection.CorrelationField = strings.TrimSpace(*req.CorrelationField) + } + if req.ClosedStateIDs != nil { + if hasNonPositiveIDs(*req.ClosedStateIDs) { + return nil, zammadValidationError("closed_state_ids must contain positive IDs") + } + connection.ClosedStateIDs = normalizePositiveIDs(*req.ClosedStateIDs) + } + if req.ClearCompletionStatus { + connection.CompletionStatusID = nil + } else if req.CompletionStatusID != nil { + v := *req.CompletionStatusID + connection.CompletionStatusID = &v + } + if req.AppliesToAllWorkspaces != nil { + connection.AppliesToAllWorkspaces = *req.AppliesToAllWorkspaces + } + if req.WorkspaceIDs != nil { + if hasNonPositiveIDs(*req.WorkspaceIDs) { + return nil, zammadValidationError("workspace_ids must contain positive IDs") + } + connection.WorkspaceIDs = normalizePositiveIDs(*req.WorkspaceIDs) + } + if err := validateZammadConnection(connection); err != nil { + return nil, err + } + if connection.AuthMethod == models.ZammadAuthMethodOAuth { + if err := validateZammadOAuthConfiguration(connection); err != nil { + return nil, err + } + credentialsChanged := connection.BaseURL != originalBaseURL || connection.OAuthClientID != originalOAuthClientID || oauthSecretChanged + var replacementSecret *string + if credentialsChanged { + pending := pendingZammadOAuthCredential("pending") + replacementSecret = &pending + } + credentialUpdate, err := s.credentials.PrepareManagedUpdate(connection.CredentialID, + connection.Name+" Zammad OAuth credentials", connection.AppliesToAllWorkspaces, connection.WorkspaceIDs, replacementSecret, + string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil { + return nil, err + } + if s.updateBeforeLock != nil { + s.updateBeforeLock() + } + if err := database.WithTx(s.db, func(tx database.Tx) error { + if err := s.repo.LockConnectionTx(tx, connection.ProviderID); err != nil { + return err + } + if err := s.validateZammadConnectionLinksTx(tx, connection); err != nil { + return err + } + if err := s.repo.UpdateConnectionTx(tx, connection); err != nil { + return err + } + if err := credentialUpdate(tx); err != nil { + return err + } + if replacementSecret != nil { + return s.repo.ResetOAuthAuthorizationTx(tx, connection.ProviderID) + } + return nil + }); err != nil { + return nil, err + } + return s.repo.GetConnection(id) + } + if connection.CredentialID <= 0 { + return nil, zammadValidationError("API token is not configured") + } + credentialUpdate, err := s.credentials.PrepareManagedUpdate(connection.CredentialID, + connection.Name+" Zammad API token", connection.AppliesToAllWorkspaces, + connection.WorkspaceIDs, req.APIToken, string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil { + return nil, err + } + if s.updateBeforeLock != nil { + s.updateBeforeLock() + } + if err := database.WithTx(s.db, func(tx database.Tx) error { + if err := s.repo.LockConnectionTx(tx, connection.ProviderID); err != nil { + return err + } + if err := s.validateZammadConnectionLinksTx(tx, connection); err != nil { + return err + } + if err := s.repo.UpdateConnectionTx(tx, connection); err != nil { + return err + } + return credentialUpdate(tx) + }); err != nil { + return nil, err + } + return s.repo.GetConnection(id) +} + +func (s *ZammadService) DeleteConnection(id string) error { + return database.WithTx(s.db, func(tx database.Tx) error { + if err := s.repo.LockConnectionTx(tx, id); err != nil { + return err + } + hasLinks, err := s.repo.HasTicketLinksForConnectionTx(tx, id) + if err != nil { + return err + } + if hasLinks { + return zammadValidationError("unlink all Zammad tickets before deleting this connection") + } + return s.repo.DeleteConnectionTx(tx, id) + }) +} + +func (s *ZammadService) validateZammadConnectionLinksTx(tx database.Tx, connection *models.ZammadConnection) error { + current, err := s.repo.ConnectionMutationSnapshotTx(tx, connection.ProviderID) + if err != nil { + return err + } + links, err := s.repo.ListTicketLinkScopesForUpdateTx(tx, connection.ProviderID) + if err != nil { + return err + } + if len(links) > 0 && (connection.BaseURL != current.BaseURL || connection.CorrelationField != current.CorrelationField) { + return zammadValidationError("base_url and correlation_field cannot change after ticket creation has started") + } + groupPolicyChanged := connection.DefaultGroupID != current.DefaultGroupID || + connection.DefaultGroupName != current.DefaultGroupName || + !slices.Equal(effectiveZammadGroupRefs(connection), effectiveZammadSnapshotGroupRefs(current)) + completionPolicyChanged := !slices.Equal(connection.ClosedStateIDs, current.ClosedStateIDs) || + !optionalIntEqual(connection.CompletionStatusID, current.CompletionStatusID) + groupPolicyNarrows := groupPolicyChanged && zammadGroupPolicyNarrows(current, connection) + for _, link := range links { + if (groupPolicyChanged || completionPolicyChanged) && link.SyncLocked { + return ErrZammadConnectionBusy + } + if groupPolicyNarrows && !link.SetupComplete { + return ErrZammadConnectionBusy + } + if !zammadConnectionAllowsWorkspace(connection, link.WorkspaceID) { + return zammadValidationError("workspace scope cannot exclude items with linked Zammad tickets") + } + if !zammadConnectionAllowsGroupSnapshot(connection, link.GroupID, link.GroupName) { + return zammadValidationError("allowed groups cannot exclude linked Zammad tickets") + } + } + return nil +} + +func optionalIntEqual(left, right *int) bool { + return (left == nil && right == nil) || (left != nil && right != nil && *left == *right) +} + +func zammadConnectionAllowsWorkspace(connection *models.ZammadConnection, workspaceID int) bool { + return connection.AppliesToAllWorkspaces || slices.Contains(connection.WorkspaceIDs, workspaceID) +} + +func zammadConnectionAllowsGroupSnapshot(connection *models.ZammadConnection, groupID int, groupName string) bool { + allowedGroups := effectiveZammadGroupRefs(connection) + if len(allowedGroups) > 0 { + _, ok := zammadGroupRefByID(allowedGroups, groupID) + return ok + } + if connection.DefaultGroupID > 0 { + return connection.DefaultGroupID == groupID + } + return connection.DefaultGroupName != "" && connection.DefaultGroupName == groupName +} + +// zammadGroupPolicyNarrows reports whether proposed can remove a group that an +// incomplete ticket reservation may still need. Numeric group IDs can be +// compared exactly. A name-only configuration has no stable ID to compare, so +// only retaining the identical name-only policy is provably safe. +func zammadGroupPolicyNarrows(current *repository.ZammadConnectionMutationSnapshot, proposed *models.ZammadConnection) bool { + currentGroups := effectiveZammadSnapshotGroupRefs(current) + if len(currentGroups) > 0 { + for _, group := range currentGroups { + if !zammadConnectionAllowsGroupSnapshot(proposed, group.ID, group.Name) { + return true + } + } + // The current default, including a name-only default, already resolves + // to one of these validated IDs. Preserving the entire allowlist is the + // complete safety condition and avoids treating a pure expansion as a + // narrowing when the stored default has no numeric ID. + return false + } + if current.DefaultGroupID > 0 { + return !zammadConnectionAllowsGroupSnapshot(proposed, current.DefaultGroupID, current.DefaultGroupName) + } + return len(effectiveZammadGroupRefs(proposed)) > 0 || proposed.DefaultGroupID > 0 || proposed.DefaultGroupName != current.DefaultGroupName +} + +func (s *ZammadService) reserveZammadTicketLink(connection *models.ZammadConnection, item *models.Item, link *models.ZammadTicketLink, existing bool) error { + return database.WithTx(s.db, func(tx database.Tx) error { + snapshot, err := s.repo.LockTicketLinkReservationTx(tx, connection.ProviderID, item.ID) + if err != nil { + return err + } + if !snapshot.ConnectionEnabled || !snapshot.WorkspaceAllowed || + snapshot.BaseURL != connection.BaseURL || snapshot.CorrelationField != connection.CorrelationField || + snapshot.DefaultCustomer != connection.DefaultCustomer || + snapshot.WorkspaceID != item.WorkspaceID || snapshot.WorkspaceItemNumber != item.WorkspaceItemNumber || + snapshot.WorkspaceKey != item.WorkspaceKey || + !zammadConnectionAllowsGroupSnapshot(&models.ZammadConnection{ + DefaultGroupID: snapshot.DefaultGroupID, DefaultGroupName: snapshot.DefaultGroupName, + AllowedGroups: snapshot.AllowedGroups, + }, link.GroupID, link.GroupName) { + return ErrZammadLinkReservationConflict + } + if existing { + return s.repo.ReserveExistingTicketLinkTx(tx, link) + } + return s.repo.CreatePendingTicketLinkTx(tx, link) + }) +} + +// StartOAuth stores a short-lived state bound to this system connection and +// the initiating administrator. The callback URI is deliberately fixed. +func (s *ZammadService) StartOAuth(ctx context.Context, id string, actorID int, publicBaseURL string) (string, error) { + connection, err := s.repo.GetConnection(id) + if err != nil { + return "", err + } + if !connection.Enabled { + return "", zammadValidationError("connection is disabled") + } + if connection.AuthMethod != models.ZammadAuthMethodOAuth { + return "", zammadValidationError("connection does not use OAuth") + } + if err := validateZammadOAuthConfiguration(connection); err != nil { + return "", err + } + redirectURI, err := zammadOAuthRedirectURI(publicBaseURL) + if err != nil { + return "", err + } + state := uuid.New().String() + uuid.New().String() + if err := s.repo.CreateOAuthState(state, connection.ProviderID, actorID, connection.OAuthGeneration, time.Now().Add(5*time.Minute)); err != nil { + return "", err + } + authorizeURL := connection.BaseURL + "/oauth/authorize?" + url.Values{"response_type": {"code"}, "client_id": {connection.OAuthClientID}, "redirect_uri": {redirectURI}, "scope": {"full"}, "state": {state}}.Encode() + return authorizeURL, nil +} + +func (s *ZammadService) InvalidateOAuthState(state string) error { + if strings.TrimSpace(state) == "" { + return nil + } + return s.repo.InvalidateOAuthState(state) +} + +func (s *ZammadService) ConsumeFailedOAuthCallback(state string) (*ZammadOAuthCallbackResult, error) { + if strings.TrimSpace(state) == "" { + return nil, repository.ErrNotFound + } + consumed, err := s.repo.ConsumeOAuthState(state) + if err != nil { + return nil, err + } + if err := s.repo.ClearOAuthAttempt(consumed.ProviderID, consumed.OAuthGeneration, state); err != nil { + return s.oauthCallbackResult(consumed), err + } + return s.oauthCallbackResult(consumed), nil +} + +func (s *ZammadService) CompleteOAuth(ctx context.Context, state, code, publicBaseURL string) (*ZammadOAuthCallbackResult, error) { + redirectURI, err := zammadOAuthRedirectURI(publicBaseURL) + if err != nil { + return nil, err + } + consumed, err := s.repo.ConsumeOAuthState(state) + if err != nil { + return nil, err + } + attemptActive := true + defer func() { + if attemptActive { + _ = s.repo.ClearOAuthAttempt(consumed.ProviderID, consumed.OAuthGeneration, state) + } + }() + result := s.oauthCallbackResult(consumed) + connection, err := s.repo.GetConnection(consumed.ProviderID) + if err != nil { + return result, err + } + result.ProviderName = connection.Name + if connection.OAuthGeneration != consumed.OAuthGeneration { + return result, ErrZammadOAuthSuperseded + } + if !connection.Enabled { + return result, zammadValidationError("connection is disabled") + } + if connection.AuthMethod != models.ZammadAuthMethodOAuth { + return result, zammadValidationError("connection OAuth configuration changed") + } + if err := validateZammadOAuthConfiguration(connection); err != nil { + return result, err + } + if s.encryption == nil { + return result, errors.New("zammad OAuth encryption is not configured") + } + secret, err := s.encryption.Decrypt(connection.OAuthClientSecretEncrypted) + if err != nil { + return result, errors.New("could not decrypt Zammad OAuth client secret") + } + tokens, err := zammad.ExchangeOAuthCode(ctx, s.oauthTransport(connection), connection.BaseURL+"/oauth/token", connection.OAuthClientID, secret, code, redirectURI) + if err != nil { + return result, err + } + bundle, err := activeZammadOAuthCredential(tokens.AccessToken, tokens.RefreshToken) + if err != nil { + return result, err + } + credentialUpdate, err := s.credentials.PrepareManagedSecretUpdate(connection.CredentialID, bundle, + string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil { + return result, err + } + if err := database.WithTx(s.db, func(tx database.Tx) error { + current, err := s.repo.GuardOAuthCallbackTx(tx, connection.ProviderID, consumed.OAuthGeneration, state) + if err != nil { + return err + } + if !current { + return ErrZammadOAuthSuperseded + } + if err := credentialUpdate(tx); err != nil { + return err + } + return s.repo.UpsertOAuthTokenTx(tx, repository.ZammadOAuthToken{ProviderID: connection.ProviderID, OAuthGeneration: consumed.OAuthGeneration, ExpiresAt: time.Now().Add(tokens.ExpiresIn)}) + }); err != nil { + return result, err + } + attemptActive = false + return result, nil +} + +func (s *ZammadService) oauthCallbackResult(consumed *repository.ZammadOAuthState) *ZammadOAuthCallbackResult { + result := &ZammadOAuthCallbackResult{ProviderID: consumed.ProviderID, Generation: consumed.OAuthGeneration} + initiator, err := repository.NewUserRepository(s.db).GetByID(consumed.InitiatedBy) + if err != nil { + initiator = &models.User{ID: consumed.InitiatedBy, Username: "unknown"} + } + result.Initiator = initiator + return result +} + +func (s *ZammadService) oauthTransport(connection *models.ZammadConnection) zammad.Transport { + if s.oauthTransportOverride != nil { + return s.oauthTransportOverride + } + return newZammadSafeTransport(connection.BaseURL, "/oauth/token") +} + +func (s *ZammadService) oauthAccessToken(ctx context.Context, connection *models.ZammadConnection, workspaceID int) (string, error) { + if s.encryption == nil { + return "", errors.New("zammad OAuth encryption is not configured") + } + token, err := s.repo.GetOAuthToken(connection.ProviderID) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return "", ErrZammadReauthorizationRequired + } + return "", err + } + if token.ReauthorizationRequired { + return "", ErrZammadReauthorizationRequired + } + if token.OAuthGeneration != connection.OAuthGeneration { + return "", ErrZammadOAuthSuperseded + } + if token.ExpiresAt.After(time.Now().Add(2 * time.Minute)) { + return s.resolveStoredZammadOAuthAccessToken(ctx, connection, workspaceID) + } + if s.oauthBeforeRefreshClaim != nil { + s.oauthBeforeRefreshClaim() + } + claimProviderID := connection.ProviderID + claimGeneration := token.OAuthGeneration + claimOwner := uuid.New().String() + waitUntil := time.Now().Add(zammadOAuthRefreshWaitDuration) + for { + claimed, err := s.repo.ClaimOAuthRefresh(claimProviderID, claimGeneration, claimOwner, time.Now().Add(zammadOAuthRefreshLeaseDuration)) + if err != nil { + return "", err + } + if claimed { + break + } + + // Another request owns the per-connection refresh lease. Briefly wait + // for it to publish the rotated credential, then reuse that token rather + // than issuing a competing refresh request. + connection, err = s.repo.GetConnection(claimProviderID) + if err != nil { + return "", err + } + if connection.OAuthGeneration != claimGeneration { + return "", ErrZammadOAuthSuperseded + } + token, err = s.repo.GetOAuthToken(claimProviderID) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return "", ErrZammadReauthorizationRequired + } + return "", err + } + if token.ReauthorizationRequired { + return "", ErrZammadReauthorizationRequired + } + if token.OAuthGeneration != claimGeneration { + return "", ErrZammadOAuthSuperseded + } + if token.ExpiresAt.After(time.Now().Add(2 * time.Minute)) { + return s.resolveStoredZammadOAuthAccessToken(ctx, connection, workspaceID) + } + if !time.Now().Before(waitUntil) { + return "", ErrZammadOAuthRefreshInProgress + } + timer := time.NewTimer(zammadOAuthRefreshPollInterval) + select { + case <-ctx.Done(): + timer.Stop() + return "", ctx.Err() + case <-timer.C: + } + } + claimActive := true + defer func() { + if claimActive { + _ = s.repo.ReleaseOAuthRefreshClaim(claimProviderID, claimGeneration, claimOwner) + } + }() + connection, err = s.repo.GetConnection(claimProviderID) + if err != nil { + return "", err + } + if connection.OAuthGeneration != claimGeneration { + return "", ErrZammadOAuthSuperseded + } + token, err = s.repo.GetOAuthTokenForRefreshClaim(claimProviderID, claimGeneration, claimOwner) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return "", ErrZammadOAuthSuperseded + } + return "", err + } + if token.ReauthorizationRequired { + return "", ErrZammadReauthorizationRequired + } + raw, _, err := s.credentials.ResolveManaged(ctx, connection.CredentialID, workspaceID, string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil { + return "", err + } + bundle, err := parseZammadOAuthCredential(raw) + if err != nil { + return "", err + } + if token.ExpiresAt.After(time.Now().Add(2 * time.Minute)) { + if err := s.repo.ReleaseOAuthRefreshClaim(claimProviderID, claimGeneration, claimOwner); err != nil { + return "", err + } + claimActive = false + return bundle.AccessToken, nil + } + clientSecret, err := s.encryption.Decrypt(connection.OAuthClientSecretEncrypted) + if err != nil { + return "", errors.New("could not decrypt Zammad OAuth client secret") + } + refreshed, err := zammad.RefreshOAuthToken(ctx, s.oauthTransport(connection), connection.BaseURL+"/oauth/token", connection.OAuthClientID, clientSecret, bundle.RefreshToken) + if errors.Is(err, zammad.ErrInvalidGrant) { + if persistErr := s.markOAuthReauthorizationRequired(connection, connection.OAuthGeneration, claimOwner); persistErr != nil { + return "", persistErr + } + claimActive = false + return "", ErrZammadReauthorizationRequired + } + if err != nil { + return "", err + } + updatedBundle, err := activeZammadOAuthCredential(refreshed.AccessToken, refreshed.RefreshToken) + if err != nil { + return "", err + } + credentialUpdate, err := s.credentials.PrepareManagedSecretUpdate(connection.CredentialID, updatedBundle, + string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil { + return "", err + } + if err := database.WithTx(s.db, func(tx database.Tx) error { + current, err := s.repo.GuardOAuthGenerationTx(tx, connection.ProviderID, connection.OAuthGeneration) + if err != nil { + return err + } + if !current { + return ErrZammadOAuthSuperseded + } + owned, err := s.repo.GuardOAuthRefreshClaimTx(tx, connection.ProviderID, connection.OAuthGeneration, claimOwner) + if err != nil { + return err + } + if !owned { + return ErrZammadOAuthSuperseded + } + if err := credentialUpdate(tx); err != nil { + return err + } + return s.repo.UpsertOAuthTokenTx(tx, repository.ZammadOAuthToken{ProviderID: connection.ProviderID, OAuthGeneration: connection.OAuthGeneration, ExpiresAt: time.Now().Add(refreshed.ExpiresIn)}) + }); err != nil { + return "", err + } + claimActive = false + return refreshed.AccessToken, nil +} + +func (s *ZammadService) resolveStoredZammadOAuthAccessToken(ctx context.Context, connection *models.ZammadConnection, workspaceID int) (string, error) { + raw, _, err := s.credentials.ResolveManaged(ctx, connection.CredentialID, workspaceID, string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil { + return "", err + } + bundle, err := parseZammadOAuthCredential(raw) + if err != nil { + return "", err + } + return bundle.AccessToken, nil +} + +func (s *ZammadService) markOAuthReauthorizationRequired(connection *models.ZammadConnection, generation int64, claimOwner string) error { + pending := pendingZammadOAuthCredential("reauthorization_required") + credentialUpdate, err := s.credentials.PrepareManagedSecretUpdate(connection.CredentialID, pending, + string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil { + return err + } + return database.WithTx(s.db, func(tx database.Tx) error { + current, err := s.repo.GuardOAuthGenerationTx(tx, connection.ProviderID, generation) + if err != nil { + return err + } + if !current { + return ErrZammadOAuthSuperseded + } + owned, err := s.repo.GuardOAuthRefreshClaimTx(tx, connection.ProviderID, generation, claimOwner) + if err != nil { + return err + } + if !owned { + return ErrZammadOAuthSuperseded + } + if err := credentialUpdate(tx); err != nil { + return err + } + marked, err := s.repo.MarkOAuthReauthorizationRequiredTx(tx, connection.ProviderID, generation, claimOwner) + if err != nil { + return err + } + if !marked { + return ErrZammadOAuthSuperseded + } + return nil + }) +} + +func (s *ZammadService) TestConnection(ctx context.Context, id string) (*models.ZammadConnectionMetadata, error) { + connection, err := s.repo.GetConnection(id) + if err != nil { + return nil, err + } + workspaceID := 0 + if !connection.AppliesToAllWorkspaces && len(connection.WorkspaceIDs) > 0 { + workspaceID = connection.WorkspaceIDs[0] + } + client, err := s.clientForConnection(ctx, connection, workspaceID) + if err != nil { + return nil, err + } + metadata, err := zammadRuntimeMetadata(ctx, connection, client) + if err == nil { + // Least-privilege API-token and OAuth service accounts intentionally need + // neither admin.object nor admin.group. The configured group catalog and + // custom field therefore remain explicitly unverified. + metadata.GroupCatalogVerified = false + metadata.CorrelationFieldVerified = false + } + if err == nil { + err = validateZammadStates(connection, metadata) + } + safeError := "" + if err != nil { + safeError = RedactString(err.Error()) + } + _ = s.repo.SetConnectionTestResult(connection.ProviderID, time.Now(), safeError) + return metadata, err +} + +func (s *ZammadService) MetadataForWorkspace(ctx context.Context, id string, workspaceID int) (*models.ZammadConnectionMetadata, error) { + connection, client, err := s.client(ctx, id, workspaceID) + if err != nil { + return nil, err + } + return zammadRuntimeMetadata(ctx, connection, client) +} + +func (s *ZammadService) OwnersForWorkspace(ctx context.Context, id string, workspaceID, groupID int) ([]models.ZammadOwner, error) { + if groupID <= 0 { + return nil, zammadValidationError("group_id must be positive") + } + connection, client, err := s.client(ctx, id, workspaceID) + if err != nil { + return nil, err + } + if _, err := allowedZammadGroup(connection, groupID); err != nil { + return nil, err + } + owners, err := client.Owners(ctx, groupID) + if err != nil { + return nil, err + } + result := make([]models.ZammadOwner, 0, len(owners)+1) + result = append(result, models.ZammadOwner{ID: 1, Name: "Unassigned"}) + for _, owner := range owners { + if owner.ID != 1 { + result = append(result, models.ZammadOwner{ID: owner.ID, Name: owner.Name}) + } + } + return result, nil +} + +func (s *ZammadService) TicketLinksForItem(itemID int) ([]*models.ZammadTicketLink, error) { + return s.repo.GetTicketLinksForItem(itemID) +} + +func (s *ZammadService) GetTicketLink(id string) (*models.ZammadTicketLink, error) { + return s.repo.GetTicketLink(id) +} + +func (s *ZammadService) ResolveTicketLink(correlationKey string) (itemID, workspaceID int, err error) { + correlationKey = strings.TrimSpace(correlationKey) + if correlationKey == "" || len(correlationKey) > 512 { + return 0, 0, repository.ErrNotFound + } + providerAndItem, ok := strings.CutPrefix(correlationKey, "windshift:") + if !ok { + return 0, 0, repository.ErrNotFound + } + providerID, itemKey, ok := strings.Cut(providerAndItem, ":") + if !ok || providerID == "" || itemKey == "" { + return 0, 0, repository.ErrNotFound + } + return s.repo.GetItemDestinationByCorrelationKey(providerID, correlationKey) +} + +// LinkExistingTicket attaches a remote ticket without creating a second one. +// The local reservation is written before the remote correlation field so a +// competing item cannot claim the same provider/ticket pair. +func (s *ZammadService) LinkExistingTicket(ctx context.Context, itemID, actorID int, req models.LinkZammadTicketRequest) (*models.ZammadTicketLink, error) { + req.ConnectionID = strings.TrimSpace(req.ConnectionID) + req.TicketNumber = strings.TrimSpace(req.TicketNumber) + if req.ConnectionID == "" || req.TicketNumber == "" { + return nil, zammadValidationError("connection_id and ticket_number are required") + } + item, err := repository.NewItemRepository(s.db).FindByIDWithDetails(itemID) + if err != nil { + return nil, err + } + connection, client, err := s.client(ctx, req.ConnectionID, item.WorkspaceID) + if err != nil { + return nil, err + } + found, err := client.FindByNumber(ctx, req.TicketNumber) + if err != nil { + return nil, err + } + if found == nil { + return nil, zammadValidationError("Zammad ticket was not found") + } + ticket, err := client.GetTicket(ctx, found.ID) + if err != nil { + return nil, err + } + if ticket.Number != req.TicketNumber { + return nil, zammadValidationError("Zammad ticket was not found") + } + metadata, err := zammadRuntimeMetadata(ctx, connection, client) + if err != nil { + return nil, err + } + group, err := allowedZammadGroup(connection, ticket.GroupID) + if err != nil { + return nil, err + } + correlation := fmt.Sprintf("windshift:%s:%s-%d", connection.ProviderID, item.WorkspaceKey, item.WorkspaceItemNumber) + link, err := s.repo.GetTicketLinkForItem(itemID, connection.ProviderID) + if err != nil && !errors.Is(err, repository.ErrNotFound) { + return nil, err + } + if link != nil { + if link.TicketID != ticket.ID { + return nil, zammadValidationError("this item already has another Zammad ticket for the selected connection") + } + correlation = link.CorrelationKey + if link.SyncState == models.ZammadSyncLinked && link.ItemIntegrationLinkID != "" { + return link, nil + } + } + remoteCorrelation, err := zammadTicketAttributeString(ticket, connection.CorrelationField) + if err != nil { + return nil, err + } + if remoteCorrelation != "" && remoteCorrelation != correlation { + return nil, zammadValidationError("Zammad ticket is already linked through another correlation key") + } + if link == nil { + statusName := zammadStateName(metadata, ticket.StateID, ticket.StateName) + groupName := ticket.GroupName + if groupName == "" { + groupName = group.Name + } + link = &models.ZammadTicketLink{ + ID: uuid.New().String(), ItemID: itemID, ProviderID: connection.ProviderID, + TicketID: ticket.ID, TicketNumber: ticket.Number, + TicketURL: connection.BaseURL + "/#ticket/zoom/" + strconv.Itoa(ticket.ID), + GroupID: ticket.GroupID, GroupName: groupName, + OwnerID: ticket.OwnerID, OwnerName: ticket.OwnerName, + CorrelationKey: correlation, SyncState: models.ZammadSyncCreating, + LastStatusID: ticket.StateID, LastStatusName: statusName, CreatedBy: &actorID, + } + if err := s.reserveZammadTicketLink(connection, item, link, true); err != nil { + if !errors.Is(err, repository.ErrDuplicateEntry) { + return nil, err + } + existing, getErr := s.repo.GetTicketLinkForItem(itemID, connection.ProviderID) + if getErr != nil || existing.TicketID != ticket.ID { + return nil, err + } + link = existing + } + } + syncOwner := uuid.New().String() + claimed, err := s.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(zammadSyncLeaseDuration)) + if err != nil { + return nil, err + } + if !claimed { + return nil, ErrZammadConnectionBusy + } + defer func() { _ = s.repo.ReleaseSyncClaim(link.ID, syncOwner) }() + link, err = s.repo.GetTicketLink(link.ID) + if err != nil { + return nil, err + } + if link.SyncState == models.ZammadSyncLinked && link.ItemIntegrationLinkID != "" { + return link, nil + } + if link.TicketID <= 0 || link.TicketID != ticket.ID { + return nil, zammadValidationError("this item already has another Zammad ticket for the selected connection") + } + // Reservation and claim may have waited behind another request. Reload the + // current connection and remote ticket before deciding whether to write the + // correlation field. + connection, client, err = s.client(ctx, link.ProviderID, item.WorkspaceID) + if err != nil { + return nil, err + } + ticket, err = client.GetTicket(ctx, link.TicketID) + if err != nil { + return nil, err + } + metadata, err = zammadRuntimeMetadata(ctx, connection, client) + if err != nil { + return nil, err + } + group, err = allowedZammadGroup(connection, ticket.GroupID) + if err != nil { + return nil, err + } + remoteCorrelation, err = zammadTicketAttributeString(ticket, connection.CorrelationField) + if err != nil { + return nil, err + } + correlation = link.CorrelationKey + if remoteCorrelation != "" && remoteCorrelation != correlation { + return nil, zammadValidationError("Zammad ticket is already linked through another correlation key") + } + + if remoteCorrelation == "" { + if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil { + return nil, err + } + ticket, err = client.UpdateTicket(ctx, ticket.ID, nil, nil, nil, connection.CorrelationField, correlation) + if err != nil { + _ = s.repo.MarkTicketLinkSetupError(link.ID, syncOwner, RedactString(err.Error())) + return nil, err + } + } + link.TicketID = ticket.ID + link.TicketNumber = ticket.Number + link.TicketURL = connection.BaseURL + "/#ticket/zoom/" + strconv.Itoa(ticket.ID) + link.GroupID = ticket.GroupID + link.GroupName = zammadGroupName(metadata, ticket.GroupID, ticket.GroupName) + link.OwnerID = ticket.OwnerID + link.OwnerName = resolveZammadOwnerName(ctx, client, ticket) + link.LastStatusID = ticket.StateID + link.LastStatusName = zammadStateName(metadata, ticket.StateID, ticket.StateName) + if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil { + return nil, err + } + if err := s.completeExistingTicketLinkWithCurrentGroupPolicy(link, syncOwner, actorID); err != nil { + _ = s.repo.MarkTicketLinkSetupError(link.ID, syncOwner, RedactString(err.Error())) + return nil, s.mapZammadSyncClaimError(err) + } + return s.repo.GetTicketLink(link.ID) +} + +func (s *ZammadService) CreateTicket(ctx context.Context, itemID, actorID int, req models.CreateZammadTicketRequest) (*models.ZammadTicketLink, error) { + item, err := repository.NewItemRepository(s.db).FindByIDWithDetails(itemID) + if err != nil { + return nil, err + } + connection, err := s.connection(req.ConnectionID, item.WorkspaceID) + if err != nil { + return nil, err + } + var client *zammad.Client + groupID, groupName := req.GroupID, "" + if groupID == 0 { + groupID = connection.DefaultGroupID + } + group, err := allowedZammadGroup(connection, groupID) + if err != nil { + return nil, err + } + groupID, groupName = group.ID, group.Name + if strings.TrimSpace(groupName) == "" { + return nil, zammadValidationError("selected Zammad group needs a stored name before tickets can be created") + } + correlation := fmt.Sprintf("windshift:%s:%s-%d", connection.ProviderID, item.WorkspaceKey, item.WorkspaceItemNumber) + link, err := s.repo.GetTicketLinkForItem(itemID, connection.ProviderID) + if err != nil && !errors.Is(err, repository.ErrNotFound) { + return nil, err + } + if link == nil { + link = &models.ZammadTicketLink{ + ID: uuid.New().String(), ItemID: itemID, ProviderID: connection.ProviderID, + GroupID: groupID, GroupName: groupName, CorrelationKey: correlation, + SyncState: models.ZammadSyncPending, CreatedBy: &actorID, + } + if err := s.reserveZammadTicketLink(connection, item, link, false); err != nil && !errors.Is(err, repository.ErrDuplicateEntry) { + return nil, err + } + link, err = s.repo.GetTicketLinkForItem(itemID, connection.ProviderID) + if err != nil { + return nil, err + } + } + if link.TicketID != 0 { + return link, nil + } + syncOwner := uuid.New().String() + now := time.Now() + claimed, wasUncertain, err := s.repo.ClaimTicketCreation(link.ID, syncOwner, now, now.Add(zammadSyncLeaseDuration)) + if err != nil { + return nil, err + } + if !claimed { + return s.repo.GetTicketLinkForItem(itemID, connection.ProviderID) + } + defer func() { _ = s.repo.ReleaseSyncClaim(link.ID, syncOwner) }() + link, err = s.repo.GetTicketLink(link.ID) + if err != nil { + return nil, err + } + if link.TicketID != 0 { + return link, nil + } + // Once a durable creation attempt exists, retries keep its original + // destination and correlation key. This also covers an item moved to a + // different workspace between attempts. + groupID = link.GroupID + groupName = link.GroupName + correlation = link.CorrelationKey + connection, client, err = s.client(ctx, link.ProviderID, item.WorkspaceID) + if err != nil { + return nil, s.failZammadTicketCreationBeforePost(link.ID, syncOwner, wasUncertain, err) + } + if _, err := allowedZammadGroup(connection, groupID); err != nil { + return nil, s.failZammadTicketCreationBeforePost(link.ID, syncOwner, wasUncertain, err) + } + + ticket, requestErr := client.FindByCorrelation(ctx, connection.CorrelationField, correlation) + postAttempted := false + if requestErr == nil && ticket == nil && wasUncertain { + if err := s.repo.MarkTicketLinkUncertain(link.ID, syncOwner, "Zammad ticket creation outcome is uncertain; retry only searches by correlation key"); err != nil { + return nil, s.mapZammadSyncClaimError(err) + } + return s.repo.GetTicketLink(link.ID) + } + if requestErr == nil && ticket == nil { + postAttempted = true + title := truncateRunes(fmt.Sprintf("[%s-%d] %s", item.WorkspaceKey, item.WorkspaceItemNumber, item.Title), 200) + body := truncateRunes(strings.TrimSpace(item.Description), 20000) + if body == "" { + body = title + } + if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil { + return nil, err + } + ticket, requestErr = client.CreateTicket(ctx, title, body, connection.DefaultCustomer, + groupName, connection.CorrelationField, correlation) + } + if requestErr != nil { + safeError := RedactString(requestErr.Error()) + var markErr error + if wasUncertain || (postAttempted && zammadCreationOutcomeUncertain(requestErr)) { + markErr = s.repo.MarkTicketLinkUncertain(link.ID, syncOwner, safeError) + } else { + markErr = s.repo.MarkTicketLinkFailed(link.ID, syncOwner, safeError) + } + if markErr != nil { + return nil, s.mapZammadSyncClaimError(markErr) + } + return nil, requestErr + } + if ticket.GroupID == 0 { + ticket, requestErr = client.GetTicket(ctx, ticket.ID) + if requestErr != nil { + if err := s.repo.MarkTicketLinkUncertain(link.ID, syncOwner, RedactString(requestErr.Error())); err != nil { + return nil, s.mapZammadSyncClaimError(err) + } + return nil, requestErr + } + } + ticketGroupID := ticket.GroupID + allowedGroup, groupErr := s.requireAllowedTicketGroup(ctx, connection, client, ticket, nil) + if groupErr != nil { + if err := s.repo.MarkTicketLinkUncertain(link.ID, syncOwner, RedactString(groupErr.Error())); err != nil { + return nil, s.mapZammadSyncClaimError(err) + } + return nil, groupErr + } + statusName := ticket.StateName + if statusName == "" { + statusName = s.resolveStateName(ctx, client, ticket.StateID) + } + ticketGroupName := ticket.GroupName + if ticketGroupName == "" { + ticketGroupName = allowedGroup.Name + } + ticketURL := connection.BaseURL + "/#ticket/zoom/" + fmt.Sprintf("%d", ticket.ID) + if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil { + return nil, err + } + if err := s.completeTicketCreationWithCurrentGroupPolicy(link.ProviderID, link.ID, syncOwner, ticket.ID, ticket.Number, ticketURL, + ticket.StateID, statusName, ticketGroupID, ticketGroupName, ticket.OwnerID, ticket.OwnerName, actorID); err != nil { + // The remote ticket is known to exist. Keep retries search-only until + // the durable local association has been completed. + if markErr := s.repo.MarkTicketLinkUncertain(link.ID, syncOwner, RedactString(err.Error())); markErr != nil { + return nil, s.mapZammadSyncClaimError(markErr) + } + return nil, s.mapZammadSyncClaimError(err) + } + return s.repo.GetTicketLink(link.ID) +} + +func validateZammadStates(connection *models.ZammadConnection, metadata *models.ZammadConnectionMetadata) error { + activeStates := make(map[int]struct{}, len(metadata.States)) + for _, state := range metadata.States { + activeStates[state.ID] = struct{}{} + } + for _, stateID := range connection.ClosedStateIDs { + if _, ok := activeStates[stateID]; !ok { + return zammadValidationError(fmt.Sprintf("closed Zammad state %d is missing or inactive", stateID)) + } + } + return nil +} + +func (s *ZammadService) UpdateTicketLink(ctx context.Context, linkID string, req models.UpdateZammadTicketLinkRequest) (*models.ZammadTicketLink, error) { + if req.StateID == nil && req.GroupID == nil && req.OwnerID == nil { + return nil, zammadValidationError("at least one of state_id, group_id, or owner_id is required") + } + if (req.StateID != nil && *req.StateID <= 0) || (req.GroupID != nil && *req.GroupID <= 0) || (req.OwnerID != nil && *req.OwnerID <= 0) { + return nil, zammadValidationError("state_id, group_id, and owner_id must be positive") + } + link, err := s.repo.GetTicketLink(linkID) + if err != nil { + return nil, err + } + if link.TicketID <= 0 || link.ItemIntegrationLinkID == "" { + return nil, zammadValidationError("Zammad ticket link setup is incomplete") + } + syncOwner := uuid.New().String() + claimed, err := s.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(zammadSyncLeaseDuration)) + if err != nil { + return nil, err + } + if !claimed { + return nil, ErrZammadConnectionBusy + } + defer func() { _ = s.repo.ReleaseSyncClaim(link.ID, syncOwner) }() + // The caller's snapshot predates the claim. Reload after winning it so a + // completed closed-state episode cannot be replayed from stale state. + link, err = s.repo.GetTicketLink(link.ID) + if err != nil { + return nil, err + } + if link.TicketID <= 0 || link.ItemIntegrationLinkID == "" { + return nil, zammadValidationError("Zammad ticket link setup is incomplete") + } + item, err := repository.NewItemRepository(s.db).FindByIDWithDetails(link.ItemID) + if err != nil { + return nil, err + } + connection, client, err := s.client(ctx, link.ProviderID, item.WorkspaceID) + if err != nil { + return nil, err + } + current, err := client.GetTicket(ctx, link.TicketID) + if err != nil { + return nil, err + } + metadata, err := zammadRuntimeMetadata(ctx, connection, client) + if err != nil { + return nil, err + } + + effectiveGroupID := current.GroupID + if req.GroupID != nil { + effectiveGroupID = *req.GroupID + } + if _, err := allowedZammadGroup(connection, effectiveGroupID); err != nil { + return nil, err + } + if req.StateID != nil && !zammadStateExists(metadata, *req.StateID) { + return nil, zammadValidationError("selected Zammad state is missing or inactive") + } + + ownerID := req.OwnerID + if req.GroupID != nil && req.OwnerID == nil && *req.GroupID != current.GroupID { + // An owner may not have change access in the new group. Zammad's + // unassigned system user is the deterministic safe default. + unassigned := 1 + ownerID = &unassigned + } + if ownerID != nil && *ownerID != 1 { + owners, err := client.Owners(ctx, effectiveGroupID) + if err != nil { + return nil, err + } + if !slices.ContainsFunc(owners, func(owner zammad.Owner) bool { return owner.ID == *ownerID }) { + return nil, zammadValidationError("selected Zammad owner cannot be assigned tickets in this group") + } + } + + if s.updateBeforeRemoteWrite != nil { + s.updateBeforeRemoteWrite() + } + if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil { + return nil, err + } + currentConnection, err := s.repo.GetConnection(link.ProviderID) + if err != nil { + return nil, err + } + effectiveGroupName := zammadGroupName(metadata, effectiveGroupID, current.GroupName) + if !currentConnection.Enabled || !zammadConnectionAllowsGroupSnapshot(currentConnection, effectiveGroupID, effectiveGroupName) { + return nil, ErrZammadTicketGroupPolicyChanged + } + updated, err := client.UpdateTicket(ctx, link.TicketID, req.StateID, req.GroupID, ownerID, "", "") + if err != nil { + return nil, err + } + if err := s.persistTicketSnapshot(ctx, link, client, updated, metadata, syncOwner); err != nil { + return nil, err + } + return s.repo.GetTicketLink(link.ID) +} + +// UnlinkTicket removes the Windshift association but never deletes the Zammad +// ticket. The remote correlation field is cleared only when it still contains +// this link's exact value. Ambiguous upstream failures keep the local link so +// the user can safely retry. +func (s *ZammadService) UnlinkTicket(ctx context.Context, linkID string) (*models.ZammadTicketLink, error) { + link, err := s.repo.GetTicketLink(linkID) + if err != nil { + return nil, err + } + syncOwner := uuid.New().String() + claimed, err := s.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(zammadSyncLeaseDuration)) + if err != nil { + return nil, err + } + if !claimed { + return nil, ErrZammadConnectionBusy + } + defer func() { _ = s.repo.ReleaseSyncClaim(link.ID, syncOwner) }() + link, err = s.repo.GetTicketLink(link.ID) + if err != nil { + return nil, err + } + needsCorrelationLookup := link.TicketID == 0 && + (link.SyncState == models.ZammadSyncCreating || link.SyncState == models.ZammadSyncUncertain) + if link.TicketID > 0 || needsCorrelationLookup { + item, err := repository.NewItemRepository(s.db).FindByIDWithDetails(link.ItemID) + if err != nil { + return nil, err + } + connection, client, err := s.clientForUnlink(ctx, link.ProviderID, item.WorkspaceID) + if err != nil { + return nil, err + } + var ticket *zammad.Ticket + var getErr error + if link.TicketID > 0 { + ticket, getErr = client.GetTicket(ctx, link.TicketID) + } else { + ticket, getErr = client.FindByCorrelation(ctx, connection.CorrelationField, link.CorrelationKey) + } + switch { + case getErr != nil: + var apiErr *zammad.APIError + if link.TicketID == 0 || !errors.As(getErr, &apiErr) || apiErr.StatusCode != 404 { + return nil, getErr + } + case ticket == nil: + // An empty search cannot prove that an ambiguously created ticket does + // not exist. Keep the pinned correlation so a later search or explicit + // administrator decision can recover it safely. + return nil, zammadValidationError("cannot safely unlink an uncertain Zammad ticket while correlation search is empty") + default: + remoteCorrelation, err := zammadTicketAttributeString(ticket, connection.CorrelationField) + if err != nil { + return nil, err + } + if remoteCorrelation == link.CorrelationKey { + if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil { + return nil, err + } + if _, err := client.UpdateTicket(ctx, ticket.ID, nil, nil, nil, connection.CorrelationField, ""); err != nil { + return nil, err + } + } + } + } + if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil { + return nil, err + } + if err := s.repo.DeleteTicketLinkClaimed(link.ID, syncOwner); err != nil { + return nil, s.mapZammadSyncClaimError(err) + } + return link, nil +} + +// DetachTicketLinkLocally is an explicit administrator recovery path for +// upstream outages or permanently invalid credentials. It removes only the +// local typed and generic links. The remote correlation value may remain in +// Zammad and must be cleared there separately when the upstream system is +// available again. +func (s *ZammadService) DetachTicketLinkLocally(linkID string) (*models.ZammadTicketLink, error) { + link, err := s.repo.GetTicketLink(linkID) + if err != nil { + return nil, err + } + syncOwner := uuid.New().String() + claimed, err := s.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(zammadSyncLeaseDuration)) + if err != nil { + return nil, err + } + if !claimed { + return nil, ErrZammadConnectionBusy + } + deleted := false + defer func() { + if !deleted { + _ = s.repo.ReleaseSyncClaim(link.ID, syncOwner) + } + }() + + link, err = s.repo.GetTicketLink(link.ID) + if err != nil { + return nil, err + } + if err := s.repo.DeleteTicketLinkClaimed(link.ID, syncOwner); err != nil { + return nil, s.mapZammadSyncClaimError(err) + } + deleted = true + return link, nil +} + +func (s *ZammadService) SyncTicketLink(ctx context.Context, linkID string) (*models.ZammadTicketLink, error) { + link, err := s.repo.GetTicketLink(linkID) + if err != nil { + return nil, err + } + if link.TicketID == 0 || link.ItemIntegrationLinkID == "" { + return nil, zammadValidationError("Zammad ticket link setup is incomplete") + } + syncOwner := uuid.New().String() + claimed, err := s.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(zammadSyncLeaseDuration)) + if err != nil { + return nil, err + } + if !claimed { + return nil, ErrZammadConnectionBusy + } + return s.syncClaimedTicketLink(ctx, link, syncOwner) +} + +// RetryUncertainTicketCreation is an explicit administrator override after +// the remote system has been checked and confirmed not to contain the ticket. +func (s *ZammadService) RetryUncertainTicketCreation(ctx context.Context, linkID string, actorID int) (*models.ZammadTicketLink, error) { + link, err := s.repo.GetTicketLink(linkID) + if err != nil { + return nil, err + } + reset, err := s.repo.ResetUncertainTicketCreation(linkID) + if err != nil { + return nil, err + } + if !reset { + return nil, zammadValidationError("ticket creation is not awaiting an administrator decision") + } + return s.CreateTicket(ctx, link.ItemID, actorID, models.CreateZammadTicketRequest{ + ConnectionID: link.ProviderID, + GroupID: link.GroupID, + }) +} + +func (s *ZammadService) syncClaimedTicketLink(ctx context.Context, link *models.ZammadTicketLink, syncOwner string) (*models.ZammadTicketLink, error) { + defer func() { _ = s.repo.ReleaseSyncClaim(link.ID, syncOwner) }() + // The due-list/manual-refresh snapshot predates the sync claim. Reload after + // winning the claim so completion_applied and other idempotency state cannot + // be replayed from a stale worker snapshot. + current, err := s.repo.GetTicketLink(link.ID) + if err != nil { + return nil, err + } + link = current + item, itemErr := repository.NewItemRepository(s.db).FindByID(link.ItemID) + if itemErr != nil { + s.recordZammadSyncError(link, syncOwner, itemErr) + return nil, itemErr + } + connection, client, err := s.client(ctx, link.ProviderID, item.WorkspaceID) + if err != nil { + if errors.Is(err, ErrZammadOAuthRefreshInProgress) { + _ = s.repo.ReleaseSyncClaim(link.ID, syncOwner) + return nil, err + } + s.recordZammadSyncError(link, syncOwner, err) + return nil, err + } + if !connection.Enabled { + if err := s.repo.UpdateTicketLinkSync(link.ID, syncOwner, link.LastStatusID, link.LastStatusName, + link.GroupID, link.GroupName, link.OwnerID, link.OwnerName, "", time.Now(), false, false); err != nil { + return nil, s.mapZammadSyncClaimError(err) + } + return s.repo.GetTicketLink(link.ID) + } + ticket, err := client.GetTicket(ctx, link.TicketID) + if err != nil { + s.recordZammadSyncError(link, syncOwner, err) + return nil, err + } + allowedGroup, err := s.requireAllowedTicketGroup(ctx, connection, client, ticket, nil) + if err != nil { + s.recordZammadSyncError(link, syncOwner, err) + return nil, err + } + if ticket.GroupName == "" { + ticket.GroupName = allowedGroup.Name + } + var metadata *models.ZammadConnectionMetadata + needsStateName := ticket.StateName == "" && (ticket.StateID != link.LastStatusID || link.LastStatusName == "") + needsGroupName := ticket.GroupName == "" && (ticket.GroupID != link.GroupID || link.GroupName == "") + if needsStateName || needsGroupName { + metadata, err = zammadRuntimeMetadata(ctx, connection, client) + if err != nil { + s.recordZammadSyncError(link, syncOwner, err) + return nil, err + } + } + if err := s.persistTicketSnapshot(ctx, link, client, ticket, metadata, syncOwner); err != nil { + return nil, err + } + updated, err := s.repo.GetTicketLink(link.ID) + if err != nil { + return nil, err + } + PublishItemChange(updated.ItemID, ItemChangeZammad) + return updated, nil +} + +func (s *ZammadService) recordZammadSyncError(link *models.ZammadTicketLink, syncOwner string, err error) { + if updateErr := s.repo.UpdateTicketLinkSync(link.ID, syncOwner, link.LastStatusID, link.LastStatusName, + link.GroupID, link.GroupName, link.OwnerID, link.OwnerName, + RedactString(err.Error()), time.Now(), false, false); updateErr == nil { + PublishItemChange(link.ItemID, ItemChangeZammad) + } +} + +func (s *ZammadService) persistTicketSnapshot(ctx context.Context, link *models.ZammadTicketLink, client *zammad.Client, ticket *zammad.Ticket, metadata *models.ZammadConnectionMetadata, syncOwner string) error { + if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil { + return err + } + // A lease can expire without being taken over. Configuration may have + // changed during that gap, so reload both the idempotency state and policy + // only after renewal has made the operation exclusive again. + currentLink, err := s.repo.GetTicketLink(link.ID) + if err != nil { + return err + } + link = currentLink + connection, err := s.repo.GetConnection(link.ProviderID) + if err != nil { + return err + } + statusName := zammadStateName(metadata, ticket.StateID, ticket.StateName) + if statusName == "" && ticket.StateID == link.LastStatusID { + statusName = link.LastStatusName + } + groupName := zammadGroupName(metadata, ticket.GroupID, ticket.GroupName) + if groupName == "" && ticket.GroupID == link.GroupID { + groupName = link.GroupName + } + if !zammadConnectionAllowsGroupSnapshot(connection, ticket.GroupID, groupName) { + s.recordZammadSyncError(link, syncOwner, ErrZammadTicketGroupPolicyChanged) + return ErrZammadTicketGroupPolicyChanged + } + ownerName := resolveZammadOwnerName(ctx, client, ticket) + isClosed := slices.Contains(connection.ClosedStateIDs, ticket.StateID) + completionApplied := false + if connection.CompletionStatusID != nil && isClosed && !link.CompletionApplied { + if s.completionBeforeTransition != nil { + s.completionBeforeTransition() + } + // Renew and reload unconditionally at the last boundary before the local + // side effect. A worker may have been paused long enough for takeover or + // for policy changes even without the test hook being installed. + if err := s.renewZammadSyncClaim(link.ID, syncOwner); err != nil { + return err + } + link, err = s.repo.GetTicketLink(link.ID) + if err != nil { + return err + } + connection, err = s.repo.GetConnection(link.ProviderID) + if err != nil { + return err + } + if !zammadConnectionAllowsGroupSnapshot(connection, ticket.GroupID, groupName) { + s.recordZammadSyncError(link, syncOwner, ErrZammadTicketGroupPolicyChanged) + return ErrZammadTicketGroupPolicyChanged + } + isClosed = slices.Contains(connection.ClosedStateIDs, ticket.StateID) + if connection.CompletionStatusID != nil && isClosed && !link.CompletionApplied { + completionCommitted, completionErr := s.completeWindshiftItem(ctx, link, connection) + if completionErr != nil { + safeError := RedactString(completionErr.Error()) + updateErr := s.updateTicketLinkSyncWithCurrentGroupPolicy(link.ProviderID, link.ID, syncOwner, ticket.StateID, statusName, + ticket.GroupID, groupName, ticket.OwnerID, ownerName, + safeError, time.Now(), completionCommitted, completionCommitted) + if updateErr == nil { + PublishItemChange(link.ItemID, ItemChangeZammad) + } + return completionErr + } + completionApplied = true + } + } + setCompletionApplied := !isClosed || completionApplied + err = s.updateTicketLinkSyncWithCurrentGroupPolicy(link.ProviderID, link.ID, syncOwner, ticket.StateID, statusName, + ticket.GroupID, groupName, ticket.OwnerID, ownerName, + "", time.Now(), setCompletionApplied, completionApplied) + if errors.Is(err, ErrZammadTicketGroupPolicyChanged) { + s.recordZammadSyncError(link, syncOwner, err) + } + return s.mapZammadSyncClaimError(err) +} + +func (s *ZammadService) updateTicketLinkSyncWithCurrentGroupPolicy(providerID, linkID, syncOwner string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName, safeError string, now time.Time, setCompletionApplied, completionApplied bool) error { + if s.persistBeforeLock != nil { + s.persistBeforeLock() + } + return database.WithTx(s.db, func(tx database.Tx) error { + if err := s.repo.LockConnectionTx(tx, providerID); err != nil { + return err + } + if err := s.requireCurrentZammadGroupTx(tx, providerID, groupID, groupName); err != nil { + return err + } + return s.repo.UpdateTicketLinkSyncTx(tx, linkID, syncOwner, statusID, statusName, groupID, groupName, + ownerID, ownerName, safeError, now, setCompletionApplied, completionApplied) + }) +} + +func (s *ZammadService) renewZammadSyncClaim(linkID, syncOwner string) error { + renewed, err := s.repo.RenewSyncClaim(linkID, syncOwner, time.Now().Add(zammadSyncLeaseDuration)) + if err != nil { + return s.mapZammadSyncClaimError(err) + } + if !renewed { + return ErrZammadConnectionBusy + } + return nil +} + +func (s *ZammadService) mapZammadSyncClaimError(err error) error { + if errors.Is(err, repository.ErrConcurrentUpdate) { + return ErrZammadConnectionBusy + } + return err +} + +func (s *ZammadService) failZammadTicketCreationBeforePost(linkID, syncOwner string, wasUncertain bool, cause error) error { + var err error + if wasUncertain { + err = s.repo.MarkTicketLinkUncertain(linkID, syncOwner, RedactString(cause.Error())) + } else { + err = s.repo.MarkTicketLinkFailed(linkID, syncOwner, RedactString(cause.Error())) + } + if err != nil { + return s.mapZammadSyncClaimError(err) + } + return cause +} + +func (s *ZammadService) completeTicketCreationWithCurrentGroupPolicy(providerID, linkID, syncOwner string, ticketID int, number, ticketURL string, statusID int, statusName string, groupID int, groupName string, ownerID int, ownerName string, linkedBy int) error { + if s.persistBeforeLock != nil { + s.persistBeforeLock() + } + return database.WithTx(s.db, func(tx database.Tx) error { + if err := s.repo.LockConnectionTx(tx, providerID); err != nil { + return err + } + if err := s.requireCurrentZammadGroupTx(tx, providerID, groupID, groupName); err != nil { + return err + } + return s.repo.CompleteTicketCreationTx(tx, linkID, syncOwner, ticketID, number, ticketURL, statusID, statusName, + groupID, groupName, ownerID, ownerName, linkedBy) + }) +} + +func (s *ZammadService) completeExistingTicketLinkWithCurrentGroupPolicy(link *models.ZammadTicketLink, syncOwner string, linkedBy int) error { + if s.persistBeforeLock != nil { + s.persistBeforeLock() + } + return database.WithTx(s.db, func(tx database.Tx) error { + if err := s.repo.LockConnectionTx(tx, link.ProviderID); err != nil { + return err + } + if err := s.requireCurrentZammadGroupTx(tx, link.ProviderID, link.GroupID, link.GroupName); err != nil { + return err + } + return s.repo.CompleteExistingTicketLinkTx(tx, link.ID, syncOwner, link.ID+"-external", link, linkedBy) + }) +} + +func (s *ZammadService) requireCurrentZammadGroupTx(tx database.Tx, providerID string, groupID int, groupName string) error { + defaultGroupID, defaultGroupName, allowedGroups, err := s.repo.ConnectionGroupPolicyTx(tx, providerID) + if err != nil { + return err + } + if !zammadConnectionAllowsGroupSnapshot(&models.ZammadConnection{ + DefaultGroupID: defaultGroupID, DefaultGroupName: defaultGroupName, AllowedGroups: allowedGroups, + }, groupID, groupName) { + return ErrZammadTicketGroupPolicyChanged + } + return nil +} + +func resolveZammadOwnerName(ctx context.Context, client *zammad.Client, ticket *zammad.Ticket) string { + if ticket == nil || ticket.OwnerID <= 0 { + return "" + } + if ticket.OwnerName != "" { + return ticket.OwnerName + } + if ticket.OwnerID == 1 { + return "Unassigned" + } + owners, err := client.Owners(ctx, ticket.GroupID) + if err != nil { + return "" + } + for _, owner := range owners { + if owner.ID == ticket.OwnerID { + return owner.Name + } + } + return "" +} + +func allowedZammadGroups(connection *models.ZammadConnection) []models.ZammadGroup { + refs := effectiveZammadGroupRefs(connection) + if len(refs) == 0 && connection.DefaultGroupID > 0 { + refs = []models.ZammadGroupRef{{ID: connection.DefaultGroupID, Name: connection.DefaultGroupName}} + } + allowed := make([]models.ZammadGroup, 0, len(refs)) + for _, group := range refs { + allowed = append(allowed, models.ZammadGroup{ID: group.ID, Name: group.Name, Active: true}) + } + return allowed +} + +func allowedZammadGroup(connection *models.ZammadConnection, groupID int) (models.ZammadGroup, error) { + for _, group := range allowedZammadGroups(connection) { + if group.ID == groupID { + return group, nil + } + } + return models.ZammadGroup{}, zammadValidationError("selected Zammad group is missing, inactive, or not allowed for this connection") +} + +func (s *ZammadService) requireAllowedTicketGroup(_ context.Context, connection *models.ZammadConnection, _ *zammad.Client, ticket *zammad.Ticket, _ []models.ZammadGroup) (models.ZammadGroup, error) { + if ticket == nil || ticket.GroupID <= 0 { + return models.ZammadGroup{}, zammadValidationError("Zammad ticket group is missing or invalid") + } + return allowedZammadGroup(connection, ticket.GroupID) +} + +func zammadRuntimeMetadata(ctx context.Context, connection *models.ZammadConnection, client *zammad.Client) (*models.ZammadConnectionMetadata, error) { + states, err := client.States(ctx) + if err != nil { + return nil, err + } + return &models.ZammadConnectionMetadata{Groups: allowedZammadGroups(connection), States: states}, nil +} + +func zammadCreationOutcomeUncertain(err error) bool { + var upstreamErr *zammad.UpstreamError + if errors.As(err, &upstreamErr) { + return true + } + var apiErr *zammad.APIError + if !errors.As(err, &apiErr) { + return false + } + return apiErr.StatusCode == http.StatusRequestTimeout || apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError +} + +func zammadStateExists(metadata *models.ZammadConnectionMetadata, stateID int) bool { + return metadata != nil && slices.ContainsFunc(metadata.States, func(state models.ZammadState) bool { + return state.ID == stateID && state.Active + }) +} + +func zammadStateName(metadata *models.ZammadConnectionMetadata, stateID int, fallback string) string { + if fallback != "" { + return fallback + } + if metadata != nil { + for _, state := range metadata.States { + if state.ID == stateID { + return state.Name + } + } + } + return "" +} + +func zammadGroupName(metadata *models.ZammadConnectionMetadata, groupID int, fallback string) string { + if fallback != "" { + return fallback + } + if metadata != nil { + for _, group := range metadata.Groups { + if group.ID == groupID { + return group.Name + } + } + } + return "" +} + +func zammadTicketAttributeString(ticket *zammad.Ticket, field string) (string, error) { + if ticket == nil || ticket.Attributes == nil { + return "", nil + } + raw, ok := ticket.Attributes[field] + if !ok || string(raw) == "null" { + return "", nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", &zammad.UpstreamError{Cause: errors.New("zammad correlation field is not a string")} + } + return strings.TrimSpace(value), nil +} + +func (s *ZammadService) SyncDue(ctx context.Context, limit int) error { + if limit <= 0 { + limit = 50 + } + // The scheduler interval controls the polling cadence. Applying the same + // age threshold here caused a freshly synchronized link to miss the next + // tick and made the effective cadence roughly twice as long. + links, err := s.repo.ListDueTicketLinks(time.Now(), limit) + if err != nil { + return err + } + var firstError error + for _, link := range links { + syncOwner := uuid.New().String() + claimed, claimErr := s.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(zammadSyncLeaseDuration)) + if claimErr != nil || !claimed { + if claimErr != nil && firstError == nil { + firstError = claimErr + } + continue + } + if _, syncErr := s.syncClaimedTicketLink(ctx, link, syncOwner); syncErr != nil && firstError == nil { + firstError = syncErr + } + } + return firstError +} + +// SyncAllTicketLinks performs one explicit, system-wide refresh of every +// complete link covered by an enabled, authorized connection. Per-link +// failures are summarized so one broken upstream ticket does not prevent the +// remaining links from being refreshed. +func (s *ZammadService) SyncAllTicketLinks(ctx context.Context) (ZammadSyncSummary, error) { + links, err := s.repo.ListSyncableTicketLinks() + if err != nil { + return ZammadSyncSummary{}, err + } + summary := ZammadSyncSummary{Selected: len(links)} + for _, link := range links { + if err := ctx.Err(); err != nil { + return summary, err + } + syncOwner := uuid.New().String() + claimed, claimErr := s.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(zammadSyncLeaseDuration)) + if claimErr != nil { + summary.Failed++ + continue + } + if !claimed { + summary.Skipped++ + continue + } + if _, syncErr := s.syncClaimedTicketLink(ctx, link, syncOwner); syncErr != nil { + summary.Failed++ + continue + } + summary.Succeeded++ + } + return summary, nil +} + +func (s *ZammadService) completeWindshiftItem(ctx context.Context, link *models.ZammadTicketLink, connection *models.ZammadConnection) (bool, error) { + if connection.CompletionStatusID == nil { + return false, nil + } + if connection.CreatedBy == nil { + return false, errors.New("configured Zammad actor no longer exists") + } + itemRepo := repository.NewItemRepository(s.db) + item, err := itemRepo.FindByIDWithDetails(link.ItemID) + if err != nil { + return false, err + } + allowed, err := s.permission.HasWorkspacePermission(*connection.CreatedBy, item.WorkspaceID, models.PermissionItemEdit) + if err != nil { + return false, err + } + if !allowed { + return false, errors.New("configured Zammad actor no longer has item edit permission") + } + result, err := s.workflow.PerformTransition(ctx, PerformTransitionRequest{ + ItemID: link.ItemID, ToStatusID: *connection.CompletionStatusID, + ActorUserID: *connection.CreatedBy, + EventMetadata: func() itemevents.Metadata { + metadata := itemevents.Integration(connection.ProviderID, "zammad_sync") + metadata.SourceRef = fmt.Sprintf("ticket:%d/link:%s", link.TicketID, link.ID) + metadata.CorrelationID = link.CorrelationKey + return metadata + }(), + }, itemRepo, s.condition, s.approval) + if err != nil && result == nil { + return false, err + } + if !result.NoOp && s.events != nil { + s.events.EmitStatusChanged(result.Item, result.OldStatusID, result.NewStatusID, *connection.CreatedBy, "Zammad") + } + return true, err +} + +func (s *ZammadService) client(ctx context.Context, id string, workspaceID int) (*models.ZammadConnection, *zammad.Client, error) { + connection, err := s.connection(id, workspaceID) + if err != nil { + return nil, nil, err + } + client, err := s.clientForConnection(ctx, connection, workspaceID) + if err != nil { + return nil, nil, err + } + return connection, client, nil +} + +func (s *ZammadService) connection(id string, workspaceID int) (*models.ZammadConnection, error) { + connection, err := s.repo.GetConnection(id) + if err != nil { + return nil, err + } + available, err := s.repo.IsConnectionAvailableToWorkspace(id, workspaceID) + if err != nil { + return nil, err + } + if !available { + return nil, ErrCredentialScopeMismatch + } + return connection, nil +} + +// clientForUnlink permits cleanup through a disabled connection while keeping +// its workspace and managed-credential boundaries intact. +func (s *ZammadService) clientForUnlink(ctx context.Context, id string, workspaceID int) (*models.ZammadConnection, *zammad.Client, error) { + connection, err := s.repo.GetConnection(id) + if err != nil { + return nil, nil, err + } + scoped, err := s.repo.IsConnectionScopedToWorkspace(id, workspaceID) + if err != nil { + return nil, nil, err + } + if !scoped { + return nil, nil, ErrCredentialScopeMismatch + } + client, err := s.clientForConnection(ctx, connection, workspaceID) + if err != nil { + return nil, nil, err + } + return connection, client, nil +} + +func (s *ZammadService) clientForConnection(ctx context.Context, connection *models.ZammadConnection, workspaceID int) (*zammad.Client, error) { + transport := s.transportOverride + if transport == nil { + transport = newZammadSafeTransport(connection.BaseURL, "/api/v1/") + } + if connection.AuthMethod == models.ZammadAuthMethodOAuth { + token, err := s.oauthAccessToken(ctx, connection, workspaceID) + if err != nil { + return nil, err + } + return zammad.NewOAuthClient(connection.BaseURL, token, transport), nil + } + if connection.CredentialID <= 0 { + return nil, zammadValidationError("API token is not configured") + } + token, _, err := s.credentials.ResolveManaged(ctx, connection.CredentialID, workspaceID, string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil { + return nil, err + } + return zammad.NewClient(connection.BaseURL, token, transport), nil +} + +func (s *ZammadService) resolveStateName(ctx context.Context, client *zammad.Client, stateID int) string { + states, err := client.States(ctx) + if err != nil { + return "" + } + for _, state := range states { + if state.ID == stateID { + return state.Name + } + } + return "" +} + +func validateNewZammadConnection(req models.CreateZammadConnectionRequest, actorID int) (*models.ZammadConnection, error) { + if hasNonPositiveIDs(req.ClosedStateIDs) { + return nil, zammadValidationError("closed_state_ids must contain positive IDs") + } + if err := validateZammadGroupRefs(req.AllowedGroups); err != nil { + return nil, err + } + if hasNonPositiveIDs(req.AllowedGroupIDs) { + return nil, zammadValidationError("allowed_group_ids must contain positive IDs") + } + if hasNonPositiveIDs(req.WorkspaceIDs) { + return nil, zammadValidationError("workspace_ids must contain positive IDs") + } + baseURL, err := NormalizeZammadBaseURL(req.BaseURL) + if err != nil { + return nil, err + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + appliesAll := false + if req.AppliesToAllWorkspaces != nil { + appliesAll = *req.AppliesToAllWorkspaces + } + correlationField := strings.TrimSpace(req.CorrelationField) + if correlationField == "" { + correlationField = "windshift_item_key" + } + connection := &models.ZammadConnection{ + ProviderID: uuid.New().String(), Slug: strings.TrimSpace(req.Slug), + Name: strings.TrimSpace(req.Name), Enabled: enabled, BaseURL: baseURL, + DefaultGroupID: req.DefaultGroupID, DefaultGroupName: strings.TrimSpace(req.DefaultGroupName), + AllowedGroups: normalizeZammadGroupRefs(req.AllowedGroups), + DefaultCustomer: strings.TrimSpace(req.DefaultCustomer), CorrelationField: correlationField, + ClosedStateIDs: normalizePositiveIDs(req.ClosedStateIDs), CompletionStatusID: req.CompletionStatusID, + AppliesToAllWorkspaces: appliesAll, WorkspaceIDs: normalizePositiveIDs(req.WorkspaceIDs), + CreatedBy: &actorID, + } + if len(connection.AllowedGroups) == 0 && len(req.AllowedGroupIDs) > 0 { + connection.AllowedGroups = legacyZammadGroupRefs(req.AllowedGroupIDs, connection.DefaultGroupID, connection.DefaultGroupName) + } + syncZammadDefaultGroupName(connection) + connection.AuthMethod = req.AuthMethod + if connection.AuthMethod == "" { + connection.AuthMethod = models.ZammadAuthMethodAPIToken + } + switch connection.AuthMethod { + case models.ZammadAuthMethodOAuth: + connection.OAuthClientID = strings.TrimSpace(req.OAuthClientID) + if connection.OAuthClientID == "" || strings.TrimSpace(req.OAuthClientSecret) == "" { + return nil, zammadValidationError("oauth_client_id and oauth_client_secret are required for OAuth") + } + case models.ZammadAuthMethodAPIToken: + if strings.TrimSpace(req.APIToken) == "" { + return nil, zammadValidationError("Zammad API token is required") + } + default: + return nil, zammadValidationError("auth_method must be api_token or oauth") + } + if err := validateZammadConnection(connection); err != nil { + return nil, err + } + return connection, nil +} + +func validateZammadConnection(connection *models.ZammadConnection) error { + if connection.Name == "" || connection.Slug == "" { + return zammadValidationError("name and slug are required") + } + if !zammadSlugPattern.MatchString(connection.Slug) { + return zammadValidationError("slug must start with a letter and contain only lowercase letters, numbers, hyphens, and underscores") + } + if connection.DefaultCustomer == "" { + return zammadValidationError("default_customer is required") + } + if connection.DefaultGroupID < 0 { + return zammadValidationError("default_group_id must be positive") + } + if connection.DefaultGroupID == 0 && connection.DefaultGroupName == "" { + return zammadValidationError("default_group_id or default_group_name is required") + } + allowedGroups := effectiveZammadGroupRefs(connection) + if connection.DefaultGroupID > 0 && len(allowedGroups) > 0 { + defaultGroup, ok := zammadGroupRefByID(allowedGroups, connection.DefaultGroupID) + if !ok { + return zammadValidationError("default Zammad group must be included in allowed_groups") + } + if connection.DefaultGroupName != "" && defaultGroup.Name != "" && connection.DefaultGroupName != defaultGroup.Name { + return zammadValidationError("default Zammad group name must match allowed_groups") + } + } + if connection.CompletionStatusID != nil && *connection.CompletionStatusID <= 0 { + return zammadValidationError("completion_status_id must be positive") + } + if !zammadFieldNamePattern.MatchString(connection.CorrelationField) { + return zammadValidationError("correlation_field is not a valid Zammad object field name") + } + if !connection.AppliesToAllWorkspaces && len(connection.WorkspaceIDs) == 0 { + return zammadValidationError("at least one workspace is required") + } + return nil +} + +func validateZammadOAuthConfiguration(connection *models.ZammadConnection) error { + if connection.AuthMethod != models.ZammadAuthMethodOAuth { + return nil + } + if strings.TrimSpace(connection.OAuthClientID) == "" || strings.TrimSpace(connection.OAuthClientSecretEncrypted) == "" { + return zammadValidationError("OAuth client credentials are not configured") + } + return nil +} + +func NormalizeZammadBaseURL(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.Opaque != "" { + return "", zammadValidationError("invalid Zammad base URL") + } + if parsed.Scheme != "https" { + return "", zammadValidationError("Zammad base URL must use HTTPS") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", zammadValidationError("Zammad base URL must not contain credentials, a query, or a fragment") + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + parsed.RawPath = strings.TrimRight(parsed.RawPath, "/") + return strings.TrimRight(parsed.String(), "/"), nil +} + +func zammadOAuthRedirectURI(publicBaseURL string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(publicBaseURL)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Opaque != "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", zammadValidationError("Zammad OAuth requires an absolute HTTPS public base URL") + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/api/integrations/oauth/system/zammad/callback" + parsed.RawPath = "" + return parsed.String(), nil +} + +func normalizePositiveIDs(ids []int) []int { + seen := map[int]struct{}{} + out := make([]int, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func validateZammadGroupRefs(groups []models.ZammadGroupRef) error { + seen := make(map[int]struct{}, len(groups)) + for _, group := range groups { + if group.ID <= 0 { + return zammadValidationError("allowed_groups must contain positive IDs") + } + if strings.TrimSpace(group.Name) == "" { + return zammadValidationError("allowed_groups must contain a name for every group") + } + if _, exists := seen[group.ID]; exists { + return zammadValidationError("allowed_groups must not contain duplicate IDs") + } + seen[group.ID] = struct{}{} + } + return nil +} + +func normalizeZammadGroupRefs(groups []models.ZammadGroupRef) []models.ZammadGroupRef { + seen := make(map[int]struct{}, len(groups)) + out := make([]models.ZammadGroupRef, 0, len(groups)) + for _, group := range groups { + if group.ID <= 0 { + continue + } + if _, exists := seen[group.ID]; exists { + continue + } + seen[group.ID] = struct{}{} + out = append(out, models.ZammadGroupRef{ID: group.ID, Name: strings.TrimSpace(group.Name)}) + } + return out +} + +func legacyZammadGroupRefs(groupIDs []int, defaultGroupID int, defaultGroupName string) []models.ZammadGroupRef { + groups := make([]models.ZammadGroupRef, 0, len(groupIDs)) + for _, groupID := range normalizePositiveIDs(groupIDs) { + name := "" + if groupID == defaultGroupID { + name = strings.TrimSpace(defaultGroupName) + } + groups = append(groups, models.ZammadGroupRef{ID: groupID, Name: name}) + } + return groups +} + +func effectiveZammadGroupRefs(connection *models.ZammadConnection) []models.ZammadGroupRef { + if connection == nil { + return nil + } + return connection.AllowedGroups +} + +func effectiveZammadSnapshotGroupRefs(snapshot *repository.ZammadConnectionMutationSnapshot) []models.ZammadGroupRef { + if snapshot == nil { + return nil + } + return snapshot.AllowedGroups +} + +func syncZammadDefaultGroupName(connection *models.ZammadConnection) { + if connection == nil || connection.DefaultGroupID <= 0 { + return + } + if group, ok := zammadGroupRefByID(effectiveZammadGroupRefs(connection), connection.DefaultGroupID); ok && strings.TrimSpace(group.Name) != "" { + connection.DefaultGroupName = strings.TrimSpace(group.Name) + } +} + +func zammadGroupRefByID(groups []models.ZammadGroupRef, groupID int) (models.ZammadGroupRef, bool) { + for _, group := range groups { + if group.ID == groupID { + return group, true + } + } + return models.ZammadGroupRef{}, false +} + +func hasNonPositiveIDs(ids []int) bool { + for _, id := range ids { + if id <= 0 { + return true + } + } + return false +} + +func truncateRunes(value string, limit int) string { + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} + +func boolPointer(value bool) *bool { return &value } diff --git a/internal/services/zammad_service_test.go b/internal/services/zammad_service_test.go new file mode 100644 index 000000000..0ec8f32a4 --- /dev/null +++ b/internal/services/zammad_service_test.go @@ -0,0 +1,3295 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + "windshift/internal/database" + "windshift/internal/integrations/zammad" + "windshift/internal/models" + "windshift/internal/repository" + "windshift/internal/sso" + "windshift/internal/utils" +) + +type fakeZammadTransport struct { + mu sync.Mutex + requests int + posts int + puts int + ticket map[string]any + postErrorAfterCreate error + postStatusAfterCreate int + postResponse map[string]any + putError error + searchError error + getStatus int + getTicket map[string]any + hideSearch bool + groups []map[string]any + states []map[string]any + users []map[string]any + putPayloads []map[string]any + createGroupID int + putRerouteGroupID int +} + +func zammadTestGroupRefs(ids ...int) []models.ZammadGroupRef { + groups := make([]models.ZammadGroupRef, 0, len(ids)) + for _, id := range ids { + name := "Support" + if id != 7 { + name = "Escalations" + } + groups = append(groups, models.ZammadGroupRef{ID: id, Name: name}) + } + return groups +} + +func (f *fakeZammadTransport) Do(_ context.Context, method, targetURL string, body []byte, headers map[string]string) (*zammad.Response, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.requests++ + if headers["Authorization"] != "Token token=synthetic-zammad-token" { + return nil, errors.New("unexpected authorization header") + } + parsed, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + switch { + case parsed.Path == "/api/v1/tickets/search": + if f.searchError != nil { + err := f.searchError + f.searchError = nil + return nil, err + } + if f.ticket == nil || f.hideSearch { + return jsonResponse(http.StatusOK, []any{}), nil + } + return jsonResponse(http.StatusOK, []any{f.ticket}), nil + case parsed.Path == "/api/v1/tickets" && method == http.MethodPost: + f.posts++ + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + createGroupID := f.createGroupID + if createGroupID == 0 { + createGroupID = 7 + } + f.ticket = map[string]any{ + "id": 901, "number": "420901", "group_id": createGroupID, + "state_id": 2, "state": "open", + "windshift_item_key": payload["windshift_item_key"], + } + if f.postErrorAfterCreate != nil { + err := f.postErrorAfterCreate + f.postErrorAfterCreate = nil + return nil, err + } + if f.postStatusAfterCreate != 0 { + status := f.postStatusAfterCreate + f.postStatusAfterCreate = 0 + return jsonResponse(status, map[string]string{"error": "synthetic create failure"}), nil + } + if f.postResponse != nil { + return jsonResponse(http.StatusCreated, f.postResponse), nil + } + return jsonResponse(http.StatusCreated, f.ticket), nil + case strings.HasPrefix(parsed.Path, "/api/v1/tickets/") && method == http.MethodPut: + f.puts++ + if f.putError != nil { + return nil, f.putError + } + payload := map[string]any{} + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + f.putPayloads = append(f.putPayloads, payload) + if f.ticket != nil { + for key, value := range payload { + f.ticket[key] = value + } + if f.putRerouteGroupID > 0 { + f.ticket["group_id"] = f.putRerouteGroupID + } + } + return jsonResponse(http.StatusOK, f.ticket), nil + case strings.HasPrefix(parsed.Path, "/api/v1/tickets/"): + if f.getStatus != 0 { + return jsonResponse(f.getStatus, map[string]string{"error": "synthetic remote detail"}), nil + } + if f.getTicket != nil { + return jsonResponse(http.StatusOK, f.getTicket), nil + } + return jsonResponse(http.StatusOK, f.ticket), nil + case parsed.Path == "/api/v1/ticket_states": + if f.states != nil { + return jsonResponse(http.StatusOK, f.states), nil + } + return jsonResponse(http.StatusOK, []map[string]any{{"id": 2, "name": "open", "active": true}, {"id": 4, "name": "closed", "active": true}}), nil + case parsed.Path == "/api/v1/users/search": + return jsonResponse(http.StatusOK, f.users), nil + case parsed.Path == "/api/v1/object_manager_attributes": + return jsonResponse(http.StatusOK, []map[string]any{{"name": "windshift_item_key", "object": "Ticket", "active": true}}), nil + default: + return jsonResponse(http.StatusNotFound, map[string]string{"error": "not found"}), nil + } +} + +func (f *fakeZammadTransport) counts() (requests, posts int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.requests, f.posts +} + +func (f *fakeZammadTransport) putCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.puts +} + +func (f *fakeZammadTransport) lastPutPayload() map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.putPayloads) == 0 { + return nil + } + return f.putPayloads[len(f.putPayloads)-1] +} + +func (f *fakeZammadTransport) resetRequests() { + f.mu.Lock() + defer f.mu.Unlock() + f.requests = 0 +} + +func jsonResponse(status int, value any) *zammad.Response { + body, _ := json.Marshal(value) + return &zammad.Response{StatusCode: status, Body: body} +} + +type allowZammadPermission struct{} + +func (allowZammadPermission) HasWorkspacePermission(_, _ int, _ string) (bool, error) { + return true, nil +} + +type recordingItemChangePublisher struct { + itemID int + kind ItemChangeKind +} + +func (p *recordingItemChangePublisher) PublishItemChange(itemID int, kind ItemChangeKind) { + p.itemID = itemID + p.kind = kind +} + +type fakeZammadWorkflow struct { + db database.Database + writes int + postCommitErr error +} + +func (f *fakeZammadWorkflow) PerformTransition(_ context.Context, req PerformTransitionRequest, itemRepo *repository.ItemRepository, _ *ConditionService, _ transitionApprovalService) (*PerformTransitionResult, error) { + item, err := itemRepo.FindByIDWithDetails(req.ItemID) + if err != nil { + return nil, err + } + if item.StatusID != nil && *item.StatusID == req.ToStatusID { + return &PerformTransitionResult{Item: item, OldStatusID: item.StatusID, NewStatusID: item.StatusID, NoOp: true}, nil + } + oldStatusID := item.StatusID + if _, err := f.db.ExecWrite("UPDATE items SET status_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", req.ToStatusID, req.ItemID); err != nil { + return nil, err + } + f.writes++ + item, err = itemRepo.FindByIDWithDetails(req.ItemID) + if err != nil { + return nil, err + } + newStatusID := req.ToStatusID + result := &PerformTransitionResult{Item: item, OldStatusID: oldStatusID, NewStatusID: &newStatusID} + if f.postCommitErr != nil { + return result, f.postCommitErr + } + return result, nil +} + +type zammadServiceFixture struct { + t *testing.T + db database.Database + service *ZammadService + credentials *ActionCredentialService + transport *fakeZammadTransport + connection *models.ZammadConnection + workspace1 int + workspace2 int + item1 int + item2 int + actorID int + openStatus int + doneStatus int +} + +func newZammadServiceFixture(t *testing.T, workflow zammadWorkflowTransitioner) *zammadServiceFixture { + t.Helper() + db, err := database.NewSQLiteDB(t.TempDir() + "/windshift.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Initialize(); err != nil { + t.Fatal(err) + } + f := &zammadServiceFixture{t: t, db: db, transport: &fakeZammadTransport{}} + f.actorID = mustInsertID(t, db, `INSERT INTO users (email, username, first_name, last_name) VALUES (?, ?, ?, ?)`, "agent@example.test", "zammad-agent", "Zammad", "Agent") + f.workspace1 = mustInsertID(t, db, `INSERT INTO workspaces (name, key) VALUES (?, ?)`, "Primary", "PRI") + f.workspace2 = mustInsertID(t, db, `INSERT INTO workspaces (name, key) VALUES (?, ?)`, "Other", "OTH") + if err := db.QueryRow("SELECT id FROM statuses ORDER BY id LIMIT 1").Scan(&f.openStatus); err != nil { + t.Fatal(err) + } + if err := db.QueryRow("SELECT id FROM statuses ORDER BY id DESC LIMIT 1").Scan(&f.doneStatus); err != nil { + t.Fatal(err) + } + f.item1 = mustInsertID(t, db, `INSERT INTO items (workspace_id, workspace_item_number, title, description, frac_index, status_id, creator_id, last_active_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, f.workspace1, 49, "Synthetic ticket source", "Synthetic description", "a0", f.openStatus, f.actorID) + f.item2 = mustInsertID(t, db, `INSERT INTO items (workspace_id, workspace_item_number, title, description, frac_index, status_id, creator_id, last_active_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, f.workspace2, 1, "Out of scope", "Synthetic description", "a1", f.openStatus, f.actorID) + credentialService := NewActionCredentialService(repository.NewActionCredentialRepository(db), "synthetic-server-secret-for-zammad-tests") + f.credentials = credentialService + if workflow == nil { + workflow = &fakeZammadWorkflow{db: db} + } + f.service = NewZammadService(db, repository.NewZammadRepository(db), credentialService, allowZammadPermission{}, workflow, nil, nil) + f.service.SetTransportForTesting(f.transport) + f.connection, err = f.service.CreateConnection(models.CreateZammadConnectionRequest{ + Slug: "helpdesk", Name: "Synthetic helpdesk", BaseURL: "https://zammad.example.test", + APIToken: "synthetic-zammad-token", DefaultGroupID: 7, DefaultGroupName: "Support", + DefaultCustomer: "robot@example.test", CorrelationField: "windshift_item_key", + WorkspaceIDs: []int{f.workspace1}, + }, f.actorID) + if err != nil { + t.Fatal(err) + } + return f +} + +func mustInsertID(t *testing.T, db database.Database, query string, args ...any) int { + t.Helper() + result, err := db.ExecWrite(query, args...) + if err != nil { + t.Fatal(err) + } + id, err := result.LastInsertId() + if err != nil { + t.Fatal(err) + } + return int(id) +} + +func TestZammadCreateTicketIsIdempotentAndPersistsGenericLink(t *testing.T) { + f := newZammadServiceFixture(t, nil) + first, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + second, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + _, posts := f.transport.counts() + if posts != 1 || first.TicketID != 901 || second.ID != first.ID { + t.Fatalf("unexpected idempotency result: first=%#v second=%#v posts=%d", first, second, posts) + } + var genericID string + if err := f.db.QueryRow("SELECT item_integration_link_id FROM zammad_ticket_links WHERE id = ?", first.ID).Scan(&genericID); err != nil { + t.Fatal(err) + } + var count int + if err := f.db.QueryRow("SELECT COUNT(*) FROM item_integration_links WHERE id = ? AND external_id = ?", genericID, strconv.Itoa(first.TicketID)).Scan(&count); err != nil || count != 1 { + t.Fatalf("generic link was not persisted: count=%d err=%v", count, err) + } +} + +func TestZammadLinkExistingTicketPinsExactTicketAndIsIdempotent(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.ticket = map[string]any{ + "id": 711, "number": "420711", "group_id": 7, "group": "Support", + "state_id": 2, "state": "open", "owner_id": 99, + } + f.transport.users = []map[string]any{{"id": 99, "active": true, "firstname": "Grace", "lastname": "Hopper", "group_ids": map[string][]string{"7": {"full"}}}} + + first, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{ + ConnectionID: f.connection.ProviderID, TicketNumber: "420711", + }) + if err != nil { + t.Fatal(err) + } + second, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{ + ConnectionID: f.connection.ProviderID, TicketNumber: "420711", + }) + if err != nil { + t.Fatal(err) + } + if first.ID != second.ID || first.TicketID != 711 || first.SyncState != models.ZammadSyncLinked || f.transport.putCount() != 1 { + t.Fatalf("existing-ticket link was not idempotent: first=%#v second=%#v puts=%d", first, second, f.transport.putCount()) + } + if first.OwnerID != 99 || first.OwnerName != "Grace Hopper" { + t.Fatalf("existing-ticket owner was not resolved: %#v", first) + } + if got := f.transport.lastPutPayload()["windshift_item_key"]; got != first.CorrelationKey { + t.Fatalf("remote correlation was not pinned to the local link: %#v", f.transport.lastPutPayload()) + } +} + +func TestZammadLinkExistingTicketSetupFailureCannotBePromotedBySync(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.ticket = map[string]any{ + "id": 715, "number": "420715", "group_id": 7, "group": "Support", + "state_id": 2, "state": "open", "owner_id": 1, + } + f.transport.putError = context.DeadlineExceeded + + _, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{ + ConnectionID: f.connection.ProviderID, TicketNumber: "420715", + }) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected correlation update failure, got %v", err) + } + reserved, err := f.service.TicketLinksForItem(f.item1) + if err != nil || len(reserved) != 1 { + t.Fatalf("failed setup did not retain exactly one reservation: links=%#v err=%v", reserved, err) + } + link := reserved[0] + if link.SyncState != models.ZammadSyncCreating || link.ItemIntegrationLinkID != "" || link.LastError == "" { + t.Fatalf("failed setup looked complete or lost its safe error: %#v", link) + } + var genericLinks int + if err := f.db.QueryRow("SELECT COUNT(*) FROM item_integration_links WHERE integration_provider_id = ?", f.connection.ProviderID).Scan(&genericLinks); err != nil || genericLinks != 0 { + t.Fatalf("failed setup created a generic integration link: count=%d err=%v", genericLinks, err) + } + + f.transport.resetRequests() + if err := f.service.SyncDue(context.Background(), 10); err != nil { + t.Fatal(err) + } + requests, _ := f.transport.counts() + if requests != 0 { + t.Fatalf("scheduler attempted to sync incomplete reservation: requests=%d", requests) + } + if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err == nil { + t.Fatal("manual refresh promoted an incomplete reservation") + } + closedStateID := 4 + if _, err := f.service.UpdateTicketLink(context.Background(), link.ID, models.UpdateZammadTicketLinkRequest{StateID: &closedStateID}); err == nil { + t.Fatal("ticket edit was allowed before link setup completed") + } + stored, err := f.service.GetTicketLink(link.ID) + if err != nil || stored.SyncState != models.ZammadSyncCreating || stored.ItemIntegrationLinkID != "" { + t.Fatalf("sync attempts changed incomplete reservation: link=%#v err=%v", stored, err) + } + + f.transport.putError = nil + completed, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{ + ConnectionID: f.connection.ProviderID, TicketNumber: "420715", + }) + if err != nil { + t.Fatal(err) + } + if completed.ID != link.ID || completed.SyncState != models.ZammadSyncLinked || completed.ItemIntegrationLinkID == "" || completed.LastError != "" { + t.Fatalf("retry did not finish the retained reservation: %#v", completed) + } +} + +func TestZammadLinkExistingTicketRejectsDisallowedGroupAndConflictingCorrelation(t *testing.T) { + t.Run("disallowed group", func(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.ticket = map[string]any{"id": 712, "number": "420712", "group_id": 99, "state_id": 2, "state": "open"} + _, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{ConnectionID: f.connection.ProviderID, TicketNumber: "420712"}) + var validationErr *ZammadValidationError + if !errors.As(err, &validationErr) || f.transport.putCount() != 0 { + t.Fatalf("disallowed ticket group was accepted: err=%v puts=%d", err, f.transport.putCount()) + } + }) + t.Run("conflicting correlation", func(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.ticket = map[string]any{ + "id": 713, "number": "420713", "group_id": 7, "state_id": 2, "state": "open", + "windshift_item_key": "windshift:other-connection:PRI-49", + } + _, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{ConnectionID: f.connection.ProviderID, TicketNumber: "420713"}) + var validationErr *ZammadValidationError + if !errors.As(err, &validationErr) || f.transport.putCount() != 0 { + t.Fatalf("conflicting remote correlation was accepted: err=%v puts=%d", err, f.transport.putCount()) + } + var links int + if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_ticket_links").Scan(&links); err != nil || links != 0 { + t.Fatalf("conflicting ticket left a local link behind: links=%d err=%v", links, err) + } + }) +} + +func TestZammadLinkExistingTicketDoesNotStealAlreadyReservedRemoteTicket(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.ticket = map[string]any{"id": 714, "number": "420714", "group_id": 7, "state_id": 2, "state": "open"} + first, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{ConnectionID: f.connection.ProviderID, TicketNumber: "420714"}) + if err != nil { + t.Fatal(err) + } + secondItem := mustInsertID(t, f.db, `INSERT INTO items (workspace_id, workspace_item_number, title, description, frac_index, status_id, creator_id, last_active_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, f.workspace1, 50, "Second ticket source", "Synthetic description", "a2", f.openStatus, f.actorID) + // Simulate a remote-side correlation edit between attempts. The local unique + // provider/ticket reservation must still prevent the second item from taking + // over this ticket. + f.transport.ticket["windshift_item_key"] = "" + puts := f.transport.putCount() + _, err = f.service.LinkExistingTicket(context.Background(), secondItem, f.actorID, models.LinkZammadTicketRequest{ConnectionID: f.connection.ProviderID, TicketNumber: "420714"}) + if !errors.Is(err, repository.ErrDuplicateEntry) || f.transport.putCount() != puts { + t.Fatalf("second item stole an already reserved ticket: err=%v puts=%d", err, f.transport.putCount()) + } + stored, err := f.service.GetTicketLink(first.ID) + if err != nil || stored.ItemID != f.item1 { + t.Fatalf("original local reservation changed: link=%#v err=%v", stored, err) + } +} + +func TestZammadUpdateTicketLinkValidatesOwnerForEffectiveGroupAndPersistsSnapshot(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + f.transport.ticket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 2, "owner_id": 1} + f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}} + f.transport.states = []map[string]any{{"id": 2, "name": "open", "active": true}, {"id": 4, "name": "closed", "active": true}} + f.transport.users = []map[string]any{{"id": 99, "active": true, "firstname": "Grace", "lastname": "Hopper", "group_ids": map[string][]string{"8": {"full"}}}} + allowedGroups := zammadTestGroupRefs(7, 8) + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &allowedGroups}); err != nil { + t.Fatal(err) + } + stateID, groupID, ownerID := 4, 8, 99 + updated, err := f.service.UpdateTicketLink(context.Background(), link.ID, models.UpdateZammadTicketLinkRequest{StateID: &stateID, GroupID: &groupID, OwnerID: &ownerID}) + if err != nil { + t.Fatal(err) + } + if updated.LastStatusID != 4 || updated.LastStatusName != "closed" || updated.GroupID != 8 || updated.GroupName != "Escalations" || updated.OwnerID != 99 || updated.OwnerName != "Grace Hopper" { + t.Fatalf("remote update snapshot was not persisted: %#v", updated) + } + puts := f.transport.putCount() + invalidOwner := 100 + _, err = f.service.UpdateTicketLink(context.Background(), link.ID, models.UpdateZammadTicketLinkRequest{OwnerID: &invalidOwner}) + var validationErr *ZammadValidationError + if !errors.As(err, &validationErr) || f.transport.putCount() != puts { + t.Fatalf("owner without group access reached remote update: err=%v puts=%d", err, f.transport.putCount()) + } +} + +func TestZammadTicketSnapshotBlocksConcurrentGroupPolicyNarrowing(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}} + initialAllowedGroups := zammadTestGroupRefs(7, 8) + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &initialAllowedGroups}); err != nil { + t.Fatal(err) + } + + arrived := make(chan struct{}, 1) + release := make(chan struct{}) + f.service.SetPersistBeforeConnectionLockForTesting(func() { + arrived <- struct{}{} + <-release + }) + defer f.service.SetPersistBeforeConnectionLockForTesting(nil) + + newGroupID := 8 + updateErr := make(chan error, 1) + go func() { + _, err := f.service.UpdateTicketLink(context.Background(), link.ID, models.UpdateZammadTicketLinkRequest{GroupID: &newGroupID}) + updateErr <- err + }() + <-arrived + narrowedAllowedGroups := zammadTestGroupRefs(7) + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &narrowedAllowedGroups}); !errors.Is(err, ErrZammadConnectionBusy) { + t.Fatalf("group policy changed while remote update was pending: %v", err) + } + close(release) + if err := <-updateErr; err != nil { + t.Fatalf("remote group move failed after conflicting policy update was rejected: %v", err) + } + + stored, err := f.service.GetTicketLink(link.ID) + if err != nil { + t.Fatal(err) + } + if stored.GroupID != 8 || stored.SyncState != models.ZammadSyncLinked || stored.LastError != "" { + t.Fatalf("rejected policy race prevented the valid snapshot: %#v", stored) + } + if got := int(f.transport.ticket["group_id"].(float64)); got != 8 { + t.Fatalf("test did not move the remote ticket before narrowing: group=%d", got) + } +} + +func TestZammadExpiredUpdateLeaseReloadsGroupPolicyBeforeRemoteWrite(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}} + initialAllowed := zammadTestGroupRefs(7, 8) + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &initialAllowed}); err != nil { + t.Fatal(err) + } + arrived := make(chan struct{}, 1) + release := make(chan struct{}) + f.service.SetUpdateBeforeRemoteWriteForTesting(func() { + arrived <- struct{}{} + <-release + }) + defer f.service.SetUpdateBeforeRemoteWriteForTesting(nil) + + groupID := 8 + updateErr := make(chan error, 1) + go func() { + _, err := f.service.UpdateTicketLink(context.Background(), link.ID, models.UpdateZammadTicketLinkRequest{GroupID: &groupID}) + updateErr <- err + }() + <-arrived + if _, err := f.db.ExecWrite("UPDATE zammad_ticket_links SET sync_lock_until = ? WHERE id = ?", time.Now().Add(-time.Minute), link.ID); err != nil { + t.Fatal(err) + } + narrowed := zammadTestGroupRefs(7) + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &narrowed}); err != nil { + t.Fatalf("group policy could not change after synthetic lease expiry: %v", err) + } + puts := f.transport.putCount() + close(release) + if err := <-updateErr; !errors.Is(err, ErrZammadTicketGroupPolicyChanged) { + t.Fatalf("expired update worker did not honor current group policy: %v", err) + } + if f.transport.putCount() != puts { + t.Fatalf("expired update worker wrote a group removed by current policy") + } +} + +func TestZammadTicketCreationBlocksGroupNarrowingAcrossRemoteReroute(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}} + f.transport.createGroupID = 8 + initialAllowedGroups := zammadTestGroupRefs(7, 8) + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &initialAllowedGroups}); err != nil { + t.Fatal(err) + } + arrived := make(chan struct{}, 1) + release := make(chan struct{}) + f.service.SetPersistBeforeConnectionLockForTesting(func() { + arrived <- struct{}{} + <-release + }) + defer f.service.SetPersistBeforeConnectionLockForTesting(nil) + + type creationResult struct { + link *models.ZammadTicketLink + err error + } + result := make(chan creationResult, 1) + go func() { + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID, GroupID: 7}) + result <- creationResult{link: link, err: err} + }() + <-arrived + narrowedAllowedGroups := zammadTestGroupRefs(7) + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &narrowedAllowedGroups}); !errors.Is(err, ErrZammadConnectionBusy) { + t.Fatalf("group policy changed while rerouted ticket creation was incomplete: %v", err) + } + close(release) + created := <-result + if created.err != nil || created.link == nil || created.link.GroupID != 8 { + t.Fatalf("ticket creation did not finish under the preserved policy: link=%#v err=%v", created.link, created.err) + } +} + +func TestZammadIncompleteReservationAllowsPolicyExpansionAndCorrelationRecovery(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}} + f.transport.createGroupID = 8 + initialAllowedGroups := zammadTestGroupRefs(7) + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &initialAllowedGroups}); err != nil { + t.Fatal(err) + } + + _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + var validationErr *ZammadValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("rerouted ticket unexpectedly completed under the old group policy: %v", err) + } + links, err := f.service.TicketLinksForItem(f.item1) + if err != nil || len(links) != 1 || links[0].SyncState != models.ZammadSyncUncertain { + t.Fatalf("rerouted ticket was not retained for correlation recovery: links=%#v err=%v", links, err) + } + + expandedAllowedGroups := zammadTestGroupRefs(7, 8) + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &expandedAllowedGroups}); err != nil { + t.Fatalf("safe group policy expansion was blocked by incomplete reservation: %v", err) + } + narrowedAllowedGroups := zammadTestGroupRefs(7) + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &narrowedAllowedGroups}); !errors.Is(err, ErrZammadConnectionBusy) { + t.Fatalf("group policy narrowing was allowed while reservation was incomplete: %v", err) + } + + recovered, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatalf("correlation retry did not recover after policy expansion: %v", err) + } + if recovered.TicketID != 901 || recovered.GroupID != 8 || recovered.SyncState != models.ZammadSyncLinked { + t.Fatalf("correlation retry did not complete the rerouted ticket: %#v", recovered) + } + _, posts := f.transport.counts() + if posts != 1 { + t.Fatalf("correlation retry created a duplicate ticket: posts=%d", posts) + } +} + +func TestZammadGroupPolicyNarrows(t *testing.T) { + currentNumeric := &repository.ZammadConnectionMutationSnapshot{DefaultGroupID: 7, AllowedGroups: zammadTestGroupRefs(7, 8)} + currentNameOnly := &repository.ZammadConnectionMutationSnapshot{DefaultGroupName: "Support"} + tests := []struct { + name string + current *repository.ZammadConnectionMutationSnapshot + proposed *models.ZammadConnection + narrows bool + }{ + { + name: "numeric expansion", current: currentNumeric, + proposed: &models.ZammadConnection{DefaultGroupID: 7, AllowedGroups: zammadTestGroupRefs(7, 8, 9)}, + }, + { + name: "numeric expansion preserves name-only default through allowlist", + current: &repository.ZammadConnectionMutationSnapshot{ + DefaultGroupName: "Support", AllowedGroups: zammadTestGroupRefs(7, 8), + }, + proposed: &models.ZammadConnection{DefaultGroupName: "Support", AllowedGroups: zammadTestGroupRefs(7, 8, 9)}, + }, + { + name: "numeric narrowing", current: currentNumeric, + proposed: &models.ZammadConnection{DefaultGroupID: 7, AllowedGroups: zammadTestGroupRefs(7)}, narrows: true, + }, + { + name: "old default remains allowed", current: &repository.ZammadConnectionMutationSnapshot{DefaultGroupID: 7}, + proposed: &models.ZammadConnection{DefaultGroupID: 8, AllowedGroups: zammadTestGroupRefs(7, 8)}, + }, + { + name: "old default excluded", current: &repository.ZammadConnectionMutationSnapshot{DefaultGroupID: 7}, + proposed: &models.ZammadConnection{DefaultGroupID: 8, AllowedGroups: zammadTestGroupRefs(8)}, narrows: true, + }, + { + name: "same name-only policy", current: currentNameOnly, + proposed: &models.ZammadConnection{DefaultGroupName: "Support"}, + }, + { + name: "name-only changed to IDs", current: currentNameOnly, + proposed: &models.ZammadConnection{DefaultGroupID: 7, AllowedGroups: zammadTestGroupRefs(7)}, narrows: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := zammadGroupPolicyNarrows(tt.current, tt.proposed); got != tt.narrows { + t.Fatalf("zammadGroupPolicyNarrows() = %v, want %v", got, tt.narrows) + } + }) + } +} + +func TestZammadExistingTicketLinkBlocksGroupNarrowingAcrossRemoteReroute(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}} + f.transport.ticket = map[string]any{"id": 714, "number": "420714", "group_id": 7, "state_id": 2, "state": "open"} + f.transport.putRerouteGroupID = 8 + initialAllowedGroups := zammadTestGroupRefs(7, 8) + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &initialAllowedGroups}); err != nil { + t.Fatal(err) + } + arrived := make(chan struct{}, 1) + release := make(chan struct{}) + f.service.SetPersistBeforeConnectionLockForTesting(func() { + arrived <- struct{}{} + <-release + }) + defer f.service.SetPersistBeforeConnectionLockForTesting(nil) + + type linkResult struct { + link *models.ZammadTicketLink + err error + } + result := make(chan linkResult, 1) + go func() { + link, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{ConnectionID: f.connection.ProviderID, TicketNumber: "420714"}) + result <- linkResult{link: link, err: err} + }() + <-arrived + narrowedAllowedGroups := zammadTestGroupRefs(7) + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &narrowedAllowedGroups}); !errors.Is(err, ErrZammadConnectionBusy) { + t.Fatalf("group policy changed while rerouted existing-ticket link was incomplete: %v", err) + } + close(release) + linked := <-result + if linked.err != nil || linked.link == nil || linked.link.GroupID != 8 { + t.Fatalf("existing ticket link did not finish under the preserved policy: link=%#v err=%v", linked.link, linked.err) + } +} + +func TestZammadUnlinkClearsOnlyExactRemoteCorrelationWithoutDeletingTicket(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + if _, err := f.service.UnlinkTicket(context.Background(), link.ID); err != nil { + t.Fatal(err) + } + if f.transport.ticket == nil || f.transport.ticket["id"] != 901 || f.transport.ticket["windshift_item_key"] != "" { + t.Fatalf("unlink changed or deleted the remote ticket: %#v", f.transport.ticket) + } + if _, err := f.service.GetTicketLink(link.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("unlink retained local association: %v", err) + } +} + +func TestZammadLocalDetachRemovesLinksWithoutContactingUnavailableUpstream(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + f.transport.resetRequests() + f.transport.getStatus = http.StatusServiceUnavailable + f.transport.putError = errors.New("synthetic upstream outage") + + detached, err := f.service.DetachTicketLinkLocally(link.ID) + if err != nil { + t.Fatalf("local detach failed while upstream was unavailable: %v", err) + } + if detached.ID != link.ID { + t.Fatalf("local detach returned the wrong link: got %q want %q", detached.ID, link.ID) + } + requests, _ := f.transport.counts() + if requests != 0 { + t.Fatalf("local detach contacted Zammad %d times", requests) + } + if f.transport.ticket["windshift_item_key"] != link.CorrelationKey { + t.Fatalf("local detach unexpectedly changed remote correlation: %#v", f.transport.ticket) + } + if _, err := f.service.GetTicketLink(link.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("local detach retained typed link: %v", err) + } + var genericLinkCount int + if err := f.db.QueryRow("SELECT COUNT(*) FROM item_integration_links WHERE id = ?", link.ItemIntegrationLinkID).Scan(&genericLinkCount); err != nil || genericLinkCount != 0 { + t.Fatalf("local detach retained generic link: count=%d err=%v", genericLinkCount, err) + } + if _, err := NewItemCRUDService(f.db).Delete(f.item1); err != nil { + t.Fatalf("item remained protected after local detach: %v", err) + } +} + +func TestZammadDisabledConnectionCanBeUnlinkedAndDeleted(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + disabled := false + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Enabled: &disabled}); err != nil { + t.Fatal(err) + } + if _, err := f.service.UnlinkTicket(context.Background(), link.ID); err != nil { + t.Fatalf("unlink through disabled connection failed: %v", err) + } + if f.transport.ticket["windshift_item_key"] != "" { + t.Fatalf("disabled-connection unlink left remote correlation: %#v", f.transport.ticket) + } + if _, err := f.service.GetTicketLink(link.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("disabled-connection unlink retained local link: %v", err) + } + if err := f.service.DeleteConnection(f.connection.ProviderID); err != nil { + t.Fatalf("connection remained undeletable after unlink: %v", err) + } +} + +func TestZammadUnlinkStillRequiresWorkspaceScope(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + if _, err := f.db.ExecWrite("DELETE FROM action_credential_workspaces WHERE credential_id = ?", f.connection.CredentialID); err != nil { + t.Fatal(err) + } + if _, err := f.service.UnlinkTicket(context.Background(), link.ID); !errors.Is(err, ErrCredentialScopeMismatch) { + t.Fatalf("out-of-scope unlink returned %v", err) + } + if _, err := f.service.GetTicketLink(link.ID); err != nil { + t.Fatalf("out-of-scope unlink removed local link: %v", err) + } + if f.transport.ticket["windshift_item_key"] != link.CorrelationKey { + t.Fatalf("out-of-scope unlink changed remote correlation: %#v", f.transport.ticket) + } +} + +func TestZammadUnlinkStillRequiresEnabledCredential(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + if _, err := f.db.ExecWrite("UPDATE action_credentials SET is_enabled = false WHERE id = ?", f.connection.CredentialID); err != nil { + t.Fatal(err) + } + if _, err := f.service.UnlinkTicket(context.Background(), link.ID); !errors.Is(err, ErrCredentialDisabled) { + t.Fatalf("disabled-credential unlink returned %v", err) + } + if _, err := f.service.GetTicketLink(link.ID); err != nil { + t.Fatalf("disabled-credential unlink removed local link: %v", err) + } + if f.transport.ticket["windshift_item_key"] != link.CorrelationKey { + t.Fatalf("disabled-credential unlink changed remote correlation: %#v", f.transport.ticket) + } +} + +func TestZammadLinkedItemDeletionRequiresExplicitUnlink(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + + if _, err := f.db.ExecWrite("DELETE FROM items WHERE id = ?", f.item1); err == nil { + t.Fatal("database allowed direct deletion of a Zammad-linked item") + } + crud := NewItemCRUDService(f.db) + if _, err := crud.Delete(f.item1); !errors.Is(err, ErrItemHasProtectedIntegrationLinks) { + t.Fatalf("linked item deletion returned %v", err) + } + if _, err := repository.NewItemRepository(f.db).FindByID(f.item1); err != nil { + t.Fatalf("rejected deletion removed item: %v", err) + } + if _, err := f.service.GetTicketLink(link.ID); err != nil { + t.Fatalf("rejected deletion removed Zammad link: %v", err) + } + var genericLinkCount int + if err := f.db.QueryRow("SELECT COUNT(*) FROM item_integration_links WHERE id = ?", link.ItemIntegrationLinkID).Scan(&genericLinkCount); err != nil || genericLinkCount != 1 { + t.Fatalf("rejected deletion removed generic item link: count=%d err=%v", genericLinkCount, err) + } + + if _, err := f.service.UnlinkTicket(context.Background(), link.ID); err != nil { + t.Fatal(err) + } + if _, err := crud.Delete(f.item1); err != nil { + t.Fatalf("item remained undeletable after explicit unlink: %v", err) + } + if _, err := repository.NewItemRepository(f.db).FindByID(f.item1); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("item remained after unlink and delete: %v", err) + } +} + +func TestZammadCascadeDeletionIsBlockedByLinkedDescendant(t *testing.T) { + f := newZammadServiceFixture(t, nil) + childID := mustInsertID(t, f.db, `INSERT INTO items + (workspace_id, workspace_item_number, parent_id, title, description, frac_index, status_id, creator_id, last_active_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, + f.workspace1, 50, f.item1, "Linked child", "Synthetic child", "a0V", f.openStatus, f.actorID) + if _, err := f.service.CreateTicket(context.Background(), childID, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}); err != nil { + t.Fatal(err) + } + + if _, err := NewItemCRUDService(f.db).Delete(f.item1); !errors.Is(err, ErrItemHasProtectedIntegrationLinks) { + t.Fatalf("cascade with linked descendant returned %v", err) + } + itemRepo := repository.NewItemRepository(f.db) + if _, err := itemRepo.FindByID(f.item1); err != nil { + t.Fatalf("rejected cascade removed root item: %v", err) + } + if _, err := itemRepo.FindByID(childID); err != nil { + t.Fatalf("rejected cascade removed linked descendant: %v", err) + } +} + +func TestZammadLinkedItemsBlockWorkspaceDeletion(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + + workspaceService := NewWorkspaceService(f.db) + if err := workspaceService.Delete(f.workspace1); !errors.Is(err, ErrWorkspaceHasProtectedIntegrationLinks) { + t.Fatalf("workspace deletion with linked item returned %v", err) + } + if exists, err := repository.NewWorkspaceRepository(f.db).Exists(f.workspace1); err != nil || !exists { + t.Fatalf("rejected workspace deletion removed workspace: exists=%t err=%v", exists, err) + } + if _, err := repository.NewItemRepository(f.db).FindByID(f.item1); err != nil { + t.Fatalf("rejected workspace deletion removed item: %v", err) + } + if _, err := f.service.GetTicketLink(link.ID); err != nil { + t.Fatalf("rejected workspace deletion removed Zammad link: %v", err) + } + + if _, err := f.service.UnlinkTicket(context.Background(), link.ID); err != nil { + t.Fatal(err) + } + if err := workspaceService.Delete(f.workspace1); err != nil { + t.Fatalf("workspace remained undeletable after explicit unlink: %v", err) + } + if exists, err := repository.NewWorkspaceRepository(f.db).Exists(f.workspace1); err != nil || exists { + t.Fatalf("workspace remained after unlink and delete: exists=%t err=%v", exists, err) + } +} + +func TestZammadLinkedPersonalWorkspaceBlocksUserOffboardingBeforeMutation(t *testing.T) { + f := newZammadServiceFixture(t, nil) + if _, err := f.db.ExecWrite("UPDATE workspaces SET is_personal = true, owner_id = ? WHERE id = ?", f.actorID, f.workspace1); err != nil { + t.Fatal(err) + } + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + var beforeEmail, beforeUsername string + var beforeActive bool + if err := f.db.QueryRow("SELECT email, username, is_active FROM users WHERE id = ?", f.actorID).Scan(&beforeEmail, &beforeUsername, &beforeActive); err != nil { + t.Fatal(err) + } + + if _, err := OffboardUser(f.db, f.actorID, nil); !errors.Is(err, ErrUserOffboardingHasProtectedIntegrationLinks) { + t.Fatalf("offboarding with linked personal item returned %v", err) + } + var afterEmail, afterUsername string + var afterActive bool + if err := f.db.QueryRow("SELECT email, username, is_active FROM users WHERE id = ?", f.actorID).Scan(&afterEmail, &afterUsername, &afterActive); err != nil { + t.Fatal(err) + } + if beforeEmail != afterEmail || beforeUsername != afterUsername || beforeActive != afterActive { + t.Fatalf("blocked offboarding mutated user: before=(%q,%q,%t) after=(%q,%q,%t)", beforeEmail, beforeUsername, beforeActive, afterEmail, afterUsername, afterActive) + } + if exists, err := repository.NewWorkspaceRepository(f.db).Exists(f.workspace1); err != nil || !exists { + t.Fatalf("blocked offboarding removed personal workspace: exists=%t err=%v", exists, err) + } + if _, err := repository.NewItemRepository(f.db).FindByID(f.item1); err != nil { + t.Fatalf("blocked offboarding removed item: %v", err) + } + if _, err := f.service.GetTicketLink(link.ID); err != nil { + t.Fatalf("blocked offboarding removed Zammad link: %v", err) + } +} + +func TestZammadUnlinkUpstreamFailurePreservesLocalLink(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + f.transport.putError = context.DeadlineExceeded + if _, err := f.service.UnlinkTicket(context.Background(), link.ID); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected remote unlink failure, got %v", err) + } + if stored, err := f.service.GetTicketLink(link.ID); err != nil || stored.CorrelationKey != link.CorrelationKey { + t.Fatalf("unlink failure removed or corrupted the local link: link=%#v err=%v", stored, err) + } +} + +func TestZammadUnlinkDoesNotClearAnotherCorrelation(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + f.transport.ticket["windshift_item_key"] = "windshift:another-link:OTHER-8" + puts := f.transport.putCount() + if _, err := f.service.UnlinkTicket(context.Background(), link.ID); err != nil { + t.Fatal(err) + } + if f.transport.putCount() != puts || f.transport.ticket["windshift_item_key"] != "windshift:another-link:OTHER-8" { + t.Fatalf("unlink cleared a foreign correlation: %#v", f.transport.ticket) + } + if _, err := f.service.GetTicketLink(link.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("stale local link was not removed: %v", err) + } +} + +func TestZammadUnlinkUncertainCreationResolvesExactCorrelationBeforeDelete(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.postErrorAfterCreate = context.DeadlineExceeded + _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected ambiguous create timeout, got %v", err) + } + links, err := f.service.TicketLinksForItem(f.item1) + if err != nil || len(links) != 1 || links[0].TicketID != 0 || links[0].SyncState != models.ZammadSyncUncertain { + t.Fatalf("ambiguous create reservation missing: links=%#v err=%v", links, err) + } + if _, err := f.service.UnlinkTicket(context.Background(), links[0].ID); err != nil { + t.Fatalf("uncertain unlink did not resolve the exact remote correlation: %v", err) + } + if f.transport.ticket["windshift_item_key"] != "" { + t.Fatalf("uncertain unlink left the resolved remote correlation: ticket=%#v", f.transport.ticket) + } + if _, err := f.service.GetTicketLink(links[0].ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("resolved uncertain reservation remained local: %v", err) + } +} + +func TestZammadUnlinkUncertainCreationPreservesReservationWhenSearchIsEmpty(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.postErrorAfterCreate = context.DeadlineExceeded + _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected ambiguous create timeout, got %v", err) + } + links, err := f.service.TicketLinksForItem(f.item1) + if err != nil || len(links) != 1 { + t.Fatalf("ambiguous create reservation missing: links=%#v err=%v", links, err) + } + correlation := links[0].CorrelationKey + f.transport.hideSearch = true + if _, err := f.service.UnlinkTicket(context.Background(), links[0].ID); err == nil { + t.Fatal("uncertain unlink discarded an unresolved correlation") + } + stored, err := f.service.GetTicketLink(links[0].ID) + if err != nil || stored.SyncState != models.ZammadSyncUncertain { + t.Fatalf("empty correlation search removed uncertain reservation: link=%#v err=%v", stored, err) + } + if f.transport.ticket["windshift_item_key"] != correlation { + t.Fatalf("empty search changed remote correlation: ticket=%#v", f.transport.ticket) + } +} + +func TestZammadCreateTicketMarksLocalCompletionFailureUncertainAndKeepsCorrelationPinned(t *testing.T) { + f := newZammadServiceFixture(t, nil) + if _, err := f.db.ExecWrite(`CREATE TRIGGER fail_zammad_ticket_completion + BEFORE UPDATE OF ticket_id ON zammad_ticket_links + WHEN NEW.ticket_id IS NOT NULL + BEGIN SELECT RAISE(ABORT, 'synthetic completion failure'); END`); err != nil { + t.Fatal(err) + } + _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err == nil { + t.Fatal("expected local completion failure") + } + link, err := f.service.TicketLinksForItem(f.item1) + if err != nil || len(link) != 1 || link[0].SyncState != models.ZammadSyncUncertain || f.transport.ticket["windshift_item_key"] != link[0].CorrelationKey { + t.Fatalf("known remote ticket was not preserved as uncertain: links=%#v ticket=%#v err=%v", link, f.transport.ticket, err) + } + if _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}); err == nil { + t.Fatal("expected retry to surface the retained local completion failure") + } + _, posts := f.transport.counts() + if posts != 1 { + t.Fatalf("retry created a second ticket after local completion failure: posts=%d", posts) + } +} + +func TestZammadCreateTicketMarksAmbiguousHTTPFailuresUncertainWithoutRetryingPOST(t *testing.T) { + for _, status := range []int{http.StatusInternalServerError, http.StatusRequestTimeout, http.StatusTooManyRequests} { + t.Run(http.StatusText(status), func(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.hideSearch = true + f.transport.postStatusAfterCreate = status + + _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + var apiErr *zammad.APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != status { + t.Fatalf("expected HTTP %d create failure, got %v", status, err) + } + links, err := f.service.TicketLinksForItem(f.item1) + if err != nil || len(links) != 1 || links[0].SyncState != models.ZammadSyncUncertain { + t.Fatalf("ambiguous HTTP %d result was not retained as uncertain: links=%#v err=%v", status, links, err) + } + + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatalf("normal retry after ambiguous HTTP %d failed: %v", status, err) + } + _, posts := f.transport.counts() + if posts != 1 || link.SyncState != models.ZammadSyncUncertain { + t.Fatalf("normal retry after ambiguous HTTP %d sent another POST: link=%#v posts=%d", status, link, posts) + } + }) + } +} + +func TestZammadCreateTicketMarksRejectingHTTP400Failed(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.hideSearch = true + f.transport.postStatusAfterCreate = http.StatusBadRequest + + _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + var apiErr *zammad.APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusBadRequest { + t.Fatalf("expected HTTP 400 create failure, got %v", err) + } + links, err := f.service.TicketLinksForItem(f.item1) + if err != nil || len(links) != 1 || links[0].SyncState != models.ZammadSyncFailed { + t.Fatalf("rejecting HTTP 400 result was not retained as failed: links=%#v err=%v", links, err) + } +} + +func TestZammadCreateTicketRejectsCorrelationMatchInDisallowedGroup(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.ticket = map[string]any{ + "id": 715, "number": "420715", "group_id": 99, "state_id": 2, "state": "open", + "windshift_item_key": "windshift:" + f.connection.ProviderID + ":PRI-49", + } + + _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + var validationErr *ZammadValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("correlation match in a disallowed group was accepted: %v", err) + } + links, err := f.service.TicketLinksForItem(f.item1) + if err != nil || len(links) != 1 || links[0].TicketID != 0 || links[0].SyncState == models.ZammadSyncLinked { + t.Fatalf("disallowed correlation match completed a local link: links=%#v err=%v", links, err) + } + var genericLinks int + if err := f.db.QueryRow("SELECT COUNT(*) FROM item_integration_links").Scan(&genericLinks); err != nil || genericLinks != 0 { + t.Fatalf("disallowed correlation match created a generic link: count=%d err=%v", genericLinks, err) + } +} + +func TestZammadSyncPersistsRemoteGroupAndOwner(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 8, "state_id": 3, "state": "pending", "owner_id": 99} + f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}} + f.transport.users = []map[string]any{{"id": 99, "active": true, "firstname": "Grace", "lastname": "Hopper", "group_ids": map[string][]string{"8": {"full"}}}} + allowedGroups := zammadTestGroupRefs(7, 8) + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{AllowedGroups: &allowedGroups}); err != nil { + t.Fatal(err) + } + synced, err := f.service.SyncTicketLink(context.Background(), link.ID) + if err != nil { + t.Fatal(err) + } + if synced.GroupID != 8 || synced.GroupName != "Escalations" || synced.OwnerID != 99 || synced.OwnerName != "Grace Hopper" || synced.LastSyncedAt == nil { + t.Fatalf("sync did not retain remote group/owner state: %#v", synced) + } +} + +func TestZammadSyncRejectsTicketMovedToDisallowedGroupBeforeSnapshotOrCompletion(t *testing.T) { + workflow := &fakeZammadWorkflow{} + f := newZammadServiceFixture(t, workflow) + workflow.db = f.db + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + allowedGroups := zammadTestGroupRefs(7) + closedStates := []int{4} + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ + AllowedGroups: &allowedGroups, + ClosedStateIDs: &closedStates, + CompletionStatusID: &f.doneStatus, + }); err != nil { + t.Fatal(err) + } + f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 99, "state_id": 4, "state": "closed"} + f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 99, "name": "Restricted", "active": true}} + + if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err == nil { + t.Fatal("sync accepted a ticket moved to a disallowed group") + } + stored, err := f.service.GetTicketLink(link.ID) + if err != nil { + t.Fatal(err) + } + if stored.LastStatusID != link.LastStatusID || stored.GroupID != link.GroupID || stored.CompletionApplied || workflow.writes != 0 { + t.Fatalf("disallowed remote group changed local snapshot or completed item: link=%#v writes=%d", stored, workflow.writes) + } + var statusID int + if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil { + t.Fatal(err) + } + if statusID != f.openStatus { + t.Fatalf("disallowed remote group completed the item: status=%d", statusID) + } +} + +func TestZammadDueLinksRespectRetryDelayAndOAuthReauthorization(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + oldSync := time.Now().Add(-10 * time.Minute) + if _, err := f.db.ExecWrite("UPDATE zammad_ticket_links SET last_synced_at = ? WHERE id = ?", oldSync, link.ID); err != nil { + t.Fatal(err) + } + attemptedAt := time.Now() + syncOwner := "retry-delay-test" + claimed, err := f.service.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(time.Minute)) + if err != nil || !claimed { + t.Fatalf("could not claim retry-delay test link: claimed=%v err=%v", claimed, err) + } + if err := f.service.repo.UpdateTicketLinkSync(link.ID, syncOwner, link.LastStatusID, link.LastStatusName, + link.GroupID, link.GroupName, link.OwnerID, link.OwnerName, + "synthetic safe failure", attemptedAt, false, false); err != nil { + t.Fatal(err) + } + due, err := f.service.repo.ListDueTicketLinks(time.Now().Add(-2*time.Minute), 10) + if err != nil { + t.Fatal(err) + } + if len(due) != 0 { + t.Fatalf("failed link ignored retry delay: %#v", due) + } + if _, err := f.db.ExecWrite("UPDATE zammad_ticket_links SET next_attempt_at = ? WHERE id = ?", time.Now().Add(-time.Minute), link.ID); err != nil { + t.Fatal(err) + } + due, err = f.service.repo.ListDueTicketLinks(time.Now().Add(-2*time.Minute), 10) + if err != nil || len(due) != 1 || due[0].ID != link.ID { + t.Fatalf("eligible retry was not scheduled: due=%#v err=%v", due, err) + } + if _, err := f.db.ExecWrite("UPDATE zammad_connections SET auth_method = 'oauth' WHERE provider_id = ?", f.connection.ProviderID); err != nil { + t.Fatal(err) + } + if _, err := f.db.ExecWrite(`INSERT INTO zammad_oauth_tokens(provider_id, oauth_generation, expires_at, reauthorization_required) + VALUES (?, 1, ?, true)`, f.connection.ProviderID, time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } + due, err = f.service.repo.ListDueTicketLinks(time.Now().Add(-2*time.Minute), 10) + if err != nil { + t.Fatal(err) + } + if len(due) != 0 { + t.Fatalf("reauthorization-required OAuth connection remained due: %#v", due) + } +} + +func TestZammadRetryAfterAmbiguousTimeoutFindsExistingTicket(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.postErrorAfterCreate = context.DeadlineExceeded + _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected timeout, got %v", err) + } + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + _, posts := f.transport.counts() + if posts != 1 || link.TicketID != 901 || link.SyncState != models.ZammadSyncLinked { + t.Fatalf("retry created a duplicate or failed to recover: link=%#v posts=%d", link, posts) + } +} + +func TestZammadAmbiguousCreateNeverPostsAgainWhileSearchIsEmpty(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.hideSearch = true + f.transport.postErrorAfterCreate = context.DeadlineExceeded + _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected timeout, got %v", err) + } + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + _, posts := f.transport.counts() + if posts != 1 || link.SyncState != models.ZammadSyncUncertain { + t.Fatalf("uncertain retry sent another POST: state=%s posts=%d", link.SyncState, posts) + } + f.transport.postErrorAfterCreate = nil + link, err = f.service.RetryUncertainTicketCreation(context.Background(), link.ID, f.actorID) + if err != nil { + t.Fatal(err) + } + _, posts = f.transport.counts() + if posts != 2 || link.TicketID != 901 { + t.Fatalf("explicit administrator override did not retry creation: ticket=%d posts=%d", link.TicketID, posts) + } +} + +func TestZammadUncertainCreateSurvivesTransientCorrelationSearchFailure(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.postErrorAfterCreate = context.DeadlineExceeded + _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected ambiguous initial timeout, got %v", err) + } + f.transport.searchError = context.DeadlineExceeded + if _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected transient correlation-search failure, got %v", err) + } + links, err := f.service.TicketLinksForItem(f.item1) + if err != nil || len(links) != 1 || links[0].SyncState != models.ZammadSyncUncertain { + t.Fatalf("search failure downgraded uncertain creation: links=%#v err=%v", links, err) + } + f.transport.hideSearch = true + retried, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + _, posts := f.transport.counts() + if posts != 1 || retried.SyncState != models.ZammadSyncUncertain { + t.Fatalf("search failure enabled a duplicate POST: posts=%d link=%#v", posts, retried) + } +} + +func TestZammadStaleCreatingLeaseRetriesSearchOnly(t *testing.T) { + f := newZammadServiceFixture(t, nil) + item, err := repository.NewItemRepository(f.db).FindByIDWithDetails(f.item1) + if err != nil { + t.Fatal(err) + } + link := &models.ZammadTicketLink{ + ID: "stale-create-link", ItemID: item.ID, ProviderID: f.connection.ProviderID, + GroupID: 7, GroupName: "Support", CorrelationKey: "windshift:stale-create:PRI-49", + SyncState: models.ZammadSyncPending, CreatedBy: &f.actorID, + } + if err := f.service.reserveZammadTicketLink(f.connection, item, link, false); err != nil { + t.Fatal(err) + } + if _, err := f.db.ExecWrite(`UPDATE zammad_ticket_links + SET sync_state = ?, creating_started_at = ?, sync_lock_until = ?, sync_lock_owner = ? + WHERE id = ?`, models.ZammadSyncCreating, time.Now().Add(-3*time.Minute), time.Now().Add(-time.Minute), "crashed-worker", link.ID); err != nil { + t.Fatal(err) + } + f.transport.hideSearch = true + retried, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + _, posts := f.transport.counts() + if posts != 0 || retried.SyncState != models.ZammadSyncUncertain { + t.Fatalf("stale in-flight creation retried POST: posts=%d link=%#v", posts, retried) + } +} + +func TestZammadCreateVerifiesGroupFromTicketDetailAfterPartialResponse(t *testing.T) { + for _, tt := range []struct { + name string + getTicket map[string]any + wantGroup int + }{ + { + name: "remote reroute is rejected", + getTicket: map[string]any{"id": 901, "number": "420901", "group_id": 8, "state_id": 2, "state": "open"}, + wantGroup: 8, + }, + { + name: "missing detail group is rejected", + getTicket: map[string]any{"id": 901, "number": "420901", "state_id": 2, "state": "open"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": true}, {"id": 8, "name": "Escalations", "active": true}} + f.transport.createGroupID = tt.wantGroup + f.transport.postResponse = map[string]any{"id": 901, "number": "420901", "state_id": 2, "state": "open"} + f.transport.getTicket = tt.getTicket + + _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + var validationErr *ZammadValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("partial create response completed without a verified allowed group: %v", err) + } + links, err := f.service.TicketLinksForItem(f.item1) + if err != nil || len(links) != 1 || links[0].SyncState != models.ZammadSyncUncertain || links[0].ItemIntegrationLinkID != "" { + t.Fatalf("unverified create was not retained as uncertain: links=%#v err=%v", links, err) + } + if got, ok := f.transport.ticket["group_id"].(int); tt.wantGroup > 0 && (!ok || got != tt.wantGroup) { + t.Fatalf("test did not model remote reroute: ticket=%#v", f.transport.ticket) + } + }) + } +} + +func TestZammadRejectsUnconfiguredGroupBeforeCreate(t *testing.T) { + f := newZammadServiceFixture(t, nil) + _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID, GroupID: 99}) + var validationErr *ZammadValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected group validation error, got %v", err) + } + _, posts := f.transport.counts() + if posts != 0 { + t.Fatalf("unconfigured group reached ticket create endpoint %d times", posts) + } +} + +func TestZammadWorkspaceScopeBlocksHTTPAndSecretIsEncrypted(t *testing.T) { + f := newZammadServiceFixture(t, nil) + _, err := f.service.CreateTicket(context.Background(), f.item2, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if !errors.Is(err, ErrCredentialScopeMismatch) { + t.Fatalf("expected scope mismatch, got %v", err) + } + requests, _ := f.transport.counts() + if requests != 0 { + t.Fatalf("out-of-scope request reached transport %d times", requests) + } + encoded, err := json.Marshal(f.connection) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "synthetic-zammad-token") || strings.Contains(string(encoded), "credential_id") { + t.Fatalf("connection API model disclosed credential material: %s", encoded) + } + var encrypted string + if err := f.db.QueryRow("SELECT encrypted_secret FROM action_credentials WHERE id = ?", f.connection.CredentialID).Scan(&encrypted); err != nil { + t.Fatal(err) + } + if encrypted == "synthetic-zammad-token" || strings.Contains(encrypted, "synthetic-zammad-token") { + t.Fatal("Zammad token was stored in plaintext") + } + listed, err := f.credentials.ListForWorkspace(f.workspace1) + if err != nil { + t.Fatal(err) + } + if len(listed) != 0 { + t.Fatalf("managed Zammad credential leaked into generic list: %#v", listed) + } + if _, err := f.credentials.Get(f.connection.CredentialID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("managed Zammad credential was addressable through generic CRUD: %v", err) + } + if _, _, err := f.credentials.Resolve(context.Background(), f.connection.CredentialID, f.workspace1); !errors.Is(err, ErrCredentialPurposeMismatch) { + t.Fatalf("managed Zammad credential resolved through generic action path: %v", err) + } +} + +func TestZammadConnectionScopeCannotStrandExistingTicketLinks(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + + excludedScope := []int{f.workspace2} + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{WorkspaceIDs: &excludedScope}); err == nil { + t.Fatal("connection scope excluded a workspace containing a linked ticket") + } + stored, err := f.service.GetTicketLink(link.ID) + if err != nil || stored.ItemIntegrationLinkID == "" { + t.Fatalf("rejected scope update changed the link: link=%#v err=%v", stored, err) + } + if _, _, err := f.credentials.ResolveManaged(context.Background(), f.connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), f.connection.ProviderID); err != nil { + t.Fatalf("rejected scope update changed credential scope: %v", err) + } + + expandedScope := []int{f.workspace1, f.workspace2} + updated, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{WorkspaceIDs: &expandedScope}) + if err != nil || !slices.Contains(updated.WorkspaceIDs, f.workspace2) { + t.Fatalf("scope expansion with existing link failed: connection=%#v err=%v", updated, err) + } +} + +func TestZammadConnectionRoutingAndGroupsCannotStrandExistingTicketLinks(t *testing.T) { + f := newZammadServiceFixture(t, nil) + if _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}); err != nil { + t.Fatal(err) + } + newBaseURL := "https://replacement.example.test" + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{BaseURL: &newBaseURL}); err == nil { + t.Fatal("base URL changed with an existing ticket link") + } + newCorrelationField := "replacement_item_key" + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{CorrelationField: &newCorrelationField}); err == nil { + t.Fatal("correlation field changed with an existing ticket link") + } + newDefaultGroupID := 8 + newDefaultGroupName := "Escalations" + newAllowedGroups := zammadTestGroupRefs(8) + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ + DefaultGroupID: &newDefaultGroupID, DefaultGroupName: &newDefaultGroupName, AllowedGroups: &newAllowedGroups, + }); err == nil { + t.Fatal("group scope excluded an existing ticket link") + } + stored, err := f.service.GetConnection(f.connection.ProviderID) + if err != nil || stored.BaseURL != f.connection.BaseURL || stored.CorrelationField != f.connection.CorrelationField || stored.DefaultGroupID != 7 { + t.Fatalf("rejected routing/group updates changed connection: connection=%#v err=%v", stored, err) + } +} + +func TestZammadReservationRejectsStaleConnectionAndItemSnapshots(t *testing.T) { + t.Run("connection routing", func(t *testing.T) { + f := newZammadServiceFixture(t, nil) + item, err := repository.NewItemRepository(f.db).FindByIDWithDetails(f.item1) + if err != nil { + t.Fatal(err) + } + staleConnection := *f.connection + newBaseURL := "https://new-routing.example.test" + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{BaseURL: &newBaseURL}); err != nil { + t.Fatal(err) + } + link := &models.ZammadTicketLink{ID: "stale-routing", ItemID: item.ID, ProviderID: f.connection.ProviderID, GroupID: 7, GroupName: "Support", CorrelationKey: "stale", SyncState: models.ZammadSyncPending, CreatedBy: &f.actorID} + if err := f.service.reserveZammadTicketLink(&staleConnection, item, link, false); !errors.Is(err, ErrZammadLinkReservationConflict) { + t.Fatalf("stale connection snapshot reserved a link: %v", err) + } + }) + + t.Run("connection group policy", func(t *testing.T) { + f := newZammadServiceFixture(t, nil) + item, err := repository.NewItemRepository(f.db).FindByIDWithDetails(f.item1) + if err != nil { + t.Fatal(err) + } + staleConnection := *f.connection + newDefaultGroupID := 8 + newDefaultGroupName := "Escalations" + newAllowedGroups := zammadTestGroupRefs(8) + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ + DefaultGroupID: &newDefaultGroupID, DefaultGroupName: &newDefaultGroupName, AllowedGroups: &newAllowedGroups, + }); err != nil { + t.Fatal(err) + } + link := &models.ZammadTicketLink{ID: "stale-group", ItemID: item.ID, ProviderID: f.connection.ProviderID, GroupID: 7, GroupName: "Support", CorrelationKey: "stale", SyncState: models.ZammadSyncPending, CreatedBy: &f.actorID} + if err := f.service.reserveZammadTicketLink(&staleConnection, item, link, false); !errors.Is(err, ErrZammadLinkReservationConflict) { + t.Fatalf("stale group policy reserved a link: %v", err) + } + }) + + t.Run("connection default customer", func(t *testing.T) { + f := newZammadServiceFixture(t, nil) + item, err := repository.NewItemRepository(f.db).FindByIDWithDetails(f.item1) + if err != nil { + t.Fatal(err) + } + staleConnection := *f.connection + newDefaultCustomer := "new-customer@example.test" + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{DefaultCustomer: &newDefaultCustomer}); err != nil { + t.Fatal(err) + } + link := &models.ZammadTicketLink{ID: "stale-customer", ItemID: item.ID, ProviderID: f.connection.ProviderID, GroupID: 7, GroupName: "Support", CorrelationKey: "stale", SyncState: models.ZammadSyncPending, CreatedBy: &f.actorID} + if err := f.service.reserveZammadTicketLink(&staleConnection, item, link, false); !errors.Is(err, ErrZammadLinkReservationConflict) { + t.Fatalf("stale default customer reserved a link: %v", err) + } + }) + + t.Run("item workspace and key", func(t *testing.T) { + f := newZammadServiceFixture(t, nil) + item, err := repository.NewItemRepository(f.db).FindByIDWithDetails(f.item1) + if err != nil { + t.Fatal(err) + } + if _, err := f.db.ExecWrite("UPDATE items SET workspace_id = ?, workspace_item_number = 2 WHERE id = ?", f.workspace2, f.item1); err != nil { + t.Fatal(err) + } + link := &models.ZammadTicketLink{ID: "stale-item", ItemID: item.ID, ProviderID: f.connection.ProviderID, GroupID: 7, GroupName: "Support", CorrelationKey: "stale", SyncState: models.ZammadSyncPending, CreatedBy: &f.actorID} + if err := f.service.reserveZammadTicketLink(f.connection, item, link, false); !errors.Is(err, ErrZammadLinkReservationConflict) { + t.Fatalf("stale item snapshot reserved a link: %v", err) + } + }) +} + +func TestZammadOutOfScopeLegacyLinkIsNotExposed(t *testing.T) { + f := newZammadServiceFixture(t, nil) + if _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}); err != nil { + t.Fatal(err) + } + if _, err := f.db.ExecWrite("UPDATE items SET workspace_id = ?, workspace_item_number = 2 WHERE id = ?", f.workspace2, f.item1); err != nil { + t.Fatal(err) + } + links, err := f.service.TicketLinksForItem(f.item1) + if err != nil || len(links) != 0 { + t.Fatalf("out-of-scope legacy link was exposed: links=%#v err=%v", links, err) + } +} + +func TestZammadLinkedItemMoveRequiresDestinationConnectionScope(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + var itemTypeID int + if err := f.db.QueryRow("SELECT id FROM item_types WHERE hierarchy_level >= 0 ORDER BY id LIMIT 1").Scan(&itemTypeID); err != nil { + t.Fatal(err) + } + input := ItemWorkspaceMoveInput{DestinationWorkspaceID: f.workspace2, TargetItemTypeID: itemTypeID, TargetStatusID: f.openStatus} + moveService := NewItemWorkspaceMoveService(f.db) + if _, err := moveService.Move(f.item1, f.actorID, input); err == nil { + t.Fatal("item moved outside its linked Zammad connection scope") + } + + expandedScope := []int{f.workspace1, f.workspace2} + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{WorkspaceIDs: &expandedScope}); err != nil { + t.Fatal(err) + } + result, err := moveService.Move(f.item1, f.actorID, input) + if err != nil { + t.Fatalf("item move inside linked Zammad connection scope failed: %v", err) + } + if result.Item.WorkspaceID != f.workspace2 { + t.Fatalf("item remained in workspace %d", result.Item.WorkspaceID) + } + stored, err := f.service.GetTicketLink(link.ID) + if err != nil || stored.CorrelationKey != link.CorrelationKey { + t.Fatalf("allowed item move changed linked ticket identity: link=%#v err=%v", stored, err) + } +} + +func TestZammadDeleteConnectionRejectsExistingTicketLinks(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + if err := f.service.DeleteConnection(f.connection.ProviderID); err == nil { + t.Fatal("connection deletion succeeded despite existing ticket link") + } + var providerCount, credentialCount, linkCount int + if err := f.db.QueryRow("SELECT COUNT(*) FROM integration_providers WHERE id = ?", f.connection.ProviderID).Scan(&providerCount); err != nil { + t.Fatal(err) + } + if err := f.db.QueryRow("SELECT COUNT(*) FROM action_credentials WHERE id = ?", f.connection.CredentialID).Scan(&credentialCount); err != nil { + t.Fatal(err) + } + if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_ticket_links WHERE id = ?", link.ID).Scan(&linkCount); err != nil { + t.Fatal(err) + } + if providerCount != 1 || credentialCount != 1 || linkCount != 1 { + t.Fatalf("rejected connection delete changed persisted data: providers=%d credentials=%d links=%d", providerCount, credentialCount, linkCount) + } +} + +func TestZammadDeleteConnectionWithoutTicketLinksRemovesOwnedCredentialAtomically(t *testing.T) { + f := newZammadServiceFixture(t, nil) + credentialID := f.connection.CredentialID + if err := f.service.DeleteConnection(f.connection.ProviderID); err != nil { + t.Fatal(err) + } + var providerCount, credentialCount int + if err := f.db.QueryRow("SELECT COUNT(*) FROM integration_providers WHERE id = ?", f.connection.ProviderID).Scan(&providerCount); err != nil { + t.Fatal(err) + } + if err := f.db.QueryRow("SELECT COUNT(*) FROM action_credentials WHERE id = ?", credentialID).Scan(&credentialCount); err != nil { + t.Fatal(err) + } + if providerCount != 0 || credentialCount != 0 { + t.Fatalf("connection delete left data behind: providers=%d credentials=%d", providerCount, credentialCount) + } +} + +func TestZammadConnectionUpdateRollsBackManagedCredentialOnProviderConflict(t *testing.T) { + f := newZammadServiceFixture(t, nil) + other, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{ + Slug: "other-helpdesk", Name: "Other helpdesk", BaseURL: "https://other.example.test", + APIToken: "other-token", DefaultGroupID: 7, DefaultGroupName: "Support", + DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1}, + }, f.actorID) + if err != nil { + t.Fatal(err) + } + newToken := "must-be-rolled-back" + newScope := []int{f.workspace2} + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Slug: &other.Slug, APIToken: &newToken, WorkspaceIDs: &newScope}); !errors.Is(err, repository.ErrDuplicateEntry) { + t.Fatalf("expected duplicate provider slug, got %v", err) + } + secret, _, err := f.credentials.ResolveManaged(context.Background(), f.connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), f.connection.ProviderID) + if err != nil || secret != "synthetic-zammad-token" { + t.Fatalf("managed credential was not restored: secret_matches=%v err=%v", secret == "synthetic-zammad-token", err) + } + if _, _, err := f.credentials.ResolveManaged(context.Background(), f.connection.CredentialID, f.workspace2, string(models.IntegrationProviderZammad), f.connection.ProviderID); !errors.Is(err, ErrCredentialScopeMismatch) { + t.Fatalf("managed credential scope was not restored: %v", err) + } +} + +func TestZammadConcurrentPartialConnectionUpdatesDoNotOverwriteEachOther(t *testing.T) { + f := newZammadServiceFixture(t, nil) + arrived := make(chan struct{}, 2) + release := make(chan struct{}) + f.service.SetUpdateBeforeConnectionLockForTesting(func() { + arrived <- struct{}{} + <-release + }) + defer f.service.SetUpdateBeforeConnectionLockForTesting(nil) + + updatedName := "Concurrent name" + expandedScope := []int{f.workspace1, f.workspace2} + errorsByRequest := make(chan error, 2) + go func() { + _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Name: &updatedName}) + errorsByRequest <- err + }() + go func() { + _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{WorkspaceIDs: &expandedScope}) + errorsByRequest <- err + }() + + <-arrived + <-arrived + close(release) + firstErr, secondErr := <-errorsByRequest, <-errorsByRequest + conflicts := 0 + for _, err := range []error{firstErr, secondErr} { + if errors.Is(err, repository.ErrConcurrentUpdate) { + conflicts++ + } else if err != nil { + t.Fatalf("unexpected concurrent update error: %v", err) + } + } + if conflicts != 1 { + t.Fatalf("expected exactly one optimistic conflict, got %d: first=%v second=%v", conflicts, firstErr, secondErr) + } + + stored, err := f.service.GetConnection(f.connection.ProviderID) + if err != nil { + t.Fatal(err) + } + nameWon := stored.Name == updatedName && slices.Equal(stored.WorkspaceIDs, []int{f.workspace1}) + scopeWon := stored.Name == f.connection.Name && slices.Equal(stored.WorkspaceIDs, expandedScope) + if !nameWon && !scopeWon { + t.Fatalf("concurrent partial updates produced a mixed or stale result: name=%q workspaces=%v", stored.Name, stored.WorkspaceIDs) + } +} + +func TestZammadSyncUpdatesStatusAndDisabledConnectionsAreSkipped(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 3, "state": "pending"} + link, err = f.service.SyncTicketLink(context.Background(), link.ID) + if err != nil || link.LastStatusID != 3 || link.LastStatusName != "pending" { + t.Fatalf("unexpected synced link: link=%#v err=%v", link, err) + } + disabled := false + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Enabled: &disabled}); err != nil { + t.Fatal(err) + } + f.transport.resetRequests() + if err := f.service.SyncDue(context.Background(), 50); err != nil { + t.Fatal(err) + } + requests, _ := f.transport.counts() + if requests != 0 { + t.Fatalf("disabled connection was polled %d times", requests) + } +} + +func TestZammadDisabledConnectionCanStillBeTestedByAdmin(t *testing.T) { + f := newZammadServiceFixture(t, nil) + disabled := false + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{Enabled: &disabled}); err != nil { + t.Fatal(err) + } + metadata, err := f.service.TestConnection(context.Background(), f.connection.ProviderID) + if err != nil { + t.Fatal(err) + } + if len(metadata.Groups) != 1 || metadata.Groups[0].Name != "Support" { + t.Fatalf("unexpected connection test metadata: %#v", metadata) + } + if metadata.GroupCatalogVerified || metadata.CorrelationFieldVerified { + t.Fatalf("least-privilege checks must report admin-only metadata as unverified: %#v", metadata) + } +} + +func TestZammadConnectionValidationRequiresUsableDefaultGroup(t *testing.T) { + f := newZammadServiceFixture(t, nil) + _, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{ + Slug: "missing-default", Name: "Missing default", BaseURL: "https://missing-default.example.test", + APIToken: "token", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1}, + }, f.actorID) + var validationErr *ZammadValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("connection without a default group was accepted: %v", err) + } + + _, err = f.service.CreateConnection(models.CreateZammadConnectionRequest{ + Slug: "default-outside-allowset", Name: "Default outside allowset", BaseURL: "https://default-outside.example.test", + APIToken: "token", DefaultGroupID: 7, AllowedGroups: zammadTestGroupRefs(8), + DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1}, + }, f.actorID) + if !errors.As(err, &validationErr) { + t.Fatalf("default group outside allowset was accepted: %v", err) + } +} + +func TestZammadLegacyGroupCatalogCanBeSavedUnchanged(t *testing.T) { + f := newZammadServiceFixture(t, nil) + connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{ + Slug: "legacy-groups", Name: "Legacy groups", BaseURL: "https://legacy-groups.example.test", + APIToken: "token", DefaultGroupID: 7, DefaultGroupName: "Support", AllowedGroupIDs: []int{7, 8}, + DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1}, + }, f.actorID) + if err != nil { + t.Fatal(err) + } + if len(connection.AllowedGroups) != 2 || connection.AllowedGroups[1].ID != 8 || connection.AllowedGroups[1].Name != "" { + t.Fatalf("unexpected legacy catalog: %#v", connection.AllowedGroups) + } + name := "Legacy groups renamed" + updated, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{ + Name: &name, AllowedGroups: &connection.AllowedGroups, + }) + if err != nil { + t.Fatalf("unchanged legacy group catalog blocked an unrelated update: %v", err) + } + if updated.Name != name || len(updated.AllowedGroups) != 2 || updated.AllowedGroups[1].Name != "" { + t.Fatalf("legacy group catalog was not preserved: %#v", updated) + } +} + +func TestZammadMetadataValidatesOnlyRuntimeTicketStates(t *testing.T) { + connection := &models.ZammadConnection{DefaultGroupName: "Support", AllowedGroups: zammadTestGroupRefs(8), ClosedStateIDs: []int{4}} + metadata := &models.ZammadConnectionMetadata{ + Groups: []models.ZammadGroup{{ID: 99, Name: "Different", Active: true}}, + States: []models.ZammadState{{ID: 2, Name: "open", Active: true}}, + } + var validationErr *ZammadValidationError + if err := validateZammadStates(connection, metadata); !errors.As(err, &validationErr) { + t.Fatalf("missing configured closed state was accepted: %v", err) + } +} + +func TestZammadUnknownTicketDoesNotChangeItem(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + f.transport.getStatus = http.StatusNotFound + if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err == nil { + t.Fatal("expected remote not-found error") + } + var statusID int + if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil { + t.Fatal(err) + } + if statusID != f.openStatus { + t.Fatalf("unknown remote ticket changed item status to %d", statusID) + } + stored, err := f.service.GetTicketLink(link.ID) + if err != nil { + t.Fatal(err) + } + if strings.Contains(stored.LastError, "synthetic remote detail") { + t.Fatalf("remote response body leaked into stored error: %q", stored.LastError) + } +} + +func TestZammadClosedStateCompletesItemOnce(t *testing.T) { + workflow := &fakeZammadWorkflow{} + f := newZammadServiceFixture(t, workflow) + workflow.db = f.db + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + closed := []int{4} + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closed, CompletionStatusID: &f.doneStatus}); err != nil { + t.Fatal(err) + } + f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 4, "state": "closed"} + if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil { + t.Fatal(err) + } + if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil { + t.Fatal(err) + } + var statusID int + if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil { + t.Fatal(err) + } + if statusID != f.doneStatus || workflow.writes != 1 { + t.Fatalf("closed-state mapping was not idempotent: status=%d writes=%d", statusID, workflow.writes) + } + if _, err := f.db.ExecWrite("UPDATE items SET status_id = ? WHERE id = ?", f.openStatus, f.item1); err != nil { + t.Fatal(err) + } + if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil { + t.Fatal(err) + } + if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil { + t.Fatal(err) + } + if statusID != f.openStatus || workflow.writes != 1 { + t.Fatalf("same remote closed episode re-completed a reopened item: status=%d writes=%d", statusID, workflow.writes) + } + f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 2, "state": "open"} + if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil { + t.Fatal(err) + } + f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 4, "state": "closed"} + if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil { + t.Fatal(err) + } + if workflow.writes != 2 { + t.Fatalf("new remote closed episode did not complete the item: writes=%d", workflow.writes) + } +} + +func TestZammadCompletionPolicyCannotChangeDuringClaimedTransition(t *testing.T) { + workflow := &fakeZammadWorkflow{} + f := newZammadServiceFixture(t, workflow) + workflow.db = f.db + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + closed := []int{4} + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closed, CompletionStatusID: &f.doneStatus}); err != nil { + t.Fatal(err) + } + f.transport.getTicket = map[string]any{"id": 901, "number": "420901", "group_id": 7, "state_id": 4, "state": "closed"} + arrived := make(chan struct{}, 1) + release := make(chan struct{}) + f.service.SetCompletionBeforeTransitionForTesting(func() { + arrived <- struct{}{} + <-release + }) + defer f.service.SetCompletionBeforeTransitionForTesting(nil) + + syncErr := make(chan error, 1) + go func() { + _, err := f.service.SyncTicketLink(context.Background(), link.ID) + syncErr <- err + }() + <-arrived + noClosedStates := []int{} + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ + ClosedStateIDs: &noClosedStates, ClearCompletionStatus: true, + }); !errors.Is(err, ErrZammadConnectionBusy) { + t.Fatalf("completion policy changed during a claimed transition: %v", err) + } + close(release) + if err := <-syncErr; err != nil { + t.Fatalf("claimed transition failed after conflicting config update was rejected: %v", err) + } + stored, err := f.service.GetTicketLink(link.ID) + if err != nil { + t.Fatal(err) + } + if workflow.writes != 1 || !stored.CompletionApplied { + t.Fatalf("completion did not commit exactly once: writes=%d link=%#v", workflow.writes, stored) + } +} + +func TestZammadPostCommitTransitionErrorStillFencesClosedEpisode(t *testing.T) { + workflow := &fakeZammadWorkflow{postCommitErr: errors.New("synthetic post-commit hook failure")} + f := newZammadServiceFixture(t, workflow) + workflow.db = f.db + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + closed := []int{4} + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closed, CompletionStatusID: &f.doneStatus}); err != nil { + t.Fatal(err) + } + f.transport.getTicket = map[string]any{"id": link.TicketID, "number": link.TicketNumber, "group_id": 7, "state_id": 4, "state": "closed"} + if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err == nil { + t.Fatal("post-commit hook error was not reported") + } + stored, err := f.service.GetTicketLink(link.ID) + if err != nil { + t.Fatal(err) + } + if workflow.writes != 1 || !stored.CompletionApplied { + t.Fatalf("committed transition was not fenced after hook error: writes=%d link=%#v", workflow.writes, stored) + } + if _, err := f.db.ExecWrite("UPDATE items SET status_id = ? WHERE id = ?", f.openStatus, f.item1); err != nil { + t.Fatal(err) + } + workflow.postCommitErr = nil + if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil { + t.Fatal(err) + } + var statusID int + if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil { + t.Fatal(err) + } + if workflow.writes != 1 || statusID != f.openStatus { + t.Fatalf("same remote closed episode replayed after post-commit error: writes=%d status=%d", workflow.writes, statusID) + } +} + +func TestZammadClaimedSyncBlocksConcurrentUnlinkBeforeCompletion(t *testing.T) { + workflow := &fakeZammadWorkflow{} + f := newZammadServiceFixture(t, workflow) + workflow.db = f.db + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + closed := []int{4} + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closed, CompletionStatusID: &f.doneStatus}); err != nil { + t.Fatal(err) + } + f.transport.getTicket = map[string]any{"id": link.TicketID, "number": link.TicketNumber, "group_id": 7, "state_id": 4, "state": "closed"} + arrived := make(chan struct{}, 1) + release := make(chan struct{}) + f.service.SetCompletionBeforeTransitionForTesting(func() { + arrived <- struct{}{} + <-release + }) + defer f.service.SetCompletionBeforeTransitionForTesting(nil) + + syncErr := make(chan error, 1) + go func() { + _, err := f.service.SyncTicketLink(context.Background(), link.ID) + syncErr <- err + }() + <-arrived + if _, err := f.service.UnlinkTicket(context.Background(), link.ID); !errors.Is(err, ErrZammadConnectionBusy) { + t.Fatalf("unlink entered a claimed sync before completion: %v", err) + } + close(release) + if err := <-syncErr; err != nil { + t.Fatalf("claimed sync failed after concurrent unlink was rejected: %v", err) + } + stored, err := f.service.GetTicketLink(link.ID) + if err != nil { + t.Fatalf("rejected unlink removed the local link: %v", err) + } + if workflow.writes != 1 || !stored.CompletionApplied { + t.Fatalf("claimed sync did not complete exactly once: writes=%d link=%#v", workflow.writes, stored) + } +} + +func TestZammadExpiredSyncLeaseReloadsChangedCompletionPolicyBeforeTransition(t *testing.T) { + workflow := &fakeZammadWorkflow{} + f := newZammadServiceFixture(t, workflow) + workflow.db = f.db + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + closed := []int{4} + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closed, CompletionStatusID: &f.doneStatus}); err != nil { + t.Fatal(err) + } + f.transport.getTicket = map[string]any{"id": link.TicketID, "number": link.TicketNumber, "group_id": 7, "state_id": 4, "state": "closed"} + arrived := make(chan struct{}, 1) + release := make(chan struct{}) + f.service.SetCompletionBeforeTransitionForTesting(func() { + arrived <- struct{}{} + <-release + }) + defer f.service.SetCompletionBeforeTransitionForTesting(nil) + + syncErr := make(chan error, 1) + go func() { + _, err := f.service.SyncTicketLink(context.Background(), link.ID) + syncErr <- err + }() + <-arrived + if _, err := f.db.ExecWrite("UPDATE zammad_ticket_links SET sync_lock_until = ? WHERE id = ?", time.Now().Add(-time.Minute), link.ID); err != nil { + t.Fatal(err) + } + noClosedStates := []int{} + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ + ClosedStateIDs: &noClosedStates, ClearCompletionStatus: true, + }); err != nil { + t.Fatalf("completion policy could not change after the synthetic lease expiry: %v", err) + } + close(release) + if err := <-syncErr; err != nil { + t.Fatalf("sync did not recover its untaken lease under the current policy: %v", err) + } + var statusID int + if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil { + t.Fatal(err) + } + if workflow.writes != 0 || statusID != f.openStatus { + t.Fatalf("expired worker applied the superseded completion policy: writes=%d status=%d", workflow.writes, statusID) + } +} + +func TestZammadTicketCreationClaimBlocksConcurrentUnlinkBeforeCompletion(t *testing.T) { + f := newZammadServiceFixture(t, nil) + arrived := make(chan struct{}, 1) + release := make(chan struct{}) + f.service.SetPersistBeforeConnectionLockForTesting(func() { + arrived <- struct{}{} + <-release + }) + defer f.service.SetPersistBeforeConnectionLockForTesting(nil) + + type createResult struct { + link *models.ZammadTicketLink + err error + } + result := make(chan createResult, 1) + go func() { + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + result <- createResult{link: link, err: err} + }() + <-arrived + links, err := f.service.TicketLinksForItem(f.item1) + if err != nil || len(links) != 1 { + t.Fatalf("creation reservation was not visible: links=%#v err=%v", links, err) + } + if _, err := f.service.UnlinkTicket(context.Background(), links[0].ID); !errors.Is(err, ErrZammadConnectionBusy) { + t.Fatalf("unlink deleted a claimed creation before local completion: %v", err) + } + close(release) + created := <-result + if created.err != nil || created.link == nil || created.link.ItemIntegrationLinkID == "" { + t.Fatalf("ticket creation did not complete after unlink was rejected: link=%#v err=%v", created.link, created.err) + } + var genericLinks int + if err := f.db.QueryRow("SELECT COUNT(*) FROM item_integration_links WHERE id = ?", created.link.ItemIntegrationLinkID).Scan(&genericLinks); err != nil { + t.Fatal(err) + } + if genericLinks != 1 { + t.Fatalf("ticket creation committed %d generic links, want 1", genericLinks) + } +} + +func TestZammadExistingTicketClaimBlocksConcurrentUnlinkBeforeCompletion(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.transport.ticket = map[string]any{"id": 714, "number": "420714", "group_id": 7, "state_id": 2, "state": "open"} + arrived := make(chan struct{}, 1) + release := make(chan struct{}) + f.service.SetPersistBeforeConnectionLockForTesting(func() { + arrived <- struct{}{} + <-release + }) + defer f.service.SetPersistBeforeConnectionLockForTesting(nil) + + type linkResult struct { + link *models.ZammadTicketLink + err error + } + result := make(chan linkResult, 1) + go func() { + link, err := f.service.LinkExistingTicket(context.Background(), f.item1, f.actorID, models.LinkZammadTicketRequest{ + ConnectionID: f.connection.ProviderID, TicketNumber: "420714", + }) + result <- linkResult{link: link, err: err} + }() + <-arrived + links, err := f.service.TicketLinksForItem(f.item1) + if err != nil || len(links) != 1 { + t.Fatalf("existing-ticket reservation was not visible: links=%#v err=%v", links, err) + } + if _, err := f.service.UnlinkTicket(context.Background(), links[0].ID); !errors.Is(err, ErrZammadConnectionBusy) { + t.Fatalf("unlink deleted a claimed existing-ticket link before completion: %v", err) + } + close(release) + linked := <-result + if linked.err != nil || linked.link == nil || linked.link.ItemIntegrationLinkID == "" { + t.Fatalf("existing-ticket link did not complete after unlink was rejected: link=%#v err=%v", linked.link, linked.err) + } + var genericLinks int + if err := f.db.QueryRow("SELECT COUNT(*) FROM item_integration_links WHERE id = ?", linked.link.ItemIntegrationLinkID).Scan(&genericLinks); err != nil { + t.Fatal(err) + } + if genericLinks != 1 { + t.Fatalf("existing-ticket completion committed %d generic links, want 1", genericLinks) + } +} + +func TestZammadSyncReloadsCompletionStateAfterClaim(t *testing.T) { + workflow := &fakeZammadWorkflow{} + f := newZammadServiceFixture(t, workflow) + workflow.db = f.db + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + closedStates := []int{4} + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closedStates, CompletionStatusID: &f.doneStatus}); err != nil { + t.Fatal(err) + } + stale, err := f.service.GetTicketLink(link.ID) + if err != nil { + t.Fatal(err) + } + if _, err := f.db.ExecWrite("UPDATE zammad_ticket_links SET completion_applied = true WHERE id = ?", link.ID); err != nil { + t.Fatal(err) + } + f.transport.getTicket = map[string]any{"id": link.TicketID, "number": link.TicketNumber, "group_id": 7, "state_id": 4, "state": "closed"} + syncOwner := "stale-completion-test" + claimed, err := f.service.repo.ClaimSync(link.ID, syncOwner, time.Now().Add(time.Minute)) + if err != nil || !claimed { + t.Fatalf("could not claim stale-link regression sync: claimed=%v err=%v", claimed, err) + } + updated, err := f.service.syncClaimedTicketLink(context.Background(), stale, syncOwner) + if err != nil { + t.Fatal(err) + } + if workflow.writes != 0 || !updated.CompletionApplied { + t.Fatalf("stale completion state was replayed: writes=%d link=%#v", workflow.writes, updated) + } + var statusID int + if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil || statusID != f.openStatus { + t.Fatalf("stale sync changed item status: status=%d err=%v", statusID, err) + } +} + +func TestZammadManualRefreshReportsBusyWhenAnotherWorkerOwnsLease(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + claimed, err := f.service.repo.ClaimSync(link.ID, "scheduler-owner", time.Now().Add(time.Minute)) + if err != nil || !claimed { + t.Fatalf("scheduler could not claim ticket: claimed=%v err=%v", claimed, err) + } + if _, err := f.service.SyncTicketLink(context.Background(), link.ID); !errors.Is(err, ErrZammadConnectionBusy) { + t.Fatalf("manual refresh reported success while scheduler owned the lease: %v", err) + } + if err := f.service.repo.ReleaseSyncClaim(link.ID, "scheduler-owner"); err != nil { + t.Fatal(err) + } +} + +func TestZammadSyncUsesPersistedGroupPolicyWithoutAdminLookup(t *testing.T) { + workflow := &fakeZammadWorkflow{} + f := newZammadServiceFixture(t, workflow) + workflow.db = f.db + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + closedStates := []int{4} + if _, err := f.service.UpdateConnection(f.connection.ProviderID, models.UpdateZammadConnectionRequest{ClosedStateIDs: &closedStates, CompletionStatusID: &f.doneStatus}); err != nil { + t.Fatal(err) + } + f.transport.groups = []map[string]any{{"id": 7, "name": "Support", "active": false}} + f.transport.getTicket = map[string]any{"id": link.TicketID, "number": link.TicketNumber, "group_id": 7, "state_id": 4, "state": "closed"} + _, err = f.service.SyncTicketLink(context.Background(), link.ID) + if err != nil { + t.Fatalf("persisted group policy required an admin-only lookup: %v", err) + } + stored, getErr := f.service.GetTicketLink(link.ID) + if getErr != nil || workflow.writes != 1 || !stored.CompletionApplied { + t.Fatalf("persisted group policy did not complete normal sync: link=%#v writes=%d err=%v", stored, workflow.writes, getErr) + } +} + +func TestNormalizeZammadBaseURL(t *testing.T) { + tests := []struct { + input string + want string + err bool + }{ + {input: "https://support.example.test/desk/", want: "https://support.example.test/desk"}, + {input: "http://support.example.test", err: true}, + {input: "https://user:secret@support.example.test", err: true}, + {input: "https://support.example.test?token=secret", err: true}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := NormalizeZammadBaseURL(tt.input) + if (err != nil) != tt.err || got != tt.want { + t.Fatalf("NormalizeZammadBaseURL(%q) = %q, %v", tt.input, got, err) + } + }) + } +} + +func TestZammadDueSyncUsesAgeThreshold(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + if _, err := f.db.ExecWrite("UPDATE zammad_ticket_links SET last_synced_at = ? WHERE id = ?", time.Now().Add(-10*time.Minute), link.ID); err != nil { + t.Fatal(err) + } + f.transport.resetRequests() + if err := f.service.SyncDue(context.Background(), 10); err != nil { + t.Fatal(err) + } + requests, _ := f.transport.counts() + if requests != 1 { + t.Fatalf("expected only the ticket refresh, got %d transport requests", requests) + } +} + +func TestZammadDueSyncPollsFreshLinkOnNextSchedulerTick(t *testing.T) { + f := newZammadServiceFixture(t, nil) + if _, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}); err != nil { + t.Fatal(err) + } + f.transport.resetRequests() + if err := f.service.SyncDue(context.Background(), 10); err != nil { + t.Fatal(err) + } + requests, _ := f.transport.counts() + if requests != 1 { + t.Fatalf("fresh link missed the next scheduler tick: requests=%d", requests) + } +} + +func TestZammadSyncAllTicketLinksOverridesRetryDelay(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + if _, err := f.db.ExecWrite("UPDATE zammad_ticket_links SET next_attempt_at = ? WHERE id = ?", time.Now().Add(time.Hour), link.ID); err != nil { + t.Fatal(err) + } + f.transport.resetRequests() + summary, err := f.service.SyncAllTicketLinks(context.Background()) + if err != nil { + t.Fatal(err) + } + if summary.Selected != 1 || summary.Succeeded != 1 || summary.Failed != 0 || summary.Skipped != 0 { + t.Fatalf("unexpected system refresh summary: %#v", summary) + } + requests, _ := f.transport.counts() + if requests != 1 { + t.Fatalf("system refresh did not override the retry delay: requests=%d", requests) + } +} + +func TestZammadSyncAllTicketLinksReportsAlreadyClaimedLinks(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + claimed, err := f.service.repo.ClaimSync(link.ID, "other-worker", time.Now().Add(time.Minute)) + if err != nil || !claimed { + t.Fatalf("failed to create competing claim: claimed=%v err=%v", claimed, err) + } + f.transport.resetRequests() + summary, err := f.service.SyncAllTicketLinks(context.Background()) + if err != nil { + t.Fatal(err) + } + if summary.Selected != 1 || summary.Succeeded != 0 || summary.Failed != 0 || summary.Skipped != 1 { + t.Fatalf("claimed link disappeared from system refresh summary: %#v", summary) + } + requests, _ := f.transport.counts() + if requests != 0 { + t.Fatalf("claimed link was refreshed concurrently: requests=%d", requests) + } +} + +func TestZammadTicketSyncPublishesLivePanelUpdate(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + publisher := &recordingItemChangePublisher{} + SetItemChangePublisher(publisher) + t.Cleanup(func() { SetItemChangePublisher(nil) }) + + if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil { + t.Fatal(err) + } + if publisher.itemID != f.item1 || publisher.kind != ItemChangeZammad { + t.Fatalf("unexpected live-update event: item=%d kind=%q", publisher.itemID, publisher.kind) + } +} + +func TestZammadTicketSyncFailurePublishesLivePanelUpdate(t *testing.T) { + f := newZammadServiceFixture(t, nil) + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + publisher := &recordingItemChangePublisher{} + SetItemChangePublisher(publisher) + t.Cleanup(func() { SetItemChangePublisher(nil) }) + f.transport.getStatus = http.StatusBadGateway + + if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err == nil { + t.Fatal("expected upstream refresh failure") + } + if publisher.itemID != f.item1 || publisher.kind != ItemChangeZammad { + t.Fatalf("failure did not publish a live-update event: item=%d kind=%q", publisher.itemID, publisher.kind) + } + stored, err := f.service.GetTicketLink(link.ID) + if err != nil || stored.LastError == "" || stored.SyncState != models.ZammadSyncFailed { + t.Fatalf("failure state was not persisted: link=%#v err=%v", stored, err) + } +} + +func TestZammadOAuthConnectionStoresConnectionTokensAndConsumesStateOnce(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests")) + f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, method, targetURL string, body []byte, headers map[string]string) (*zammad.Response, error) { + if method != http.MethodPost || targetURL != "https://oauth.example.test/oauth/token" || headers["Content-Type"] != "application/x-www-form-urlencoded" || strings.Contains(string(body), "synthetic-client-secret") == false { + t.Fatalf("unexpected OAuth token request: %s %s %q %#v", method, targetURL, body, headers) + } + return jsonResponse(http.StatusOK, map[string]any{"access_token": "oauth-access-token", "refresh_token": "oauth-refresh-token", "expires_in": 7200}), nil + })) + connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{ + Slug: "oauth-helpdesk", Name: "OAuth helpdesk", BaseURL: "https://oauth.example.test", + AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "synthetic-client", OAuthClientSecret: "synthetic-client-secret", + DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1}, + }, f.actorID) + if err != nil { + t.Fatal(err) + } + if connection.CredentialID <= 0 || connection.OAuthConnected || connection.HasAPIToken { + t.Fatalf("OAuth connection should have a non-token pending credential before callback: %#v", connection) + } + pending, _, err := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil || strings.Contains(pending, "token") || !strings.Contains(pending, "pending") { + t.Fatalf("OAuth pending credential is unsafe: %q, %v", pending, err) + } + var credentialsBefore int + if err := f.db.QueryRow("SELECT COUNT(*) FROM action_credentials").Scan(&credentialsBefore); err != nil { + t.Fatal(err) + } + if _, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{ + Slug: "oauth-helpdesk", Name: "Duplicate OAuth helpdesk", BaseURL: "https://duplicate.example.test", + AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret", DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1}, + }, f.actorID); !errors.Is(err, repository.ErrDuplicateEntry) { + t.Fatalf("expected duplicate OAuth connection error, got %v", err) + } + var credentialsAfter int + if err := f.db.QueryRow("SELECT COUNT(*) FROM action_credentials").Scan(&credentialsAfter); err != nil || credentialsAfter != credentialsBefore { + t.Fatalf("failed OAuth creation left a managed credential behind: before=%d after=%d err=%v", credentialsBefore, credentialsAfter, err) + } + authURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test") + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(authURL) + if err != nil { + t.Fatal(err) + } + if parsed.Path != "/oauth/authorize" || parsed.Query().Get("scope") != "full" || parsed.Query().Get("redirect_uri") != "https://windshift.example.test/api/integrations/oauth/system/zammad/callback" { + t.Fatalf("unexpected authorization URL: %s", authURL) + } + if _, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "http://windshift.example.test"); err == nil { + t.Fatal("OAuth start accepted a non-HTTPS public base URL") + } + abortedURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test") + if err != nil { + t.Fatal(err) + } + abortedState, err := url.Parse(abortedURL) + if err != nil { + t.Fatal(err) + } + if err := f.service.InvalidateOAuthState(abortedState.Query().Get("state")); err != nil { + t.Fatal(err) + } + invalidated, err := f.service.GetConnection(connection.ProviderID) + if err != nil { + t.Fatal(err) + } + if invalidated.OAuthAttemptID != "" { + t.Fatalf("invalidated OAuth state retained connection attempt %q", invalidated.OAuthAttemptID) + } + if _, err := f.service.CompleteOAuth(context.Background(), abortedState.Query().Get("state"), "aborted-code", "https://windshift.example.test"); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("aborted OAuth state remained usable: %v", err) + } + usableURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test") + if err != nil { + t.Fatal(err) + } + usableState, err := url.Parse(usableURL) + if err != nil { + t.Fatal(err) + } + state := usableState.Query().Get("state") + if _, err := f.service.CompleteOAuth(context.Background(), state, "authorization-code", "https://windshift.example.test"); err != nil { + t.Fatal(err) + } + completed, err := f.service.GetConnection(connection.ProviderID) + if err != nil || !completed.OAuthConnected || completed.HasAPIToken { + t.Fatalf("unexpected OAuth connection status after callback: %#v, %v", completed, err) + } + if _, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test"); err != nil { + t.Fatal(err) + } + newClientID := "rotated-client" + reset, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{OAuthClientID: &newClientID}) + if err != nil || reset.OAuthConnected || reset.ReauthorizationRequired || reset.HasAPIToken { + t.Fatalf("OAuth credential change did not reset authorization: %#v, %v", reset, err) + } + var stateCount, tokenCount int + if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_oauth_state WHERE provider_id = ?", connection.ProviderID).Scan(&stateCount); err != nil { + t.Fatal(err) + } + if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_oauth_tokens WHERE provider_id = ?", connection.ProviderID).Scan(&tokenCount); err != nil { + t.Fatal(err) + } + if stateCount != 0 || tokenCount != 0 { + t.Fatalf("OAuth reset retained state=%d token=%d", stateCount, tokenCount) + } + bundle, err := activeZammadOAuthCredential("access", "refresh") + if err != nil { + t.Fatal(err) + } + if _, err := f.credentials.RotateManaged(connection.CredentialID, models.RotateActionCredentialRequest{Secret: bundle}, string(models.IntegrationProviderZammad), connection.ProviderID); err != nil { + t.Fatal(err) + } + if err := repository.NewZammadRepository(f.db).UpsertOAuthToken(repository.ZammadOAuthToken{ProviderID: connection.ProviderID, ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + if _, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test"); err != nil { + t.Fatal(err) + } + newBaseURL := "https://other-zammad.example.test" + reset, err = f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{BaseURL: &newBaseURL}) + if err != nil || reset.OAuthConnected || reset.ReauthorizationRequired { + t.Fatalf("OAuth base URL change did not reset authorization: %#v, %v", reset, err) + } + if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_oauth_state WHERE provider_id = ?", connection.ProviderID).Scan(&stateCount); err != nil { + t.Fatal(err) + } + if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_oauth_tokens WHERE provider_id = ?", connection.ProviderID).Scan(&tokenCount); err != nil { + t.Fatal(err) + } + if stateCount != 0 || tokenCount != 0 { + t.Fatalf("OAuth base URL reset retained state=%d token=%d", stateCount, tokenCount) + } + if _, err := f.service.CompleteOAuth(context.Background(), state, "authorization-code", "https://windshift.example.test"); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("OAuth state was not consumed exactly once: %v", err) + } + var encryptedBundle string + if err := f.db.QueryRow("SELECT encrypted_secret FROM action_credentials WHERE id = ?", connection.CredentialID).Scan(&encryptedBundle); err != nil { + t.Fatal(err) + } + if encryptedBundle == "oauth-access-token" || strings.Contains(encryptedBundle, "oauth-access-token") || strings.Contains(encryptedBundle, "oauth-refresh-token") { + t.Fatal("OAuth tokens were not encrypted at rest") + } +} + +func createActiveZammadOAuthConnection(t *testing.T, f *zammadServiceFixture, slug string, expiresAt time.Time) *models.ZammadConnection { + t.Helper() + f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests")) + connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{ + Slug: slug, Name: "OAuth " + slug, BaseURL: "https://" + slug + ".example.test", + AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret", + DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1}, + }, f.actorID) + if err != nil { + t.Fatal(err) + } + bundle, err := activeZammadOAuthCredential("old-access", "old-refresh") + if err != nil { + t.Fatal(err) + } + if _, err := f.credentials.RotateManaged(connection.CredentialID, models.RotateActionCredentialRequest{Secret: bundle}, string(models.IntegrationProviderZammad), connection.ProviderID); err != nil { + t.Fatal(err) + } + if err := repository.NewZammadRepository(f.db).UpsertOAuthToken(repository.ZammadOAuthToken{ + ProviderID: connection.ProviderID, OAuthGeneration: connection.OAuthGeneration, ExpiresAt: expiresAt, + }); err != nil { + t.Fatal(err) + } + connection, err = f.service.GetConnection(connection.ProviderID) + if err != nil { + t.Fatal(err) + } + return connection +} + +func assertZammadManagedCredentialMetadata(t *testing.T, f *zammadServiceFixture, connection *models.ZammadConnection, wantName string, wantWorkspaceID int) { + t.Helper() + credential, err := repository.NewActionCredentialRepository(f.db).GetActionCredentialByID(connection.CredentialID) + if err != nil { + t.Fatal(err) + } + if credential.Name != wantName || credential.AppliesToAllWorkspaces || len(credential.WorkspaceIDs) != 1 || credential.WorkspaceIDs[0] != wantWorkspaceID { + t.Fatalf("OAuth secret write overwrote concurrent credential metadata: %#v", credential) + } +} + +func TestZammadOAuthCallbackCannotCommitAfterConfigurationReset(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests")) + connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{ + Slug: "callback-race", Name: "OAuth callback race", BaseURL: "https://callback-race.example.test", + AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret", + DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1}, + }, f.actorID) + if err != nil { + t.Fatal(err) + } + authURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test") + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(authURL) + if err != nil { + t.Fatal(err) + } + entered, release := make(chan struct{}), make(chan struct{}) + f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) { + close(entered) + <-release + return jsonResponse(http.StatusOK, map[string]any{"access_token": "stale-access", "refresh_token": "stale-refresh", "expires_in": 3600}), nil + })) + type callbackResult struct { + err error + } + done := make(chan callbackResult, 1) + go func() { + _, err := f.service.CompleteOAuth(context.Background(), parsed.Query().Get("state"), "code", "https://windshift.example.test") + done <- callbackResult{err: err} + }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("OAuth callback did not reach token exchange") + } + newClientID := "replacement-client" + reset, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{OAuthClientID: &newClientID}) + if err != nil { + t.Fatal(err) + } + close(release) + result := <-done + if !errors.Is(result.err, ErrZammadOAuthSuperseded) { + t.Fatalf("stale callback commit error = %v", result.err) + } + if reset.OAuthGeneration <= connection.OAuthGeneration || reset.OAuthConnected { + t.Fatalf("reset did not advance generation and clear authorization: %#v", reset) + } + raw, _, err := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil || !strings.Contains(raw, `"status":"pending"`) || strings.Contains(raw, "stale-access") { + t.Fatalf("stale callback reactivated credential: raw=%q err=%v", raw, err) + } +} + +func TestZammadOAuthCallbackCannotCommitAfterConnectionIsDisabled(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests")) + connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{ + Slug: "callback-disable", Name: "OAuth callback disable", BaseURL: "https://callback-disable.example.test", + AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret", + DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1}, + }, f.actorID) + if err != nil { + t.Fatal(err) + } + authURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test") + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(authURL) + if err != nil { + t.Fatal(err) + } + entered, release := make(chan struct{}), make(chan struct{}) + f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) { + close(entered) + <-release + return jsonResponse(http.StatusOK, map[string]any{"access_token": "stale-access", "refresh_token": "stale-refresh", "expires_in": 3600}), nil + })) + done := make(chan error, 1) + go func() { + _, callbackErr := f.service.CompleteOAuth(context.Background(), parsed.Query().Get("state"), "code", "https://windshift.example.test") + done <- callbackErr + }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("OAuth callback did not reach token exchange") + } + disabled := false + if _, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{Enabled: &disabled}); err != nil { + t.Fatal(err) + } + close(release) + if err := <-done; !errors.Is(err, ErrZammadOAuthSuperseded) { + t.Fatalf("disabled connection accepted OAuth credentials: %v", err) + } + if _, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test"); err == nil { + t.Fatal("disabled connection allowed a new OAuth attempt") + } +} + +func TestZammadOAuthCallbackPreservesConcurrentNameAndScopeUpdate(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests")) + connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{ + Slug: "callback-metadata-race", Name: "Original callback name", BaseURL: "https://callback-metadata-race.example.test", + AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret", + DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1}, + }, f.actorID) + if err != nil { + t.Fatal(err) + } + authURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test") + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(authURL) + if err != nil { + t.Fatal(err) + } + entered, release := make(chan struct{}), make(chan struct{}) + f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) { + close(entered) + <-release + return jsonResponse(http.StatusOK, map[string]any{"access_token": "callback-access", "refresh_token": "callback-refresh", "expires_in": 3600}), nil + })) + done := make(chan error, 1) + go func() { + _, err := f.service.CompleteOAuth(context.Background(), parsed.Query().Get("state"), "code", "https://windshift.example.test") + done <- err + }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("OAuth callback did not reach token exchange") + } + updatedName := "Renamed during callback" + updatedScope := []int{f.workspace2} + if _, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{Name: &updatedName, WorkspaceIDs: &updatedScope}); err != nil { + t.Fatal(err) + } + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } + assertZammadManagedCredentialMetadata(t, f, connection, updatedName+" Zammad OAuth credentials", f.workspace2) + raw, _, err := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace2, string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil { + t.Fatal(err) + } + bundle, err := parseZammadOAuthCredential(raw) + if err != nil || bundle.AccessToken != "callback-access" { + t.Fatalf("callback secret was not committed: bundle=%#v err=%v", bundle, err) + } +} + +func TestZammadOAuthParallelStartsLeaveOnlyNewestUsableState(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests")) + connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{ + Slug: "parallel-state", Name: "Parallel OAuth state", BaseURL: "https://parallel-state.example.test", + AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret", + DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1}, + }, f.actorID) + if err != nil { + t.Fatal(err) + } + f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) { + return jsonResponse(http.StatusOK, map[string]any{"access_token": "parallel-access", "refresh_token": "parallel-refresh", "expires_in": 3600}), nil + })) + type startResult struct { + state string + err error + } + results := make(chan startResult, 2) + start := func() { + authURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test") + if err != nil { + results <- startResult{err: err} + return + } + parsed, parseErr := url.Parse(authURL) + if parseErr != nil { + results <- startResult{err: parseErr} + return + } + results <- startResult{state: parsed.Query().Get("state")} + } + go start() + go start() + first, second := <-results, <-results + if first.err != nil || second.err != nil || first.state == "" || second.state == "" || first.state == second.state { + t.Fatalf("parallel OAuth starts failed: first=%#v second=%#v", first, second) + } + var persistedState string + var stateCount int + if err := f.db.QueryRow("SELECT state FROM zammad_oauth_state WHERE provider_id = ?", connection.ProviderID).Scan(&persistedState); err != nil { + t.Fatal(err) + } + if err := f.db.QueryRow("SELECT COUNT(*) FROM zammad_oauth_state WHERE provider_id = ?", connection.ProviderID).Scan(&stateCount); err != nil || stateCount != 1 { + t.Fatalf("parallel starts retained %d states: %v", stateCount, err) + } + staleState := first.state + if persistedState == first.state { + staleState = second.state + } + if _, err := f.service.CompleteOAuth(context.Background(), staleState, "stale-code", "https://windshift.example.test"); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("superseded parallel state remained usable: %v", err) + } + if _, err := f.service.CompleteOAuth(context.Background(), persistedState, "winning-code", "https://windshift.example.test"); err != nil { + t.Fatalf("winning parallel state failed: %v", err) + } +} + +func TestZammadOAuthNewStartSupersedesAlreadyRunningCallback(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests")) + connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{ + Slug: "running-callback", Name: "Running OAuth callback", BaseURL: "https://running-callback.example.test", + AuthMethod: models.ZammadAuthMethodOAuth, OAuthClientID: "client", OAuthClientSecret: "secret", + DefaultGroupName: "Support", DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1}, + }, f.actorID) + if err != nil { + t.Fatal(err) + } + oldURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test") + if err != nil { + t.Fatal(err) + } + oldParsed, err := url.Parse(oldURL) + if err != nil { + t.Fatal(err) + } + entered, release := make(chan struct{}), make(chan struct{}) + var transportMu sync.Mutex + transportCalls := 0 + f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) { + transportMu.Lock() + transportCalls++ + call := transportCalls + transportMu.Unlock() + if call == 1 { + close(entered) + <-release + return jsonResponse(http.StatusOK, map[string]any{"access_token": "superseded-access", "refresh_token": "superseded-refresh", "expires_in": 3600}), nil + } + return jsonResponse(http.StatusOK, map[string]any{"access_token": "winning-access", "refresh_token": "winning-refresh", "expires_in": 3600}), nil + })) + oldDone := make(chan error, 1) + go func() { + _, err := f.service.CompleteOAuth(context.Background(), oldParsed.Query().Get("state"), "old-code", "https://windshift.example.test") + oldDone <- err + }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("old callback did not reach token exchange") + } + newURL, err := f.service.StartOAuth(context.Background(), connection.ProviderID, f.actorID, "https://windshift.example.test") + if err != nil { + t.Fatal(err) + } + newParsed, err := url.Parse(newURL) + if err != nil { + t.Fatal(err) + } + close(release) + if err := <-oldDone; !errors.Is(err, ErrZammadOAuthSuperseded) { + t.Fatalf("already-running old callback commit error = %v", err) + } + if _, err := f.service.CompleteOAuth(context.Background(), newParsed.Query().Get("state"), "new-code", "https://windshift.example.test"); err != nil { + t.Fatalf("new callback failed after superseding running callback: %v", err) + } + raw, _, err := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil { + t.Fatal(err) + } + bundle, err := parseZammadOAuthCredential(raw) + if err != nil || bundle.AccessToken != "winning-access" || bundle.RefreshToken != "winning-refresh" { + t.Fatalf("superseded callback overwrote winning tokens: bundle=%#v err=%v", bundle, err) + } +} + +func TestZammadOAuthRefreshCannotCommitAfterConfigurationReset(t *testing.T) { + f := newZammadServiceFixture(t, nil) + connection := createActiveZammadOAuthConnection(t, f, "refresh-race", time.Now().Add(-time.Minute)) + entered, release := make(chan struct{}), make(chan struct{}) + f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) { + close(entered) + <-release + return jsonResponse(http.StatusOK, map[string]any{"access_token": "stale-refreshed-access", "refresh_token": "stale-refreshed-refresh", "expires_in": 3600}), nil + })) + done := make(chan error, 1) + go func() { + _, err := f.service.oauthAccessToken(context.Background(), connection, f.workspace1) + done <- err + }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("OAuth refresh did not reach token exchange") + } + newClientID := "replacement-client" + reset, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{OAuthClientID: &newClientID}) + if err != nil { + t.Fatal(err) + } + close(release) + if err := <-done; !errors.Is(err, ErrZammadOAuthSuperseded) { + t.Fatalf("stale refresh commit error = %v", err) + } + if reset.OAuthGeneration <= connection.OAuthGeneration || reset.OAuthConnected { + t.Fatalf("reset did not advance generation and clear authorization: %#v", reset) + } + raw, _, err := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil || !strings.Contains(raw, `"status":"pending"`) || strings.Contains(raw, "stale-refreshed-access") { + t.Fatalf("stale refresh reactivated credential: raw=%q err=%v", raw, err) + } +} + +func TestZammadOAuthRefreshPreservesConcurrentNameAndScopeUpdate(t *testing.T) { + f := newZammadServiceFixture(t, nil) + connection := createActiveZammadOAuthConnection(t, f, "refresh-metadata-race", time.Now().Add(-time.Minute)) + entered, release := make(chan struct{}), make(chan struct{}) + f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) { + close(entered) + <-release + return jsonResponse(http.StatusOK, map[string]any{"access_token": "refreshed-access", "refresh_token": "refreshed-refresh", "expires_in": 3600}), nil + })) + done := make(chan struct { + token string + err error + }, 1) + go func() { + token, err := f.service.oauthAccessToken(context.Background(), connection, f.workspace1) + done <- struct { + token string + err error + }{token: token, err: err} + }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("OAuth refresh did not reach token exchange") + } + updatedName := "Renamed during refresh" + updatedScope := []int{f.workspace2} + if _, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{Name: &updatedName, WorkspaceIDs: &updatedScope}); err != nil { + t.Fatal(err) + } + close(release) + result := <-done + if result.err != nil || result.token != "refreshed-access" { + t.Fatalf("refresh result: token=%q err=%v", result.token, result.err) + } + assertZammadManagedCredentialMetadata(t, f, connection, updatedName+" Zammad OAuth credentials", f.workspace2) +} + +func TestZammadOAuthConcurrentRefreshWaitsForRotatedCredential(t *testing.T) { + f := newZammadServiceFixture(t, nil) + connection := createActiveZammadOAuthConnection(t, f, "refresh-contention", time.Now().Add(-time.Minute)) + entered, release := make(chan struct{}), make(chan struct{}) + var transportMu sync.Mutex + transportCalls := 0 + f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) { + transportMu.Lock() + transportCalls++ + call := transportCalls + transportMu.Unlock() + if call == 1 { + close(entered) + <-release + } + return jsonResponse(http.StatusOK, map[string]any{"access_token": "shared-refreshed-access", "refresh_token": "shared-refreshed-refresh", "expires_in": 3600}), nil + })) + type tokenResult struct { + token string + err error + } + results := make(chan tokenResult, 2) + refresh := func() { + token, err := f.service.oauthAccessToken(context.Background(), connection, f.workspace1) + results <- tokenResult{token: token, err: err} + } + go refresh() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("first OAuth refresh did not reach token exchange") + } + go refresh() + time.Sleep(2 * zammadOAuthRefreshPollInterval) + transportMu.Lock() + callsBeforeRelease := transportCalls + transportMu.Unlock() + if callsBeforeRelease != 1 { + t.Fatalf("contending request issued a second refresh: calls=%d", callsBeforeRelease) + } + close(release) + first, second := <-results, <-results + for i, result := range []tokenResult{first, second} { + if result.err != nil || result.token != "shared-refreshed-access" { + t.Fatalf("refresh result %d did not reuse rotated access token: token=%q err=%v", i+1, result.token, result.err) + } + } + transportMu.Lock() + finalCalls := transportCalls + transportMu.Unlock() + if finalCalls != 1 { + t.Fatalf("parallel refresh used the refresh token %d times", finalCalls) + } +} + +func TestZammadOAuthRefreshCommitRequiresCurrentClaimOwner(t *testing.T) { + f := newZammadServiceFixture(t, nil) + connection := createActiveZammadOAuthConnection(t, f, "claim-loss", time.Now().Add(-time.Minute)) + entered, release := make(chan struct{}), make(chan struct{}) + f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) { + close(entered) + <-release + return jsonResponse(http.StatusOK, map[string]any{"access_token": "claim-lost-access", "refresh_token": "claim-lost-refresh", "expires_in": 3600}), nil + })) + done := make(chan error, 1) + go func() { + _, err := f.service.oauthAccessToken(context.Background(), connection, f.workspace1) + done <- err + }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("OAuth refresh did not reach token exchange") + } + var claimOwner string + var leaseUntil time.Time + if err := f.db.QueryRow("SELECT refresh_claim_owner, refresh_lock_until FROM zammad_oauth_tokens WHERE provider_id = ?", connection.ProviderID).Scan(&claimOwner, &leaseUntil); err != nil { + t.Fatal(err) + } + if claimOwner == "" || time.Until(leaseUntil) <= zammadHTTPTimeout { + t.Fatalf("refresh lease is not safely longer than HTTP timeout: owner=%q remaining=%s", claimOwner, time.Until(leaseUntil)) + } + if _, err := f.db.ExecWrite("UPDATE zammad_oauth_tokens SET refresh_claim_owner = ? WHERE provider_id = ?", "replacement-owner", connection.ProviderID); err != nil { + t.Fatal(err) + } + close(release) + if err := <-done; !errors.Is(err, ErrZammadOAuthSuperseded) { + t.Fatalf("claim-lost refresh commit error = %v", err) + } + raw, _, err := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil { + t.Fatal(err) + } + bundle, err := parseZammadOAuthCredential(raw) + if err != nil || bundle.AccessToken != "old-access" || bundle.RefreshToken != "old-refresh" { + t.Fatalf("claim-lost refresh overwrote credential: bundle=%#v err=%v", bundle, err) + } +} + +func TestZammadOAuthRefreshRereadsRotatedCredentialAfterClaim(t *testing.T) { + f := newZammadServiceFixture(t, nil) + connection := createActiveZammadOAuthConnection(t, f, "refresh-reread", time.Now().Add(-time.Minute)) + beforeClaim, continueClaim := make(chan struct{}), make(chan struct{}) + f.service.SetOAuthBeforeRefreshClaimForTesting(func() { + close(beforeClaim) + <-continueClaim + }) + var transportMu sync.Mutex + transportCalls := 0 + f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) { + transportMu.Lock() + transportCalls++ + transportMu.Unlock() + return jsonResponse(http.StatusOK, map[string]any{"access_token": "unexpected-access", "refresh_token": "unexpected-refresh", "expires_in": 3600}), nil + })) + done := make(chan struct { + token string + err error + }, 1) + go func() { + token, err := f.service.oauthAccessToken(context.Background(), connection, f.workspace1) + done <- struct { + token string + err error + }{token: token, err: err} + }() + select { + case <-beforeClaim: + case <-time.After(5 * time.Second): + t.Fatal("second refresh request did not pause before its claim") + } + rotatedBundle, err := activeZammadOAuthCredential("already-refreshed-access", "already-rotated-refresh") + if err != nil { + t.Fatal(err) + } + if _, err := f.credentials.RotateManaged(connection.CredentialID, models.RotateActionCredentialRequest{Secret: rotatedBundle}, string(models.IntegrationProviderZammad), connection.ProviderID); err != nil { + t.Fatal(err) + } + if err := repository.NewZammadRepository(f.db).UpsertOAuthToken(repository.ZammadOAuthToken{ + ProviderID: connection.ProviderID, OAuthGeneration: connection.OAuthGeneration, ExpiresAt: time.Now().Add(time.Hour), + }); err != nil { + t.Fatal(err) + } + close(continueClaim) + result := <-done + if result.err != nil || result.token != "already-refreshed-access" { + t.Fatalf("second request did not reuse freshly rotated credential: token=%q err=%v", result.token, result.err) + } + transportMu.Lock() + calls := transportCalls + transportMu.Unlock() + if calls != 0 { + t.Fatalf("second request reused a stale refresh token in %d upstream calls", calls) + } + var owner *string + if err := f.db.QueryRow("SELECT refresh_claim_owner FROM zammad_oauth_tokens WHERE provider_id = ?", connection.ProviderID).Scan(&owner); err != nil { + t.Fatal(err) + } + if owner != nil { + t.Fatalf("fresh-token fast path retained refresh claim %q", *owner) + } +} + +func TestZammadOAuthInvalidGrantReturnsPersistenceFailure(t *testing.T) { + f := newZammadServiceFixture(t, nil) + connection := createActiveZammadOAuthConnection(t, f, "invalid-grant-persist", time.Now().Add(-time.Minute)) + f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) { + return jsonResponse(http.StatusBadRequest, map[string]string{"error": "invalid_grant"}), nil + })) + if _, err := f.db.ExecWrite(`CREATE TRIGGER fail_zammad_reauthorization + BEFORE UPDATE ON action_credentials + BEGIN SELECT RAISE(ABORT, 'synthetic credential persistence failure'); END`); err != nil { + t.Fatal(err) + } + _, err := f.service.oauthAccessToken(context.Background(), connection, f.workspace1) + if err == nil || errors.Is(err, ErrZammadReauthorizationRequired) { + t.Fatalf("invalid_grant persistence failure was hidden: %v", err) + } + token, tokenErr := repository.NewZammadRepository(f.db).GetOAuthToken(connection.ProviderID) + if tokenErr != nil || token.ReauthorizationRequired { + t.Fatalf("failed transaction partially marked reauthorization: token=%#v err=%v", token, tokenErr) + } + raw, _, resolveErr := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace1, string(models.IntegrationProviderZammad), connection.ProviderID) + if resolveErr != nil { + t.Fatal(resolveErr) + } + bundle, parseErr := parseZammadOAuthCredential(raw) + if parseErr != nil || bundle.AccessToken != "old-access" { + t.Fatalf("failed transaction partially replaced credential: bundle=%#v err=%v", bundle, parseErr) + } +} + +func TestZammadOAuthInvalidGrantPreservesConcurrentNameAndScopeUpdate(t *testing.T) { + f := newZammadServiceFixture(t, nil) + connection := createActiveZammadOAuthConnection(t, f, "invalid-grant-metadata-race", time.Now().Add(-time.Minute)) + entered, release := make(chan struct{}), make(chan struct{}) + f.service.SetOAuthTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, _ map[string]string) (*zammad.Response, error) { + close(entered) + <-release + return jsonResponse(http.StatusBadRequest, map[string]string{"error": "invalid_grant"}), nil + })) + done := make(chan error, 1) + go func() { + _, err := f.service.oauthAccessToken(context.Background(), connection, f.workspace1) + done <- err + }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("OAuth invalid_grant test did not reach token exchange") + } + updatedName := "Renamed during invalid grant" + updatedScope := []int{f.workspace2} + if _, err := f.service.UpdateConnection(connection.ProviderID, models.UpdateZammadConnectionRequest{Name: &updatedName, WorkspaceIDs: &updatedScope}); err != nil { + t.Fatal(err) + } + close(release) + if err := <-done; !errors.Is(err, ErrZammadReauthorizationRequired) { + t.Fatalf("invalid_grant result = %v", err) + } + assertZammadManagedCredentialMetadata(t, f, connection, updatedName+" Zammad OAuth credentials", f.workspace2) + raw, _, err := f.credentials.ResolveManaged(context.Background(), connection.CredentialID, f.workspace2, string(models.IntegrationProviderZammad), connection.ProviderID) + if err != nil || !strings.Contains(raw, `"status":"reauthorization_required"`) { + t.Fatalf("invalid_grant did not persist reauthorization secret: raw=%q err=%v", raw, err) + } +} + +func TestZammadOAuthTestSkipsAdminOnlyCorrelationFieldCheck(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.service.SetOAuthEncryption(sso.NewSecretEncryption("synthetic-server-secret-for-zammad-tests")) + connection, err := f.service.CreateConnection(models.CreateZammadConnectionRequest{ + Slug: "oauth-scope", Name: "Scoped OAuth", BaseURL: "https://scope.example.test", AuthMethod: models.ZammadAuthMethodOAuth, + OAuthClientID: "client", OAuthClientSecret: "secret", DefaultGroupID: 7, DefaultGroupName: "Support", + AllowedGroups: zammadTestGroupRefs(7), DefaultCustomer: "robot@example.test", WorkspaceIDs: []int{f.workspace1}, + }, f.actorID) + if err != nil { + t.Fatal(err) + } + bundle, err := activeZammadOAuthCredential("access", "refresh") + if err != nil { + t.Fatal(err) + } + if _, err := f.credentials.RotateManaged(connection.CredentialID, models.RotateActionCredentialRequest{Secret: bundle}, string(models.IntegrationProviderZammad), connection.ProviderID); err != nil { + t.Fatal(err) + } + if err := repository.NewZammadRepository(f.db).UpsertOAuthToken(repository.ZammadOAuthToken{ProviderID: connection.ProviderID, ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + calledObjectManager := false + f.service.SetTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, targetURL string, _ []byte, _ map[string]string) (*zammad.Response, error) { + if strings.Contains(targetURL, "object_manager_attributes") { + calledObjectManager = true + return jsonResponse(http.StatusForbidden, map[string]string{}), nil + } + return jsonResponse(http.StatusOK, []map[string]any{{"id": 2, "name": "open", "active": true}}), nil + })) + metadata, err := f.service.TestConnection(context.Background(), connection.ProviderID) + if err != nil || metadata.CorrelationFieldVerified || calledObjectManager { + t.Fatalf("OAuth test must not require admin.object: metadata=%#v err=%v called=%v", metadata, err, calledObjectManager) + } +} + +func TestZammadAPITokenTestSkipsAdminOnlyCorrelationFieldCheck(t *testing.T) { + f := newZammadServiceFixture(t, nil) + calledObjectManager := false + f.service.SetTransportForTesting(zammad.TransportFunc(func(_ context.Context, _ string, targetURL string, _ []byte, _ map[string]string) (*zammad.Response, error) { + if strings.Contains(targetURL, "object_manager_attributes") { + calledObjectManager = true + return jsonResponse(http.StatusForbidden, map[string]string{}), nil + } + return jsonResponse(http.StatusOK, []map[string]any{{"id": 2, "name": "open", "active": true}}), nil + })) + metadata, err := f.service.TestConnection(context.Background(), f.connection.ProviderID) + if err != nil || metadata.CorrelationFieldVerified || calledObjectManager { + t.Fatalf("API-token test must not require admin.object: metadata=%#v err=%v called=%v", metadata, err, calledObjectManager) + } +} + +func TestZammadSafeTransportHonorsAllowLocalConnections(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/ticket_states" { + t.Fatalf("unexpected path %q", r.URL.Path) + } + _, _ = w.Write([]byte(`[]`)) + })) + defer server.Close() + previous := utils.AllowLocalConnections() + defer utils.SetAllowLocalConnections(previous) + transport := newZammadSafeTransport(server.URL, "/api/v1/") + utils.SetAllowLocalConnections(true) + if _, err := transport.Do(context.Background(), http.MethodGet, server.URL+"/api/v1/ticket_states", nil, nil); err != nil { + t.Fatalf("ALLOW_LOCAL_CONNECTIONS=true blocked local Zammad target: %v", err) + } + utils.SetAllowLocalConnections(false) + if _, err := transport.Do(context.Background(), http.MethodGet, server.URL+"/api/v1/ticket_states", nil, nil); !errors.Is(err, utils.ErrBlockedSSRFAddr) { + t.Fatalf("ALLOW_LOCAL_CONNECTIONS=false did not block local Zammad target: %v", err) + } +} + +func TestZammadClientsUseExactlyOneExpectedAuthorizationScheme(t *testing.T) { + for _, testCase := range []struct { + name, want string + client *zammad.Client + }{ + {name: "legacy", want: "Token token=legacy-token", client: zammad.NewClient("https://zammad.example.test", "legacy-token", zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, headers map[string]string) (*zammad.Response, error) { + if headers["Authorization"] != "Token token=legacy-token" { + t.Fatalf("legacy authorization = %q", headers["Authorization"]) + } + return jsonResponse(http.StatusOK, []map[string]any{}), nil + }))}, + {name: "oauth", want: "Bearer oauth-token", client: zammad.NewOAuthClient("https://zammad.example.test", "oauth-token", zammad.TransportFunc(func(_ context.Context, _ string, _ string, _ []byte, headers map[string]string) (*zammad.Response, error) { + if headers["Authorization"] != "Bearer oauth-token" { + t.Fatalf("OAuth authorization = %q", headers["Authorization"]) + } + return jsonResponse(http.StatusOK, []map[string]any{}), nil + }))}, + } { + t.Run(testCase.name, func(t *testing.T) { + if _, err := testCase.client.States(context.Background()); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/internal/services/zammad_transport.go b/internal/services/zammad_transport.go new file mode 100644 index 000000000..6dbbe481a --- /dev/null +++ b/internal/services/zammad_transport.go @@ -0,0 +1,69 @@ +package services + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "windshift/internal/integrations/zammad" + "windshift/internal/utils" +) + +var errZammadRedirect = errors.New("zammad redirects are not allowed") + +const zammadHTTPTimeout = 30 * time.Second + +// newZammadSafeTransport is deliberately separate from the generic action +// transport. It keeps the exact configured Zammad origin and endpoint prefix, +// while its dialer honors the operator's ALLOW_LOCAL_CONNECTIONS policy. +func newZammadSafeTransport(baseURL, allowedPathPrefix string) zammad.Transport { + base, err := url.Parse(baseURL) + return zammad.TransportFunc(func(ctx context.Context, method, targetURL string, body []byte, headers map[string]string) (*zammad.Response, error) { + if err != nil || base == nil || base.Scheme == "" || base.Host == "" { + return nil, errors.New("invalid Zammad base URL") + } + target, err := url.Parse(targetURL) + if err != nil || target == nil || target.Scheme == "" || target.Host == "" { + return nil, errors.New("invalid Zammad request URL") + } + allowedPath := strings.TrimRight(base.EscapedPath(), "/") + allowedPathPrefix + pathAllowed := target.EscapedPath() == allowedPath + if strings.HasSuffix(allowedPath, "/") { + pathAllowed = strings.HasPrefix(target.EscapedPath(), allowedPath) + } + if target.User != nil || target.Fragment != "" || target.Scheme != base.Scheme || !strings.EqualFold(target.Host, base.Host) || !pathAllowed { + return nil, errors.New("zammad request URL is outside the allowed origin or path") + } + request, err := http.NewRequestWithContext(ctx, method, target.String(), strings.NewReader(string(body))) + if err != nil { + return nil, errors.New("could not create Zammad request") + } + for key, value := range headers { + request.Header.Set(key, value) + } + transport := utils.ConfigureHTTPTransport(&http.Transport{DialContext: utils.SafeNetDialer(10 * time.Second).DialContext, DisableKeepAlives: true}) + client := &http.Client{Timeout: zammadHTTPTimeout, Transport: transport, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return errZammadRedirect }} + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("zammad request failed: %w", err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode >= 300 && response.StatusCode < 400 { + return nil, errZammadRedirect + } + const maxResponseBytes = 1 << 20 + responseBody, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1)) + if err != nil { + return nil, errors.New("could not read Zammad response") + } + if len(responseBody) > maxResponseBytes { + return nil, errors.New("zammad response exceeds 1 MiB") + } + return &zammad.Response{StatusCode: response.StatusCode, Body: responseBody}, nil + }) +}