From dab00a54ab59fc8d88cae6dd632aa5a6c3b9df37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 15 May 2026 22:27:56 +0300 Subject: [PATCH] feat(settings): ApiTab wired to api-key + webhook endpoints (closes D2-D5) Audit D2/D3/D4/D5: all four ApiTab buttons were handler-less and the fields were hardcoded. Adds api/apiKeys.ts + api/webhooks.ts modules and rewires ApiTab: loads the api-key prefix + webhook settings on mount; Copy -> clipboard + snackbar; Regenerate -> confirm dialog -> POST regenerate (full key shown once); Save Webhook -> PUT webhook-settings; Test Webhook -> POST test with the result in a snackbar. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/resources/js/api/apiKeys.ts | 32 +++ app/resources/js/api/webhooks.ts | 40 +++ app/resources/js/views/settings/ApiTab.vue | 299 +++++++++++++++++++-- app/tests/Frontend/ApiTab.spec.ts | 134 +++++++++ 4 files changed, 490 insertions(+), 15 deletions(-) create mode 100644 app/resources/js/api/apiKeys.ts create mode 100644 app/resources/js/api/webhooks.ts create mode 100644 app/tests/Frontend/ApiTab.spec.ts diff --git a/app/resources/js/api/apiKeys.ts b/app/resources/js/api/apiKeys.ts new file mode 100644 index 00000000..bc063792 --- /dev/null +++ b/app/resources/js/api/apiKeys.ts @@ -0,0 +1,32 @@ +import { apiClient, ensureCsrfCookie } from './client'; + +/** + * API-ключи тенанта (audit D2/D3). Backend: ApiKeyController. + * Полный ключ доступен только в ответе regenerateApiKey(). + */ +export interface ApiKeyInfo { + id: number; + name: string; + key_prefix: string; + last_used_at: string | null; + expires_at: string | null; + created_at: string | null; +} + +export interface RegeneratedApiKey { + id: number; + name: string; + key: string; + key_prefix: string; +} + +export async function listApiKeys(): Promise { + const { data } = await apiClient.get<{ data: ApiKeyInfo[] }>('/api/api-keys'); + return data.data; +} + +export async function regenerateApiKey(): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.post('/api/api-keys/regenerate'); + return data; +} diff --git a/app/resources/js/api/webhooks.ts b/app/resources/js/api/webhooks.ts new file mode 100644 index 00000000..529f66e4 --- /dev/null +++ b/app/resources/js/api/webhooks.ts @@ -0,0 +1,40 @@ +import { apiClient, ensureCsrfCookie } from './client'; + +/** + * Настройки исходящего webhook'а тенанта (audit D4/D5). Backend: + * WebhookSettingsController. Полный secret доступен только в ответе + * saveWebhookSettings() при первом создании подписки. + */ +export interface WebhookSettings { + target_url: string; + secret_prefix: string; + events: string[]; + is_active: boolean; +} + +export interface SavedWebhookSettings extends WebhookSettings { + secret?: string; +} + +export interface WebhookTestResult { + ok: boolean; + status: number | null; + message: string; +} + +export async function getWebhookSettings(): Promise { + const { data } = await apiClient.get<{ data: WebhookSettings | null }>('/api/tenants/me/webhook-settings'); + return data.data; +} + +export async function saveWebhookSettings(payload: { target_url: string }): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.put<{ data: SavedWebhookSettings }>('/api/tenants/me/webhook-settings', payload); + return data.data; +} + +export async function testWebhook(): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.post('/api/webhooks/test'); + return data; +} diff --git a/app/resources/js/views/settings/ApiTab.vue b/app/resources/js/views/settings/ApiTab.vue index fff1f83f..0d1d8eef 100644 --- a/app/resources/js/views/settings/ApiTab.vue +++ b/app/resources/js/views/settings/ApiTab.vue @@ -1,18 +1,150 @@ diff --git a/app/tests/Frontend/ApiTab.spec.ts b/app/tests/Frontend/ApiTab.spec.ts new file mode 100644 index 00000000..9e3edc63 --- /dev/null +++ b/app/tests/Frontend/ApiTab.spec.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +vi.mock('../../resources/js/api/apiKeys', () => ({ + listApiKeys: vi.fn(), + regenerateApiKey: vi.fn(), +})); + +vi.mock('../../resources/js/api/webhooks', () => ({ + getWebhookSettings: vi.fn(), + saveWebhookSettings: vi.fn(), + testWebhook: vi.fn(), +})); + +vi.mock('../../resources/js/api/client', () => ({ + apiClient: {}, + ensureCsrfCookie: vi.fn(), + extractValidationErrors: vi.fn(() => null), + extractErrorMessage: vi.fn((_e, fallback) => fallback ?? 'Произошла ошибка.'), + extractRateLimitRetry: vi.fn(() => null), +})); + +import * as apiKeysApi from '../../resources/js/api/apiKeys'; +import * as webhooksApi from '../../resources/js/api/webhooks'; +import ApiTab from '../../resources/js/views/settings/ApiTab.vue'; + +const vuetify = createVuetify(); + +const flush = () => new Promise((r) => setTimeout(r, 30)); + +const mountTab = () => mount(ApiTab, { global: { plugins: [vuetify] } }); + +describe('ApiTab.vue', () => { + beforeEach(() => { + vi.clearAllMocks(); + (apiKeysApi.listApiKeys as ReturnType).mockResolvedValue([]); + (webhooksApi.getWebhookSettings as ReturnType).mockResolvedValue(null); + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }); + }); + + it('загружает и показывает префикс API-ключа', async () => { + (apiKeysApi.listApiKeys as ReturnType).mockResolvedValue([ + { id: 1, name: 'API-ключ', key_prefix: 'lpkapi_abc', last_used_at: null, expires_at: null, created_at: null }, + ]); + const wrapper = mountTab(); + await flush(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vm = wrapper.vm as any; + expect(vm.apiKeyExists).toBe(true); + expect(vm.apiTokenDisplay).toBe('lpkapi_abc'); + }); + + it('copyToken() пишет в буфер и открывает toast', async () => { + (apiKeysApi.listApiKeys as ReturnType).mockResolvedValue([ + { id: 1, name: 'API-ключ', key_prefix: 'lpkapi_abc', last_used_at: null, expires_at: null, created_at: null }, + ]); + const wrapper = mountTab(); + await flush(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vm = wrapper.vm as any; + await vm.copyToken(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('lpkapi_abc'); + expect(vm.toastOpen).toBe(true); + }); + + it('confirmRegenerate() показывает полный новый ключ один раз', async () => { + (apiKeysApi.regenerateApiKey as ReturnType).mockResolvedValue({ + id: 2, + name: 'API-ключ', + key: 'lpkapi_FULLNEWKEYVALUE0000000000', + key_prefix: 'lpkapi_FUL', + }); + const wrapper = mountTab(); + await flush(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vm = wrapper.vm as any; + await vm.confirmRegenerate(); + expect(apiKeysApi.regenerateApiKey).toHaveBeenCalled(); + expect(vm.apiTokenDisplay).toBe('lpkapi_FULLNEWKEYVALUE0000000000'); + expect(vm.fullKeyShown).toBe(true); + }); + + it('загружает настройки webhook', async () => { + (webhooksApi.getWebhookSettings as ReturnType).mockResolvedValue({ + target_url: 'https://crm.example.ru/hook', + secret_prefix: 'whsec_abc', + events: ['deal.created'], + is_active: true, + }); + const wrapper = mountTab(); + await flush(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vm = wrapper.vm as any; + expect(vm.webhookUrl).toBe('https://crm.example.ru/hook'); + expect(vm.secretDisplay).toBe('whsec_abc'); + }); + + it('saveWebhook() вызывает saveWebhookSettings и показывает secret один раз', async () => { + (webhooksApi.saveWebhookSettings as ReturnType).mockResolvedValue({ + target_url: 'https://crm.example.ru/hook', + secret_prefix: 'whsec_new', + events: ['deal.created'], + is_active: true, + secret: 'whsec_FULLSECRETVALUE0000', + }); + const wrapper = mountTab(); + await flush(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vm = wrapper.vm as any; + vm.webhookUrl = 'https://crm.example.ru/hook'; + await vm.saveWebhook(); + expect(webhooksApi.saveWebhookSettings).toHaveBeenCalledWith({ target_url: 'https://crm.example.ru/hook' }); + expect(vm.secretDisplay).toBe('whsec_FULLSECRETVALUE0000'); + expect(vm.fullSecretShown).toBe(true); + expect(vm.webhookSuccess).toBeTruthy(); + }); + + it('runWebhookTest() вызывает testWebhook и открывает toast с результатом', async () => { + (webhooksApi.testWebhook as ReturnType).mockResolvedValue({ + ok: true, + status: 200, + message: 'Тестовый запрос доставлен (HTTP 200).', + }); + const wrapper = mountTab(); + await flush(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vm = wrapper.vm as any; + await vm.runWebhookTest(); + expect(webhooksApi.testWebhook).toHaveBeenCalled(); + expect(vm.toastOpen).toBe(true); + expect(vm.toastText).toContain('HTTP 200'); + }); +});