Plan B of the Sprint 2 split — the Settings subsystem, 5 atomic TDD tasks: PATCH /api/auth/me profile endpoint (J6); ProfileTab rewired to real API (D1); ApiKey model + api-keys endpoints (J5/D3); outbound webhook settings endpoints (J5/D4/D5); ApiTab full wiring (D2-D5). Schema delta = 0 — api_keys + outbound_webhook_subscriptions tables already exist in schema.sql. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
77 KiB
Sprint 2 Plan B — Settings subsystem (D1-D5 + J5 + J6) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the Settings → Профиль and Settings → API tabs fully functional from UI to DB: profile save (PATCH /api/auth/me), API-key copy/regenerate, and webhook URL save/test — backed by real endpoints and two new Eloquent models.
Architecture: Five atomic tasks. (1) AuthController::updateProfile adds PATCH /api/auth/me mirroring the existing updateNotificationPreferences. (2) ProfileTab.vue is rewired from hardcoded refs to the auth store + the new endpoint. (3) A new ApiKey model + ApiKeyController expose GET /api/api-keys and POST /api/api-keys/regenerate over the existing greenfield api_keys table. (4) A new OutboundWebhookSubscription model + WebhookSettingsController expose GET/PUT /api/tenants/me/webhook-settings and POST /api/webhooks/test over the existing greenfield outbound_webhook_subscriptions table. (5) ApiTab.vue is wired to two new frontend api modules. Schema delta = 0 — both tables already exist in db/schema.sql; no migration.
Tech Stack: Laravel 13 + Sanctum (routes in routes/web.php, no api.php), PostgreSQL 16 (RLS), Pest 4 (DatabaseTransactions); Vue 3 <script setup> + Vuetify 3 + Pinia, Vitest + @vue/test-utils.
Context for the implementer
- Working directory:
c:\моя\проекты\портал crm\Документация. Laravel app underapp/: controllersapp/app/Http/Controllers/Api/, modelsapp/app/Models/, factoriesapp/database/factories/, routesapp/routes/web.php(there is noroutes/api.php— Sanctum SPA uses the web group). Frontendapp/resources/js/, frontend testsapp/tests/Frontend/, backend testsapp/tests/Feature/. - Branch:
main(consistent with Sprint 1 + Plan A — implementers commit atomically tomain). Do not push. - Commands:
- Backend single file:
cd app && php artisan test tests/Feature/<path>; full suite:cd app && composer test:parallel - Frontend single file:
cd app && npx vitest run tests/Frontend/<file>; full:cd app && npm run test:vue cd app && npm run type-check;cd app && npm run lint:vue;cd app && composer pint;cd app && composer stan
- Backend single file:
- Baseline (fresh, 2026-05-15, after Plan A): Pest
742/739/3sk/0, Vitest92 files / 774 passed / 3 skipped / 0 failed, vue-tsc0, ESLint0. - Lefthook pre-commit runs gitleaks / markdownlint / cspell / eslint-vue / pint / larastan / squawk on staged files — must pass. Never bypass (
--no-verifyforbidden). - Do not stage or modify
app/dev-indices.json(pre-existing uncommitted dev artifact). Stage only the files each task names. - The repo path contains Cyrillic — if an Edit reports success but content looks unchanged, re-Read and retry.
- Verified facts (recon 2026-05-15):
Usermodel$fillablealready includesfirst_name, last_name, phone, timezone—PATCH /mecan mass-assign directly. Registration hard-codesfirst_name='Новый', last_name='Пользователь'— ProfileTab is where they get set; theuserstable has norolecolumn (ProfileTab'srolefield was decorative mock).api_keysandoutbound_webhook_subscriptionstables exist indb/schema.sqlwith RLS, but zero application code touches them — no models, controllers, factories, or tests. Everything is created new.- No outbound webhook delivery pipeline exists (no Job/Service). The
POST /api/webhooks/testendpoint therefore performs an unsigned connectivity test (real HTTP POST, report status) — HMAC-signed event delivery is an explicit post-MVP epic. The secret is still generated/stored (schema requiressecret_hash NOT NULL) and shown once, so tenants can pre-configure their receiver. - Tenant-scoped routes use
Route::middleware(['auth:sanctum', 'tenant']).tenant=SetTenantContext(resolvestenant_idfromauth()->user(), runsSET LOCAL app.current_tenant_idinside a transaction). In Pest,actingAs($user)establishes both auth and tenant context. PATCH /api/auth/megoes in the existing/api/authauth:sanctumgroup (notenantmiddleware) — mirroring the siblingupdateNotificationPreferences, which already does$user->update(...)on theuserstable the same way.- Frontend api modules all import
{ apiClient, ensureCsrfCookie }from./client(never raw axios); GETs skipensureCsrfCookie(), mutations call it first.
File Structure
| File | Action | Responsibility |
|---|---|---|
app/app/Http/Controllers/Api/AuthController.php |
Modify | +updateProfile(); userResource() +phone/timezone |
app/routes/web.php |
Modify | +PATCH /me; +/api/api-keys group; +webhook group |
app/tests/Feature/Auth/UpdateProfileTest.php |
Create | Pest test for PATCH /api/auth/me |
app/resources/js/api/auth.ts |
Modify | AuthUser +phone/timezone; +updateProfile() + payload type |
app/resources/js/views/settings/ProfileTab.vue |
Modify | Rewire to auth store + PATCH /me |
app/tests/Frontend/ProfileTab.spec.ts |
Create | Vitest spec for ProfileTab |
app/app/Models/ApiKey.php |
Create | Eloquent model for api_keys |
app/database/factories/ApiKeyFactory.php |
Create | Factory for ApiKey |
app/app/Http/Controllers/Api/ApiKeyController.php |
Create | index() + regenerate() |
app/tests/Feature/ApiKeyControllerTest.php |
Create | Pest test |
app/app/Models/OutboundWebhookSubscription.php |
Create | Eloquent model for outbound_webhook_subscriptions |
app/database/factories/OutboundWebhookSubscriptionFactory.php |
Create | Factory |
app/app/Http/Controllers/Api/WebhookSettingsController.php |
Create | show() + update() + test() |
app/tests/Feature/WebhookSettingsControllerTest.php |
Create | Pest test |
app/resources/js/api/apiKeys.ts |
Create | Frontend api module for api-keys |
app/resources/js/api/webhooks.ts |
Create | Frontend api module for webhook settings |
app/resources/js/views/settings/ApiTab.vue |
Modify | Full wiring of all 4 buttons |
app/tests/Frontend/ApiTab.spec.ts |
Create | Vitest spec for ApiTab |
Task 1: PATCH /api/auth/me — profile update (closes J6)
Files:
-
Modify:
app/app/Http/Controllers/Api/AuthController.php -
Modify:
app/routes/web.php -
Test:
app/tests/Feature/Auth/UpdateProfileTest.php -
Step 1: Write the failing test
Create app/tests/Feature/Auth/UpdateProfileTest.php:
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create([
'tenant_id' => $this->tenant->id,
'first_name' => 'Новый',
'last_name' => 'Пользователь',
'phone' => null,
'timezone' => 'Europe/Moscow',
]);
$this->actingAs($this->user);
});
test('PATCH /api/auth/me обновляет профиль и возвращает user', function () {
$response = $this->patchJson('/api/auth/me', [
'first_name' => 'Иван',
'last_name' => 'Петров',
'phone' => '+7 916 000-00-00',
'timezone' => 'Asia/Yekaterinburg',
]);
$response->assertOk();
expect($response->json('user.first_name'))->toBe('Иван');
expect($response->json('user.last_name'))->toBe('Петров');
expect($response->json('user.phone'))->toBe('+7 916 000-00-00');
expect($response->json('user.timezone'))->toBe('Asia/Yekaterinburg');
$this->user->refresh();
expect($this->user->first_name)->toBe('Иван');
expect($this->user->timezone)->toBe('Asia/Yekaterinburg');
});
test('PATCH /api/auth/me без auth: 401', function () {
auth()->logout();
$this->patchJson('/api/auth/me', [
'first_name' => 'Иван',
'last_name' => 'Петров',
'timezone' => 'Europe/Moscow',
])->assertStatus(401);
});
test('PATCH /api/auth/me: 422 при пустом first_name', function () {
$this->patchJson('/api/auth/me', [
'first_name' => '',
'last_name' => 'Петров',
'timezone' => 'Europe/Moscow',
])->assertStatus(422)->assertJsonValidationErrorFor('first_name');
});
test('PATCH /api/auth/me: 422 при невалидной timezone', function () {
$this->patchJson('/api/auth/me', [
'first_name' => 'Иван',
'last_name' => 'Петров',
'timezone' => 'Mars/Olympus',
])->assertStatus(422)->assertJsonValidationErrorFor('timezone');
});
test('PATCH /api/auth/me: phone опционален (nullable)', function () {
$response = $this->patchJson('/api/auth/me', [
'first_name' => 'Иван',
'last_name' => 'Петров',
'timezone' => 'Europe/Moscow',
]);
$response->assertOk();
expect($response->json('user.phone'))->toBeNull();
});
test('GET /api/auth/me возвращает phone и timezone', function () {
$response = $this->getJson('/api/auth/me');
$response->assertOk();
expect($response->json('user'))->toHaveKeys(['phone', 'timezone']);
});
- Step 2: Run the test to verify it fails
Run: cd app && php artisan test tests/Feature/Auth/UpdateProfileTest.php
Expected: FAIL — PATCH /api/auth/me is not routed (404 instead of 200/422), and GET /me does not yet return phone/timezone.
- Step 3: Add the route in
app/routes/web.php
Inside the existing Route::prefix('/api/auth')->group(...) → inner Route::middleware('auth:sanctum')->group(...) block, add Route::patch('/me', ...) right after the GET /me line. The block becomes:
Route::middleware('auth:sanctum')->group(function () {
Route::get('/me', 'App\Http\Controllers\Api\AuthController@me');
Route::patch('/me', 'App\Http\Controllers\Api\AuthController@updateProfile');
Route::post('/logout', 'App\Http\Controllers\Api\AuthController@logout');
Route::patch('/me/notification-preferences', 'App\Http\Controllers\Api\AuthController@updateNotificationPreferences');
});
- Step 4: Add
updateProfile()toAuthController.php
Add this method to app/app/Http/Controllers/Api/AuthController.php (place it next to updateNotificationPreferences, before the private userResource):
/**
* PATCH /api/auth/me — обновление профиля текущего пользователя
* (имя, фамилия, телефон, тайм-зона). Email менять нельзя (через support).
*
* Audit J6/D1 (ProfileTab). Зеркалит updateNotificationPreferences:
* та же группа auth:sanctum, тот же inline-validate, тот же userResource.
*/
public function updateProfile(Request $request): JsonResponse
{
$validated = $request->validate([
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:20'],
'timezone' => ['required', 'timezone'],
]);
/** @var User $user */
$user = $request->user();
$user->update($validated);
return response()->json([
'user' => $this->userResource($user->fresh()),
]);
}
- Step 5: Extend
userResource()inAuthController.php
In the private userResource(User $user): array method, add phone and timezone keys (place them after last_name):
/** @return array<string, mixed> */
private function userResource(User $user): array
{
return [
'id' => $user->id,
'email' => $user->email,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'phone' => $user->phone,
'timezone' => $user->timezone,
'tenant_id' => $user->tenant_id,
'totp_enabled' => $user->totp_enabled,
'last_login_at' => $user->last_login_at,
'notification_preferences' => $user->notification_preferences,
'sound_enabled' => $user->sound_enabled,
];
}
- Step 6: Run the test to verify it passes
Run: cd app && php artisan test tests/Feature/Auth/UpdateProfileTest.php
Expected: PASS — 6/6.
- Step 7: Pint + regression
Run: cd app && composer pint && composer test:parallel
Expected: Pint clean; Pest 748/745/3sk/0 (baseline 742 + 6 new). 0 failures.
- Step 8: Commit
git add app/app/Http/Controllers/Api/AuthController.php app/routes/web.php app/tests/Feature/Auth/UpdateProfileTest.php
git commit -m "feat(auth): PATCH /api/auth/me profile update endpoint (closes J6)
Audit J6: ProfileTab needs a full-profile update endpoint. Adds
AuthController::updateProfile (first_name/last_name/phone/timezone),
routed in the existing /api/auth auth:sanctum group; mirrors the
sibling updateNotificationPreferences. userResource() now also returns
phone + timezone so the GET /me round-trip carries them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 2: ProfileTab wired to real API (closes D1)
Files:
-
Modify:
app/resources/js/api/auth.ts -
Modify:
app/resources/js/views/settings/ProfileTab.vue -
Test:
app/tests/Frontend/ProfileTab.spec.ts -
Step 1: Write the failing test
Create app/tests/Frontend/ProfileTab.spec.ts:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
vi.mock('../../resources/js/api/auth', () => ({
updateProfile: 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 authApi from '../../resources/js/api/auth';
import ProfileTab from '../../resources/js/views/settings/ProfileTab.vue';
import { useAuthStore } from '../../resources/js/stores/auth';
import type { AuthUser } from '../../resources/js/api/auth';
const vuetify = createVuetify();
const mockUser: AuthUser = {
id: 1,
email: 'ivan@example.ru',
first_name: 'Иван',
last_name: 'Петров',
phone: '+7 916 000-00-00',
timezone: 'Europe/Moscow',
tenant_id: 1,
totp_enabled: false,
last_login_at: null,
};
const factory = (user: AuthUser | null = mockUser) => {
setActivePinia(createPinia());
const auth = useAuthStore();
auth.user = user;
return mount(ProfileTab, { global: { plugins: [vuetify] } });
};
describe('ProfileTab.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('подставляет имя/фамилию/телефон/тайм-зону из auth-store', () => {
const wrapper = factory();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.firstName).toBe('Иван');
expect(vm.lastName).toBe('Петров');
expect(vm.phone).toBe('+7 916 000-00-00');
expect(vm.timezone).toBe('Europe/Moscow');
});
it('save() вызывает updateProfile с payload и показывает успех', async () => {
(authApi.updateProfile as ReturnType<typeof vi.fn>).mockResolvedValue({
...mockUser,
first_name: 'Пётр',
});
const wrapper = factory();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.firstName = 'Пётр';
await vm.save();
expect(authApi.updateProfile).toHaveBeenCalledWith({
first_name: 'Пётр',
last_name: 'Петров',
phone: '+7 916 000-00-00',
timezone: 'Europe/Moscow',
});
expect(vm.saveSuccess).toBe(true);
expect(vm.saveError).toBe(null);
});
it('save() с пустым phone отправляет null', async () => {
(authApi.updateProfile as ReturnType<typeof vi.fn>).mockResolvedValue(mockUser);
const wrapper = factory();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.phone = ' ';
await vm.save();
expect(authApi.updateProfile).toHaveBeenCalledWith(
expect.objectContaining({ phone: null }),
);
});
it('save() показывает ошибку при reject', async () => {
(authApi.updateProfile as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'));
const wrapper = factory();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
await vm.save();
expect(vm.saveError).toBeTruthy();
expect(vm.saveSuccess).toBe(false);
expect(vm.saving).toBe(false);
});
it('resetForm() возвращает поля к значениям из store', async () => {
const wrapper = factory();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.firstName = 'Изменено';
vm.resetForm();
expect(vm.firstName).toBe('Иван');
});
});
- Step 2: Run the test to verify it fails
Run: cd app && npx vitest run tests/Frontend/ProfileTab.spec.ts
Expected: FAIL — updateProfile is not yet exported from api/auth, and ProfileTab still uses hardcoded refs (no data-testid, no save/resetForm/saveSuccess).
- Step 3: Extend
app/resources/js/api/auth.ts
(a) In the AuthUser interface, add phone and timezone (place after last_name):
phone?: string | null;
timezone?: string | null;
(b) Add the payload interface and function (place near updateNotificationPreferences at the end of the file):
export interface UpdateProfilePayload {
first_name: string;
last_name: string;
phone: string | null;
timezone: string;
}
export async function updateProfile(payload: UpdateProfilePayload): Promise<AuthUser> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ user: AuthUser }>('/api/auth/me', payload);
return data.user;
}
- Step 4: Replace
app/resources/js/views/settings/ProfileTab.vueentirely
<script setup lang="ts">
/**
* Settings → Профиль. Имя, фамилия, телефон, тайм-зона текущего пользователя
* из auth-store; сохранение через PATCH /api/auth/me (audit D1 / J6).
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_settings.html секция #profile.
* Email — read-only (меняется через support). Поле «Роль» убрано: в таблице
* users нет колонки роли (было декоративной mock-заглушкой). Кнопка смены
* аватара пока без обработчика — загрузка аватара вне scope audit D1.
*/
import { computed, ref } from 'vue';
import { useAuthStore } from '../../stores/auth';
import { updateProfile } from '../../api/auth';
import { extractErrorMessage } from '../../api/client';
const auth = useAuthStore();
const firstName = ref(auth.user?.first_name ?? '');
const lastName = ref(auth.user?.last_name ?? '');
const phone = ref(auth.user?.phone ?? '');
const timezone = ref(auth.user?.timezone ?? 'Europe/Moscow');
const email = computed(() => auth.user?.email ?? '');
const initials = computed(() => {
const f = (auth.user?.first_name ?? '').charAt(0);
const l = (auth.user?.last_name ?? '').charAt(0);
return (f + l).toUpperCase() || '—';
});
const saving = ref(false);
const saveSuccess = ref(false);
const saveError = ref<string | null>(null);
function resetForm(): void {
firstName.value = auth.user?.first_name ?? '';
lastName.value = auth.user?.last_name ?? '';
phone.value = auth.user?.phone ?? '';
timezone.value = auth.user?.timezone ?? 'Europe/Moscow';
saveSuccess.value = false;
saveError.value = null;
}
async function save(): Promise<void> {
if (saving.value) return;
saving.value = true;
saveSuccess.value = false;
saveError.value = null;
try {
const updated = await updateProfile({
first_name: firstName.value,
last_name: lastName.value,
phone: phone.value.trim() === '' ? null : phone.value.trim(),
timezone: timezone.value,
});
auth.user = { ...auth.user!, ...updated };
saveSuccess.value = true;
} catch (err) {
saveError.value = extractErrorMessage(err, 'Не удалось сохранить профиль.');
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="tab-content">
<h2 class="tab-title text-h6 mb-4">Профиль</h2>
<v-alert
v-if="saveSuccess"
type="success"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="profile-save-success"
@click:close="saveSuccess = false"
>
Профиль сохранён.
</v-alert>
<v-alert
v-if="saveError"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="profile-save-error"
@click:close="saveError = null"
>
{{ saveError }}
</v-alert>
<v-row class="profile-row">
<v-col cols="auto">
<v-avatar size="80" color="primary">
<span class="text-h5">{{ initials }}</span>
</v-avatar>
<v-btn variant="text" size="small" class="mt-2" prepend-icon="mdi-camera"> Сменить </v-btn>
</v-col>
<v-col>
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
v-model="firstName"
label="Имя"
variant="outlined"
density="comfortable"
data-testid="profile-first-name"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="lastName"
label="Фамилия"
variant="outlined"
density="comfortable"
data-testid="profile-last-name"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
:model-value="email"
label="Email"
type="email"
variant="outlined"
density="comfortable"
disabled
persistent-hint
hint="Email менять только через support — связан с авторизацией"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="phone"
label="Телефон"
variant="outlined"
density="comfortable"
data-testid="profile-phone"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="timezone"
label="Тайм-зона"
variant="outlined"
density="comfortable"
persistent-hint
hint="Используется в логах и напоминаниях"
data-testid="profile-timezone"
/>
</v-col>
</v-row>
<div class="d-flex ga-2 mt-4">
<v-btn
color="primary"
variant="flat"
:loading="saving"
data-testid="profile-save-btn"
@click="save"
>
Сохранить
</v-btn>
<v-btn variant="text" :disabled="saving" data-testid="profile-cancel-btn" @click="resetForm">
Отмена
</v-btn>
</div>
</v-col>
</v-row>
</div>
</template>
<style scoped>
.tab-title {
font-variation-settings: 'opsz' 18;
letter-spacing: -0.005em;
}
.profile-row {
align-items: flex-start;
}
</style>
- Step 5: Run the test to verify it passes
Run: cd app && npx vitest run tests/Frontend/ProfileTab.spec.ts
Expected: PASS — 5/5.
- Step 6: Type-check + lint + full Vitest
Run: cd app && npm run type-check && npm run lint:vue && npm run test:vue
Expected: tsc 0, ESLint 0, Vitest 0 failed (baseline 774 + 5 new = 779 passed, 93 files).
- Step 7: Commit
git add app/resources/js/api/auth.ts app/resources/js/views/settings/ProfileTab.vue app/tests/Frontend/ProfileTab.spec.ts
git commit -m "feat(settings): ProfileTab wired to PATCH /api/auth/me (closes D1)
Audit D1: ProfileTab fields were hardcoded refs and the Save button had
no handler. Rewired to the auth store + a new api/auth updateProfile()
calling PATCH /api/auth/me. Single «Полное имя» field split into Имя +
Фамилия (matches users.first_name/last_name); decorative «Роль» field
removed (no such column). AuthUser type gains phone + timezone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 3: ApiKey model + api-keys endpoints (closes J5 part 1, enables D2/D3)
Files:
-
Create:
app/app/Models/ApiKey.php -
Create:
app/database/factories/ApiKeyFactory.php -
Create:
app/app/Http/Controllers/Api/ApiKeyController.php -
Modify:
app/routes/web.php -
Test:
app/tests/Feature/ApiKeyControllerTest.php -
Step 1: Write the failing test
Create app/tests/Feature/ApiKeyControllerTest.php:
<?php
declare(strict_types=1);
use App\Models\ApiKey;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
test('GET /api/api-keys возвращает активные ключи тенанта', function () {
ApiKey::factory()->create(['tenant_id' => $this->tenant->id, 'user_id' => $this->user->id]);
$response = $this->getJson('/api/api-keys');
$response->assertOk();
expect($response->json('data'))->toHaveCount(1);
expect($response->json('data.0'))->toHaveKeys(['id', 'name', 'key_prefix', 'last_used_at', 'created_at']);
// key_hash НИКОГДА не отдаётся наружу.
expect($response->json('data.0'))->not->toHaveKey('key_hash');
});
test('GET /api/api-keys без auth: 401', function () {
auth()->logout();
$this->getJson('/api/api-keys')->assertStatus(401);
});
test('GET /api/api-keys изолирован по тенанту', function () {
$otherTenant = Tenant::factory()->create();
$otherUser = User::factory()->create(['tenant_id' => $otherTenant->id]);
ApiKey::factory()->create(['tenant_id' => $otherTenant->id, 'user_id' => $otherUser->id]);
$response = $this->getJson('/api/api-keys');
$response->assertOk();
expect($response->json('data'))->toHaveCount(0);
});
test('POST /api/api-keys/regenerate создаёт ключ и возвращает plaintext один раз', function () {
$response = $this->postJson('/api/api-keys/regenerate');
$response->assertStatus(201);
expect($response->json('key'))->toStartWith('lpkapi_');
expect($response->json('key_prefix'))->toBe(substr($response->json('key'), 0, 10));
expect($response->json())->toHaveKeys(['id', 'name', 'key', 'key_prefix']);
// В БД хранится только bcrypt-хэш — не plaintext.
$row = ApiKey::query()->where('tenant_id', $this->tenant->id)->where('is_active', true)->first();
expect($row)->not->toBeNull();
expect($row->key_hash)->not->toBe($response->json('key'));
expect(Hash::check($response->json('key'), $row->key_hash))->toBeTrue();
});
test('POST /api/api-keys/regenerate деактивирует предыдущий активный ключ', function () {
$old = ApiKey::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'is_active' => true,
]);
$this->postJson('/api/api-keys/regenerate')->assertStatus(201);
$old->refresh();
expect($old->is_active)->toBeFalse();
// Ровно один активный ключ после регенерации.
expect(ApiKey::query()->where('tenant_id', $this->tenant->id)->where('is_active', true)->count())->toBe(1);
});
test('POST /api/api-keys/regenerate без auth: 401', function () {
auth()->logout();
$this->postJson('/api/api-keys/regenerate')->assertStatus(401);
});
- Step 2: Run the test to verify it fails
Run: cd app && php artisan test tests/Feature/ApiKeyControllerTest.php
Expected: FAIL — App\Models\ApiKey class does not exist (and routes are unrouted).
- Step 3: Create the model
app/app/Models/ApiKey.php
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\ApiKeyFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* API-ключ тенанта (таблица api_keys). Tenant-aware, RLS на уровне БД.
*
* key_hash — bcrypt-хэш; оригинал ключа показывается ОДИН раз при генерации
* (ApiKeyController::regenerate). key_prefix (10 символов) — для отображения
* в UI. Таблица имеет только created_at (без updated_at).
*/
class ApiKey extends Model
{
/** @use HasFactory<ApiKeyFactory> */
use HasFactory;
public $timestamps = false;
protected $fillable = [
'tenant_id',
'user_id',
'name',
'key_hash',
'key_prefix',
'scopes',
'last_used_at',
'last_used_ip',
'expires_at',
'is_active',
'created_at',
];
protected $hidden = ['key_hash'];
protected function casts(): array
{
return [
'scopes' => 'array',
'is_active' => 'boolean',
'last_used_at' => 'datetime',
'expires_at' => 'datetime',
'created_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
- Step 4: Create the factory
app/database/factories/ApiKeyFactory.php
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\ApiKey;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<ApiKey>
*/
class ApiKeyFactory extends Factory
{
protected $model = ApiKey::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'user_id' => User::factory(),
'name' => 'API-ключ',
'key_hash' => Hash::make(Str::random(48)),
'key_prefix' => 'lpkapi_'.Str::lower(Str::random(3)),
'scopes' => ['read'],
'last_used_at' => null,
'expires_at' => now()->addYear(),
'is_active' => true,
'created_at' => now(),
];
}
}
- Step 5: Create the controller
app/app/Http/Controllers/Api/ApiKeyController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ApiKey;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
/**
* API-ключи тенанта (audit D2/D3/J5). Endpoints под auth:sanctum + tenant.
*
* Полный ключ показывается ОДИН раз — в ответе regenerate(). В БД хранится
* только bcrypt key_hash + key_prefix (первые 10 символов для UI). У тенанта
* поддерживается один активный ключ: regenerate деактивирует прежние.
*/
class ApiKeyController extends Controller
{
private const KEY_PREFIX = 'lpkapi_';
public function index(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
// Defense-in-depth: явный where даже при RLS — в тестах PG superuser BYPASSRLS.
$keys = ApiKey::query()
->where('tenant_id', $tenantId)
->where('is_active', true)
->orderByDesc('created_at')
->get(['id', 'name', 'key_prefix', 'last_used_at', 'expires_at', 'created_at']);
return response()->json(['data' => $keys]);
}
public function regenerate(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$userId = (int) $request->user()->id;
// Один активный ключ на тенанта — прежние деактивируются.
ApiKey::query()
->where('tenant_id', $tenantId)
->where('is_active', true)
->update(['is_active' => false]);
$plainKey = self::KEY_PREFIX.Str::random(48);
$key = ApiKey::query()->create([
'tenant_id' => $tenantId,
'user_id' => $userId,
'name' => 'API-ключ',
'key_hash' => Hash::make($plainKey),
'key_prefix' => substr($plainKey, 0, 10),
'scopes' => ['read'],
'expires_at' => now()->addYear(),
'is_active' => true,
'created_at' => now(),
]);
return response()->json([
'id' => $key->id,
'name' => $key->name,
'key' => $plainKey, // показывается ОДИН раз
'key_prefix' => $key->key_prefix,
], Response::HTTP_CREATED);
}
}
- Step 6: Register routes in
app/routes/web.php
Add a new tenant-scoped group (place it near the other auth:sanctum+tenant groups, e.g. after the /api/billing/charges group):
// API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware.
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/api-keys')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\ApiKeyController@index');
Route::post('/regenerate', 'App\Http\Controllers\Api\ApiKeyController@regenerate');
});
- Step 7: Run the test to verify it passes
Run: cd app && php artisan test tests/Feature/ApiKeyControllerTest.php
Expected: PASS — 6/6.
- Step 8: Pint + Larastan + regression
Run: cd app && composer pint && composer stan && composer test:parallel
Expected: Pint clean; Larastan clean (no new errors); Pest 754/751/3sk/0 (after Task 1: 748 + 6 new). 0 failures.
- Step 9: Commit
git add app/app/Models/ApiKey.php app/database/factories/ApiKeyFactory.php app/app/Http/Controllers/Api/ApiKeyController.php app/routes/web.php app/tests/Feature/ApiKeyControllerTest.php
git commit -m "feat(api): api_keys model + GET/regenerate endpoints (closes J5 part 1)
Audit J5/D3: the api_keys table existed in schema but had zero code.
Adds the ApiKey model + factory, and ApiKeyController with GET
/api/api-keys (list active keys, key_hash hidden) and POST
/api/api-keys/regenerate (deactivate prior + create new, full key
returned once, bcrypt-hashed in DB). Tenant-scoped via auth:sanctum +
tenant middleware (RLS on api_keys).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 4: OutboundWebhookSubscription model + webhook-settings endpoints (closes J5 part 2, enables D4/D5)
Files:
-
Create:
app/app/Models/OutboundWebhookSubscription.php -
Create:
app/database/factories/OutboundWebhookSubscriptionFactory.php -
Create:
app/app/Http/Controllers/Api/WebhookSettingsController.php -
Modify:
app/routes/web.php -
Test:
app/tests/Feature/WebhookSettingsControllerTest.php -
Step 1: Write the failing test
Create app/tests/Feature/WebhookSettingsControllerTest.php:
<?php
declare(strict_types=1);
use App\Models\OutboundWebhookSubscription;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
test('GET webhook-settings: null когда подписки нет', function () {
$response = $this->getJson('/api/tenants/me/webhook-settings');
$response->assertOk();
expect($response->json('data'))->toBeNull();
});
test('GET webhook-settings возвращает подписку тенанта', function () {
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://crm.example.ru/hook',
]);
$response = $this->getJson('/api/tenants/me/webhook-settings');
$response->assertOk();
expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
expect($response->json('data'))->toHaveKeys(['target_url', 'secret_prefix', 'events', 'is_active']);
// secret_hash наружу не отдаётся.
expect($response->json('data'))->not->toHaveKey('secret_hash');
});
test('PUT webhook-settings создаёт подписку и возвращает secret один раз', function () {
$response = $this->putJson('/api/tenants/me/webhook-settings', [
'target_url' => 'https://crm.example.ru/hook',
]);
$response->assertOk();
expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
expect($response->json('data.secret'))->toStartWith('whsec_');
expect($response->json('data.events'))->toBeArray()->not->toBeEmpty();
$row = OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->first();
expect($row)->not->toBeNull();
expect(Hash::check($response->json('data.secret'), $row->secret_hash))->toBeTrue();
});
test('PUT webhook-settings обновляет URL существующей подписки без нового secret', function () {
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://old.example.ru/hook',
]);
$response = $this->putJson('/api/tenants/me/webhook-settings', [
'target_url' => 'https://new.example.ru/hook',
]);
$response->assertOk();
expect($response->json('data.target_url'))->toBe('https://new.example.ru/hook');
// secret НЕ перевыпускается при простом обновлении URL.
expect($response->json('data'))->not->toHaveKey('secret');
expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(1);
});
test('PUT webhook-settings: 422 при не-https URL', function () {
$this->putJson('/api/tenants/me/webhook-settings', [
'target_url' => 'http://insecure.example.ru/hook',
])->assertStatus(422)->assertJsonValidationErrorFor('target_url');
});
test('POST webhooks/test отправляет запрос и возвращает результат', function () {
Http::fake(['*' => Http::response(['ok' => true], 200)]);
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://crm.example.ru/hook',
]);
$response = $this->postJson('/api/webhooks/test');
$response->assertOk();
expect($response->json('ok'))->toBeTrue();
expect($response->json('status'))->toBe(200);
Http::assertSent(fn ($req) => $req->url() === 'https://crm.example.ru/hook');
});
test('POST webhooks/test: 422 когда подписки нет', function () {
$this->postJson('/api/webhooks/test')->assertStatus(422);
});
test('GET webhook-settings без auth: 401', function () {
auth()->logout();
$this->getJson('/api/tenants/me/webhook-settings')->assertStatus(401);
});
- Step 2: Run the test to verify it fails
Run: cd app && php artisan test tests/Feature/WebhookSettingsControllerTest.php
Expected: FAIL — App\Models\OutboundWebhookSubscription does not exist.
- Step 3: Create the model
app/app/Models/OutboundWebhookSubscription.php
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\OutboundWebhookSubscriptionFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Исходящая webhook-подписка тенанта (таблица outbound_webhook_subscriptions).
* Tenant-aware, RLS на уровне БД.
*
* secret_hash — bcrypt-хэш; оригинал секрета показывается ОДИН раз при
* создании. events — JSONB-массив, CHECK требует ≥1 элемента.
*
* NB: outbound-доставка событий (подписанные webhook'и) — пост-MVP; пока
* подписка хранит URL + секрет, а WebhookSettingsController::test делает
* unsigned connectivity-проверку.
*/
class OutboundWebhookSubscription extends Model
{
/** @use HasFactory<OutboundWebhookSubscriptionFactory> */
use HasFactory;
protected $fillable = [
'tenant_id',
'user_id',
'name',
'target_url',
'secret_hash',
'secret_prefix',
'events',
'custom_headers',
'is_active',
'paused_at',
];
protected $hidden = ['secret_hash'];
protected function casts(): array
{
return [
'events' => 'array',
'custom_headers' => 'array',
'is_active' => 'boolean',
'consecutive_failures' => 'integer',
'paused_at' => 'datetime',
'last_delivery_at' => 'datetime',
'last_failure_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
- Step 4: Create the factory
app/database/factories/OutboundWebhookSubscriptionFactory.php
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\OutboundWebhookSubscription;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<OutboundWebhookSubscription>
*/
class OutboundWebhookSubscriptionFactory extends Factory
{
protected $model = OutboundWebhookSubscription::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'user_id' => User::factory(),
'name' => 'Webhook',
'target_url' => 'https://'.fake()->domainName().'/webhook',
'secret_hash' => Hash::make('whsec_'.Str::random(40)),
'secret_prefix' => 'whsec_'.Str::lower(Str::random(4)),
'events' => ['deal.created', 'deal.status_changed'],
'is_active' => true,
];
}
}
- Step 5: Create the controller
app/app/Http/Controllers/Api/WebhookSettingsController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\OutboundWebhookSubscription;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
/**
* Настройки исходящего webhook'а тенанта (audit D4/D5/J5).
* Endpoints под auth:sanctum + tenant.
*
* Один подписка-ряд на тенанта. Секрет генерируется при создании и
* показывается ОДИН раз (в БД — bcrypt secret_hash + secret_prefix).
*
* test(): MVP делает unsigned connectivity-проверку (реальный POST на
* target_url, отчёт по HTTP-статусу). HMAC-подписанная доставка событий —
* отдельный пост-MVP эпик (outbound-pipeline пока не построен).
*/
class WebhookSettingsController extends Controller
{
private const SECRET_PREFIX = 'whsec_';
/** @var list<string> События по умолчанию для новой подписки. */
private const DEFAULT_EVENTS = ['deal.created', 'deal.status_changed'];
public function show(Request $request): JsonResponse
{
$sub = $this->currentSubscription($request);
if ($sub === null) {
return response()->json(['data' => null]);
}
return response()->json(['data' => [
'target_url' => $sub->target_url,
'secret_prefix' => $sub->secret_prefix,
'events' => $sub->events,
'is_active' => $sub->is_active,
]]);
}
public function update(Request $request): JsonResponse
{
$validated = $request->validate([
'target_url' => ['required', 'string', 'url', 'max:2048', 'starts_with:https://'],
]);
$sub = $this->currentSubscription($request);
$plainSecret = null;
if ($sub === null) {
$plainSecret = self::SECRET_PREFIX.Str::random(40);
$sub = OutboundWebhookSubscription::query()->create([
'tenant_id' => (int) $request->user()->tenant_id,
'user_id' => (int) $request->user()->id,
'name' => 'Webhook',
'target_url' => $validated['target_url'],
'secret_hash' => Hash::make($plainSecret),
'secret_prefix' => substr($plainSecret, 0, 10),
'events' => self::DEFAULT_EVENTS,
'is_active' => true,
]);
} else {
$sub->update(['target_url' => $validated['target_url']]);
}
$payload = [
'target_url' => $sub->target_url,
'secret_prefix' => $sub->secret_prefix,
'events' => $sub->events,
'is_active' => $sub->is_active,
];
if ($plainSecret !== null) {
$payload['secret'] = $plainSecret; // показывается ОДИН раз
}
return response()->json(['data' => $payload]);
}
public function test(Request $request): JsonResponse
{
$sub = $this->currentSubscription($request);
if ($sub === null) {
return response()->json([
'message' => 'Сначала сохраните URL webhook.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$testPayload = [
'event' => 'webhook.test',
'sent_at' => now()->toIso8601String(),
'message' => 'Тестовая доставка webhook от Лидерра.',
];
// MVP: unsigned connectivity-проверка. SSRF-харднинг (блок приватных
// IP) — пост-MVP security-review; URL уже ограничен https:// валидацией.
try {
$response = Http::timeout(10)
->withHeaders(['X-Webhook-Event' => 'webhook.test'])
->post($sub->target_url, $testPayload);
return response()->json([
'ok' => $response->successful(),
'status' => $response->status(),
'message' => $response->successful()
? "Тестовый запрос доставлен (HTTP {$response->status()})."
: "Endpoint ответил HTTP {$response->status()}.",
]);
} catch (\Throwable $e) {
return response()->json([
'ok' => false,
'status' => null,
'message' => 'Не удалось доставить тестовый запрос: '.$e->getMessage(),
]);
}
}
private function currentSubscription(Request $request): ?OutboundWebhookSubscription
{
$tenantId = (int) $request->user()->tenant_id;
// Defense-in-depth: явный where даже при RLS — в тестах PG superuser BYPASSRLS.
return OutboundWebhookSubscription::query()
->where('tenant_id', $tenantId)
->orderByDesc('id')
->first();
}
}
- Step 6: Register routes in
app/routes/web.php
Add a new tenant-scoped group (next to the /api/api-keys group from Task 3):
// Настройки исходящего webhook'а тенанта (audit D4/D5/J5).
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
Route::get('/api/tenants/me/webhook-settings', 'App\Http\Controllers\Api\WebhookSettingsController@show');
Route::put('/api/tenants/me/webhook-settings', 'App\Http\Controllers\Api\WebhookSettingsController@update');
Route::post('/api/webhooks/test', 'App\Http\Controllers\Api\WebhookSettingsController@test');
});
- Step 7: Run the test to verify it passes
Run: cd app && php artisan test tests/Feature/WebhookSettingsControllerTest.php
Expected: PASS — 8/8.
- Step 8: Pint + Larastan + regression
Run: cd app && composer pint && composer stan && composer test:parallel
Expected: Pint clean; Larastan clean; Pest 762/759/3sk/0 (after Task 3: 754 + 8 new). 0 failures.
- Step 9: Commit
git add app/app/Models/OutboundWebhookSubscription.php app/database/factories/OutboundWebhookSubscriptionFactory.php app/app/Http/Controllers/Api/WebhookSettingsController.php app/routes/web.php app/tests/Feature/WebhookSettingsControllerTest.php
git commit -m "feat(api): outbound webhook settings endpoints (closes J5 part 2)
Audit J5/D4/D5: the outbound_webhook_subscriptions table existed in
schema but had zero code. Adds the OutboundWebhookSubscription model +
factory and WebhookSettingsController with GET/PUT
/api/tenants/me/webhook-settings (one subscription per tenant; secret
generated + returned once on creation, bcrypt-hashed) and POST
/api/webhooks/test (unsigned connectivity check — HMAC-signed event
delivery is a separate post-MVP epic). Tenant-scoped via auth:sanctum +
tenant middleware.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 5: ApiTab full wiring — copy / regenerate / save webhook / test (closes D2, D3, D4, D5)
Files:
-
Create:
app/resources/js/api/apiKeys.ts -
Create:
app/resources/js/api/webhooks.ts -
Modify:
app/resources/js/views/settings/ApiTab.vue -
Test:
app/tests/Frontend/ApiTab.spec.ts -
Step 1: Write the failing test
Create app/tests/Frontend/ApiTab.spec.ts:
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<typeof vi.fn>).mockResolvedValue([]);
(webhooksApi.getWebhookSettings as ReturnType<typeof vi.fn>).mockResolvedValue(null);
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
});
it('загружает и показывает префикс API-ключа', async () => {
(apiKeysApi.listApiKeys as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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');
});
});
- Step 2: Run the test to verify it fails
Run: cd app && npx vitest run tests/Frontend/ApiTab.spec.ts
Expected: FAIL — ../../resources/js/api/apiKeys and ../../resources/js/api/webhooks do not exist; ApiTab has none of the exposed members.
- Step 3: Create
app/resources/js/api/apiKeys.ts
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<ApiKeyInfo[]> {
const { data } = await apiClient.get<{ data: ApiKeyInfo[] }>('/api/api-keys');
return data.data;
}
export async function regenerateApiKey(): Promise<RegeneratedApiKey> {
await ensureCsrfCookie();
const { data } = await apiClient.post<RegeneratedApiKey>('/api/api-keys/regenerate');
return data;
}
- Step 4: Create
app/resources/js/api/webhooks.ts
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<WebhookSettings | null> {
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<SavedWebhookSettings> {
await ensureCsrfCookie();
const { data } = await apiClient.put<{ data: SavedWebhookSettings }>('/api/tenants/me/webhook-settings', payload);
return data.data;
}
export async function testWebhook(): Promise<WebhookTestResult> {
await ensureCsrfCookie();
const { data } = await apiClient.post<WebhookTestResult>('/api/webhooks/test');
return data;
}
- Step 5: Replace
app/resources/js/views/settings/ApiTab.vueentirely
<script setup lang="ts">
/**
* Settings → API и Webhook (audit D2/D3/D4/D5 + J5).
*
* API-ключ: GET /api/api-keys показывает key_prefix; «Копировать» — clipboard
* + toast; «Перегенерировать» — POST /api/api-keys/regenerate, полный ключ
* показывается ОДИН раз (затем доступен только префикс).
* Webhook: GET/PUT /api/tenants/me/webhook-settings (target_url + secret_prefix),
* «Тестовый webhook» — POST /api/webhooks/test (реальный unsigned POST).
*
* Полный ключ и полный секрет показываются один раз — в БД только bcrypt-хэши.
* Подписанная outbound-доставка событий — пост-MVP.
*/
import { onMounted, ref } from 'vue';
import { listApiKeys, regenerateApiKey } from '../../api/apiKeys';
import { getWebhookSettings, saveWebhookSettings, testWebhook } from '../../api/webhooks';
import { extractErrorMessage } from '../../api/client';
// --- API-ключ ---
const apiKeyExists = ref(false);
const apiTokenDisplay = ref('');
const fullKeyShown = ref(false);
const tokenVisible = ref(false);
const apiKeyError = ref<string | null>(null);
const regenDialogOpen = ref(false);
const regenerating = ref(false);
// --- Webhook ---
const webhookUrl = ref('');
const secretDisplay = ref('');
const fullSecretShown = ref(false);
const secretVisible = ref(false);
const webhookError = ref<string | null>(null);
const webhookSuccess = ref<string | null>(null);
const savingWebhook = ref(false);
const testingWebhook = ref(false);
// --- общий toast (D2 copy + D5 test) ---
const toastOpen = ref(false);
const toastText = ref('');
const toastColor = ref<'success' | 'error'>('success');
function showToast(text: string, color: 'success' | 'error' = 'success'): void {
toastText.value = text;
toastColor.value = color;
toastOpen.value = true;
}
async function loadApiKey(): Promise<void> {
apiKeyError.value = null;
try {
const keys = await listApiKeys();
const active = keys[0];
if (active) {
apiKeyExists.value = true;
apiTokenDisplay.value = active.key_prefix;
fullKeyShown.value = false;
} else {
apiKeyExists.value = false;
apiTokenDisplay.value = '';
}
} catch (err) {
apiKeyError.value = extractErrorMessage(err, 'Не удалось загрузить API-ключ.');
}
}
async function loadWebhook(): Promise<void> {
webhookError.value = null;
try {
const settings = await getWebhookSettings();
if (settings) {
webhookUrl.value = settings.target_url;
secretDisplay.value = settings.secret_prefix;
fullSecretShown.value = false;
}
} catch (err) {
webhookError.value = extractErrorMessage(err, 'Не удалось загрузить настройки webhook.');
}
}
onMounted(() => {
void loadApiKey();
void loadWebhook();
});
async function copyToken(): Promise<void> {
if (apiTokenDisplay.value === '') return;
try {
await navigator.clipboard.writeText(apiTokenDisplay.value);
showToast('Скопировано в буфер обмена.', 'success');
} catch {
showToast('Не удалось скопировать — выделите и скопируйте вручную.', 'error');
}
}
async function confirmRegenerate(): Promise<void> {
regenerating.value = true;
apiKeyError.value = null;
try {
const result = await regenerateApiKey();
apiTokenDisplay.value = result.key;
fullKeyShown.value = true;
apiKeyExists.value = true;
tokenVisible.value = true;
regenDialogOpen.value = false;
} catch (err) {
apiKeyError.value = extractErrorMessage(err, 'Не удалось перегенерировать ключ.');
regenDialogOpen.value = false;
} finally {
regenerating.value = false;
}
}
async function saveWebhook(): Promise<void> {
if (savingWebhook.value) return;
savingWebhook.value = true;
webhookError.value = null;
webhookSuccess.value = null;
try {
const result = await saveWebhookSettings({ target_url: webhookUrl.value });
if (result.secret) {
secretDisplay.value = result.secret;
fullSecretShown.value = true;
secretVisible.value = true;
} else {
secretDisplay.value = result.secret_prefix;
}
webhookSuccess.value = 'Настройки webhook сохранены.';
} catch (err) {
webhookError.value = extractErrorMessage(err, 'Не удалось сохранить webhook.');
} finally {
savingWebhook.value = false;
}
}
async function runWebhookTest(): Promise<void> {
if (testingWebhook.value) return;
testingWebhook.value = true;
try {
const result = await testWebhook();
showToast(result.message, result.ok ? 'success' : 'error');
} catch (err) {
showToast(extractErrorMessage(err, 'Не удалось отправить тестовый запрос.'), 'error');
} finally {
testingWebhook.value = false;
}
}
</script>
<template>
<div class="tab-content">
<h2 class="tab-title text-h6 mb-4">API и Webhook</h2>
<v-card variant="outlined" class="pa-4 mb-4">
<h3 class="text-subtitle-2 mb-3">API-ключ</h3>
<p class="text-body-2 text-medium-emphasis mb-3">
Используется для подписи запросов в публичный API CRM. После регенерации старый ключ перестаёт работать
немедленно.
</p>
<v-alert
v-if="apiKeyError"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="api-key-error"
@click:close="apiKeyError = null"
>
{{ apiKeyError }}
</v-alert>
<v-alert
v-if="fullKeyShown"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
data-testid="api-key-once-notice"
>
Новый ключ показывается <strong>один раз</strong>. Скопируйте и сохраните его сейчас.
</v-alert>
<v-text-field
:model-value="apiTokenDisplay"
:type="tokenVisible ? 'text' : 'password'"
:placeholder="apiKeyExists ? '' : 'Ключ ещё не создан — нажмите «Перегенерировать»'"
readonly
variant="outlined"
density="comfortable"
data-testid="api-key-field"
:append-inner-icon="tokenVisible ? 'mdi-eye-off' : 'mdi-eye'"
@click:append-inner="tokenVisible = !tokenVisible"
/>
<div class="d-flex ga-2 mt-2">
<v-btn
variant="outlined"
size="small"
prepend-icon="mdi-content-copy"
:disabled="apiTokenDisplay === ''"
data-testid="api-key-copy-btn"
@click="copyToken"
>
Копировать
</v-btn>
<v-btn
variant="outlined"
size="small"
color="warning"
prepend-icon="mdi-refresh"
data-testid="api-key-regen-btn"
@click="regenDialogOpen = true"
>
Перегенерировать
</v-btn>
</div>
</v-card>
<v-card variant="outlined" class="pa-4">
<h3 class="text-subtitle-2 mb-3">Webhook для приёма лидов</h3>
<p class="text-body-2 text-medium-emphasis mb-3">
URL источника лидов отправляет POST с подписью HMAC-SHA256. Дедуп по
<code>(tenant_id, source_crm_id)</code> в окне 24 ч (антифрод по phone — §10.8.1).
</p>
<v-alert
v-if="webhookError"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="webhook-error"
@click:close="webhookError = null"
>
{{ webhookError }}
</v-alert>
<v-alert
v-if="webhookSuccess"
type="success"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="webhook-success"
@click:close="webhookSuccess = null"
>
{{ webhookSuccess }}
</v-alert>
<v-alert
v-if="fullSecretShown"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
data-testid="webhook-secret-once-notice"
>
Signing secret показывается <strong>один раз</strong>. Сохраните его в настройках вашего приёмника.
</v-alert>
<v-text-field
v-model="webhookUrl"
label="Endpoint URL"
placeholder="https://..."
variant="outlined"
density="comfortable"
data-testid="webhook-url-field"
/>
<v-text-field
:model-value="secretDisplay"
:type="secretVisible ? 'text' : 'password'"
label="Signing secret (HMAC)"
:placeholder="secretDisplay ? '' : 'Появится после первого сохранения URL'"
readonly
variant="outlined"
density="comfortable"
data-testid="webhook-secret-field"
:append-inner-icon="secretVisible ? 'mdi-eye-off' : 'mdi-eye'"
@click:append-inner="secretVisible = !secretVisible"
/>
<div class="d-flex ga-2 mt-2">
<v-btn
color="primary"
variant="flat"
size="small"
:loading="savingWebhook"
data-testid="webhook-save-btn"
@click="saveWebhook"
>
Сохранить
</v-btn>
<v-btn
variant="outlined"
size="small"
prepend-icon="mdi-test-tube"
:loading="testingWebhook"
data-testid="webhook-test-btn"
@click="runWebhookTest"
>
Тестовый webhook
</v-btn>
</div>
</v-card>
<v-dialog v-model="regenDialogOpen" :max-width="440" data-testid="regen-dialog">
<v-card>
<v-card-title>Перегенерация API-ключа</v-card-title>
<v-card-text>
Текущий ключ перестанет работать немедленно. Все интеграции с ним нужно будет обновить. Продолжить?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" :disabled="regenerating" @click="regenDialogOpen = false">Отмена</v-btn>
<v-btn
color="warning"
variant="flat"
:loading="regenerating"
data-testid="regen-confirm-btn"
@click="confirmRegenerate"
>
Перегенерировать
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="toastOpen"
:timeout="4000"
:color="toastColor"
location="bottom right"
data-testid="api-tab-toast"
>
{{ toastText }}
</v-snackbar>
</div>
</template>
<style scoped>
.tab-title {
font-variation-settings: 'opsz' 18;
letter-spacing: -0.005em;
}
</style>
- Step 6: Run the test to verify it passes
Run: cd app && npx vitest run tests/Frontend/ApiTab.spec.ts
Expected: PASS — 6/6.
- Step 7: Type-check + lint + full Vitest
Run: cd app && npm run type-check && npm run lint:vue && npm run test:vue
Expected: tsc 0, ESLint 0, Vitest 0 failed (after Task 2: 779 passed / 93 files; this task adds ApiTab.spec.ts = +1 file / +6 tests → 785 passed / 94 files).
- Step 8: Commit
git add app/resources/js/api/apiKeys.ts app/resources/js/api/webhooks.ts app/resources/js/views/settings/ApiTab.vue app/tests/Frontend/ApiTab.spec.ts
git commit -m "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) <noreply@anthropic.com>"
Final Acceptance — Plan B
After all 5 tasks:
- Pest
--parallel:762/759/3sk/0(baseline 742 + 6 + 6 + 8 = 762). 0 failures, 0 regressions. - Vitest:
94 files / 785 passed / 3 skipped / 0 failed(baseline 92/774 + ProfileTab.spec 5 + ApiTab.spec 6 = 94/785). - vue-tsc:
0 errors. ESLint:0 errors. Pint: clean. Larastan: no new errors. - Lefthook pre-commit green on all 5 commits.
- 5 atomic commits on
main; not pushed. - Schema delta = 0 —
db/schema.sqluntouched, no migration added.
Spec coverage (audit 2026-05-15-portal-audit-design.md)
| Audit ID | Spec line | This plan |
|---|---|---|
| D1 | ProfileTab Save → PATCH /api/auth/me |
Task 2 |
| D2 | ApiTab Copy → clipboard + toast | Task 5 |
| D3 | ApiTab Regenerate → POST /api/api-keys/regenerate |
Tasks 3 + 5 |
| D4 | ApiTab Save Webhook → PUT /api/tenants/me/webhook-settings |
Tasks 4 + 5 |
| D5 | ApiTab Test Webhook → POST /api/webhooks/test |
Tasks 4 + 5 |
| J5 | 3 new backend endpoints (api-keys/regenerate, webhook-settings, webhook-test) | Tasks 3 + 4 (+ supporting GET list/show endpoints) |
| J6 | extend PATCH /api/auth/me for full profile |
Task 1 |
Design decisions baked in (controller-resolved during recon, 2026-05-15):
- D4 webhook settings persist to the existing
outbound_webhook_subscriptionstable (purpose-built: URL + secret + events) — no schema change, no newtenantscolumns. POST /api/webhooks/testis an unsigned connectivity test (real HTTP POST, reports HTTP status). HMAC-signed outbound event delivery is a separate post-MVP epic — the outbound delivery pipeline (Job/Service) does not exist yet. The secret is still generated, stored (bcrypt), and shown once so tenants can pre-configure their receiver.- ProfileTab: single «Полное имя» field split into Имя + Фамилия (matches
users.first_name/last_name); the decorative «Роль» field is removed (nousers.rolecolumn). The avatar «Сменить» button stays handler-less — avatar upload is out of audit D1 scope. - D6/D7 (SettingsView placeholder tabs, left-rail hide-if-not-implemented) are not in Plan B — they are separate audit items routed to Sprint 3 per the audit spec Sprint Schedule.