Add application API keys
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Tickets;
|
||||||
|
|
||||||
|
use App\Actions\Projects\ListVisibleProjects;
|
||||||
|
use App\Http\Resources\ProjectResource;
|
||||||
|
use App\Http\Resources\TicketResource;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class ListAssignedTicketsByVisibleProject
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ListVisibleProjects $listVisibleProjects,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{project: array<string, mixed>, tickets: array<int, array<string, mixed>>}>
|
||||||
|
*/
|
||||||
|
public function handle(User $viewer, User $assignee): array
|
||||||
|
{
|
||||||
|
/** @var EloquentCollection<int, Project> $visibleProjects */
|
||||||
|
$visibleProjects = $this->listVisibleProjects->handle($viewer);
|
||||||
|
$visibleProjectIds = $visibleProjects->pluck('id')->all();
|
||||||
|
|
||||||
|
if ($visibleProjectIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Collection<int, Collection<int, Ticket>> $ticketsByProject */
|
||||||
|
$ticketsByProject = Ticket::query()
|
||||||
|
->where('assignee_user_id', $assignee->id)
|
||||||
|
->whereIn('project_id', $visibleProjectIds)
|
||||||
|
->with([
|
||||||
|
'assignee',
|
||||||
|
'project',
|
||||||
|
'ticketPriority',
|
||||||
|
'ticketStatus.boardColumn',
|
||||||
|
'ticketType',
|
||||||
|
])
|
||||||
|
->orderByDesc('sequence_number')
|
||||||
|
->get()
|
||||||
|
->groupBy('project_id');
|
||||||
|
|
||||||
|
return $visibleProjects
|
||||||
|
->filter(fn (Project $project): bool => $ticketsByProject->has($project->id))
|
||||||
|
->map(fn (Project $project): array => [
|
||||||
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
|
'tickets' => TicketResource::collection($ticketsByProject->get($project->id))->resolve(),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\UserApplications;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserApplication;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class CreateUserApplication
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array{name: string} $validated
|
||||||
|
* @return array{application: UserApplication, plain_text_key: string}
|
||||||
|
*/
|
||||||
|
public function handle(User $user, array $validated): array
|
||||||
|
{
|
||||||
|
$plainTextKey = UserApplication::generatePlainTextKey();
|
||||||
|
|
||||||
|
$application = UserApplication::query()->create([
|
||||||
|
'key_hash' => UserApplication::hashKey($plainTextKey),
|
||||||
|
'name' => Arr::get($validated, 'name'),
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'application' => $application,
|
||||||
|
'plain_text_key' => $plainTextKey,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\UserApplications;
|
||||||
|
|
||||||
|
use App\Models\UserApplication;
|
||||||
|
|
||||||
|
class DeleteUserApplication
|
||||||
|
{
|
||||||
|
public function handle(UserApplication $application): string
|
||||||
|
{
|
||||||
|
$name = $application->name;
|
||||||
|
|
||||||
|
$application->delete();
|
||||||
|
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
|
||||||
|
use App\Http\Resources\UserResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class UserAssignedTicketController extends ApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request, User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$viewer = $this->authenticatedUser($request);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->listAssignedTicketsByVisibleProject->handle($viewer, $user),
|
||||||
|
'meta' => [
|
||||||
|
'assignee' => UserResource::make($user)->resolve(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
|
use App\Actions\UserApplications\CreateUserApplication;
|
||||||
|
use App\Actions\UserApplications\DeleteUserApplication;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Settings\StoreUserApplicationRequest;
|
||||||
|
use App\Http\Resources\UserApplicationResource;
|
||||||
|
use App\Models\UserApplication;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class UserApplicationController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CreateUserApplication $createUserApplication,
|
||||||
|
private readonly DeleteUserApplication $deleteUserApplication,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('settings/Applications', [
|
||||||
|
'applicationKey' => $request->session()->get('applicationKey'),
|
||||||
|
'applications' => UserApplicationResource::collection($request->user()->userApplications)->resolve(),
|
||||||
|
'status' => $request->session()->get('status'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreUserApplicationRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$result = $this->createUserApplication->handle($request->user(), $request->validated());
|
||||||
|
|
||||||
|
return to_route('applications.edit')
|
||||||
|
->with('applicationKey', $result['plain_text_key'])
|
||||||
|
->with('status', "{$result['application']->name} was created. Save this key now; it will not be shown again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, UserApplication $userApplication): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->abortUnlessOwner($request, $userApplication);
|
||||||
|
|
||||||
|
$name = $this->deleteUserApplication->handle($userApplication);
|
||||||
|
|
||||||
|
return to_route('applications.edit')
|
||||||
|
->with('status', "{$name} was removed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function abortUnlessOwner(Request $request, UserApplication $userApplication): void
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
abort_unless($userApplication->user_id === $user->id, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\UserApplication;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class AuthenticateApiApplicationKey
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$plainTextKey = $request->bearerToken();
|
||||||
|
|
||||||
|
if ($plainTextKey === null || trim($plainTextKey) === '') {
|
||||||
|
return response()->json(['message' => 'Unauthenticated.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$application = UserApplication::query()
|
||||||
|
->with('user')
|
||||||
|
->where('key_hash', UserApplication::hashKey($plainTextKey))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($application === null || $application->user === null) {
|
||||||
|
return response()->json(['message' => 'Unauthenticated.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
Auth::setUser($application->user);
|
||||||
|
$request->setUserResolver(fn () => $application->user);
|
||||||
|
|
||||||
|
$application->forceFill([
|
||||||
|
'last_used_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Settings;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class StoreUserApplicationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'name' => Str::of((string) $this->input('name'))->trim()->value(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class UserApplicationResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'user_id' => $this->user_id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'last_used_at' => $this->last_used_at?->toISOString(),
|
||||||
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
|
'updated_at' => $this->updated_at?->toISOString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,13 @@ class User extends Authenticatable
|
|||||||
->orderBy('base_url');
|
->orderBy('base_url');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function userApplications(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserApplication::class)
|
||||||
|
->orderBy('name')
|
||||||
|
->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
public function assignedTickets(): HasMany
|
public function assignedTickets(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Ticket::class, 'assignee_user_id');
|
return $this->hasMany(Ticket::class, 'assignee_user_id');
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\UserApplicationFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
#[Fillable(['key_hash', 'last_used_at', 'name', 'user_id'])]
|
||||||
|
#[Hidden(['key_hash'])]
|
||||||
|
class UserApplication extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<UserApplicationFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public static function generatePlainTextKey(): string
|
||||||
|
{
|
||||||
|
return 'kg_app_'.Str::random(64);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hashKey(string $plainTextKey): string
|
||||||
|
{
|
||||||
|
return hash_hmac('sha256', $plainTextKey, (string) config('app.key'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'last_used_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\AuthenticateApiApplicationKey;
|
||||||
use App\Http\Middleware\HandleAppearance;
|
use App\Http\Middleware\HandleAppearance;
|
||||||
use App\Http\Middleware\HandleInertiaRequests;
|
use App\Http\Middleware\HandleInertiaRequests;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
@@ -22,6 +23,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
HandleInertiaRequests::class,
|
HandleInertiaRequests::class,
|
||||||
AddLinkHeadersForPreloadedAssets::class,
|
AddLinkHeadersForPreloadedAssets::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$middleware->api(append: [
|
||||||
|
AuthenticateApiApplicationKey::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserApplication;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<UserApplication>
|
||||||
|
*/
|
||||||
|
class UserApplicationFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$plainTextKey = UserApplication::generatePlainTextKey();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'key_hash' => UserApplication::hashKey($plainTextKey),
|
||||||
|
'last_used_at' => null,
|
||||||
|
'name' => fake()->words(2, true),
|
||||||
|
'user_id' => User::factory(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_applications', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('key_hash', 64)->unique();
|
||||||
|
$table->timestamp('last_used_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'name']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_applications');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,14 +3,18 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "KanbanGit Application API",
|
"title": "KanbanGit Application API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "First-party JSON API for KanbanGit UI-equivalent workflows. Authentication is intentionally not described here because API verification will be added globally by the application later."
|
"description": "First-party JSON API for KanbanGit UI-equivalent workflows. Application endpoints require a bearer application key created in Settings > Applications."
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
"url": "/api"
|
"url": "/api"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": [],
|
"security": [
|
||||||
|
{
|
||||||
|
"ApplicationKey": []
|
||||||
|
}
|
||||||
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
{
|
{
|
||||||
"name": "Projects"
|
"name": "Projects"
|
||||||
@@ -29,6 +33,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Git Connections"
|
"name": "Git Connections"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Users"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
@@ -1041,10 +1048,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/users/{user}/assigned-tickets": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Users"],
|
||||||
|
"operationId": "getUserAssignedTickets",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/UserId"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/AssignedTicketsPayload"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthenticated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
|
"securitySchemes": {
|
||||||
|
"ApplicationKey": {
|
||||||
|
"type": "http",
|
||||||
|
"scheme": "bearer",
|
||||||
|
"bearerFormat": "KanbanGit application key",
|
||||||
|
"description": "Use a first-party application key created in Settings > Applications."
|
||||||
|
}
|
||||||
|
},
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
"UserId": {
|
||||||
|
"name": "user",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"ProjectId": {
|
"ProjectId": {
|
||||||
"name": "project",
|
"name": "project",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
@@ -1600,6 +1642,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"AssignedTicketsPayload": {
|
||||||
|
"description": "Tickets assigned to a user grouped by projects visible to the authenticated application key owner.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AssignedTicketsPayloadEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ValidationError": {
|
"ValidationError": {
|
||||||
"description": "Laravel validation error response.",
|
"description": "Laravel validation error response.",
|
||||||
"content": {
|
"content": {
|
||||||
@@ -1779,6 +1831,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"AssignedTicketsPayloadEnvelope": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AssignedTicketsProjectGroup"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"assignee": {
|
||||||
|
"$ref": "#/components/schemas/User"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["assignee"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["data", "meta"]
|
||||||
|
},
|
||||||
|
"AssignedTicketsProjectGroup": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project": {
|
||||||
|
"$ref": "#/components/schemas/Project"
|
||||||
|
},
|
||||||
|
"tickets": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Ticket"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["project", "tickets"]
|
||||||
|
},
|
||||||
"BoardColumn": {
|
"BoardColumn": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true,
|
"additionalProperties": true,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Separator } from '@/components/ui/separator';
|
|||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
import { toUrl } from '@/lib/utils';
|
import { toUrl } from '@/lib/utils';
|
||||||
import { edit as editAppearance } from '@/routes/appearance/index';
|
import { edit as editAppearance } from '@/routes/appearance/index';
|
||||||
|
import { edit as editApplications } from '@/routes/applications/index';
|
||||||
import { edit as editGitConnections } from '@/routes/git-connections/index';
|
import { edit as editGitConnections } from '@/routes/git-connections/index';
|
||||||
import { edit as editProfile } from '@/routes/profile/index';
|
import { edit as editProfile } from '@/routes/profile/index';
|
||||||
import { edit as editSecurity } from '@/routes/security/index';
|
import { edit as editSecurity } from '@/routes/security/index';
|
||||||
@@ -24,6 +25,10 @@ const sidebarNavItems: NavItem[] = [
|
|||||||
title: 'Git connections',
|
title: 'Git connections',
|
||||||
href: editGitConnections(),
|
href: editGitConnections(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Applications',
|
||||||
|
href: editApplications(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Appearance',
|
title: 'Appearance',
|
||||||
href: editAppearance(),
|
href: editAppearance(),
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Form, Head } from '@inertiajs/vue3';
|
||||||
|
import UserApplicationController from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
|
||||||
|
import Heading from '@/components/Heading.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
|
import { edit as editApplications } from '@/routes/applications/index';
|
||||||
|
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
|
||||||
|
|
||||||
|
defineProps<SettingsApplicationsPageProps>();
|
||||||
|
|
||||||
|
const breadcrumbItems: BreadcrumbItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Applications',
|
||||||
|
href: editApplications(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatDate = (value: string | null): string => {
|
||||||
|
if (value === null) {
|
||||||
|
return 'Never';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(value));
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||||
|
<Head title="Applications" />
|
||||||
|
|
||||||
|
<SettingsLayout>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Heading
|
||||||
|
variant="small"
|
||||||
|
title="Applications"
|
||||||
|
description="Create API keys for tools that should use KanbanGit with your account permissions."
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Keys are shown once. Delete an application when a tool
|
||||||
|
should lose access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="status"
|
||||||
|
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||||
|
>
|
||||||
|
{{ status }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="applicationKey"
|
||||||
|
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-100"
|
||||||
|
>
|
||||||
|
<p class="font-medium">Save this API key now.</p>
|
||||||
|
<p class="mt-1">
|
||||||
|
It will not be shown again after you leave this page.
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
class="mt-3 font-mono text-xs"
|
||||||
|
:model-value="applicationKey"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create application</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Name the tool that will receive an API key.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<Form
|
||||||
|
v-bind="UserApplicationController.store.form()"
|
||||||
|
class="space-y-4"
|
||||||
|
v-slot="{ errors, processing }"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">Application name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
placeholder="Claude Desktop"
|
||||||
|
/>
|
||||||
|
<InputError :message="errors.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" :disabled="processing">
|
||||||
|
Create application
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="gap-0 overflow-hidden">
|
||||||
|
<CardHeader class="border-b">
|
||||||
|
<CardTitle>Saved applications</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Existing keys stay hidden. Create a new
|
||||||
|
application when another key is needed.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-4 p-6">
|
||||||
|
<div
|
||||||
|
v-if="applications.length === 0"
|
||||||
|
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||||
|
>
|
||||||
|
No applications have been created yet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="application in applications"
|
||||||
|
:key="application.id"
|
||||||
|
class="border-border rounded-xl border p-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-start justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<Badge>API key</Badge>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ application.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
Created:
|
||||||
|
{{
|
||||||
|
formatDate(
|
||||||
|
application.created_at,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
Last used:
|
||||||
|
{{
|
||||||
|
formatDate(
|
||||||
|
application.last_used_at,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
v-bind="
|
||||||
|
UserApplicationController.destroy.form(
|
||||||
|
application.id,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-slot="{ processing }"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export type UserApplicationModel = {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
name: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
};
|
||||||
@@ -14,6 +14,7 @@ import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
|||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
||||||
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
|
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
|
||||||
|
import type { UserApplicationModel } from '@/types/models/user-application-model';
|
||||||
import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model';
|
import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model';
|
||||||
import type { UserModel } from '@/types/models/user-model';
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
|
|
||||||
@@ -150,3 +151,9 @@ export type SettingsGitConnectionsPageProps = {
|
|||||||
gitConnections: UserGitConnectionModel[];
|
gitConnections: UserGitConnectionModel[];
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SettingsApplicationsPageProps = {
|
||||||
|
applicationKey?: string | null;
|
||||||
|
applications: UserApplicationModel[];
|
||||||
|
status?: string | null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,3 +3,5 @@
|
|||||||
require __DIR__.'/api/projects.php';
|
require __DIR__.'/api/projects.php';
|
||||||
|
|
||||||
require __DIR__.'/api/settings.php';
|
require __DIR__.'/api/settings.php';
|
||||||
|
|
||||||
|
require __DIR__.'/api/users.php';
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\UserAssignedTicketController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('users')
|
||||||
|
->name('api.users.')
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('{user}/assigned-tickets', UserAssignedTicketController::class)
|
||||||
|
->name('assigned-tickets');
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
use App\Http\Controllers\Settings\GitConnectionController;
|
use App\Http\Controllers\Settings\GitConnectionController;
|
||||||
use App\Http\Controllers\Settings\ProfileController;
|
use App\Http\Controllers\Settings\ProfileController;
|
||||||
use App\Http\Controllers\Settings\SecurityController;
|
use App\Http\Controllers\Settings\SecurityController;
|
||||||
|
use App\Http\Controllers\Settings\UserApplicationController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::middleware('auth')
|
Route::middleware('auth')
|
||||||
@@ -41,5 +42,14 @@ Route::middleware(['auth', 'verified'])
|
|||||||
Route::delete('{gitConnection}', 'destroy')->name('destroy');
|
Route::delete('{gitConnection}', 'destroy')->name('destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::prefix('applications')
|
||||||
|
->name('applications.')
|
||||||
|
->controller(UserApplicationController::class)
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('/', 'edit')->name('edit');
|
||||||
|
Route::post('/', 'store')->name('store');
|
||||||
|
Route::delete('{userApplication}', 'destroy')->name('destroy');
|
||||||
|
});
|
||||||
|
|
||||||
Route::inertia('appearance', 'settings/Appearance')->name('appearance.edit');
|
Route::inertia('appearance', 'settings/Appearance')->name('appearance.edit');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserApplication;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ApiApplicationKeyTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_missing_bearer_token_returns_unauthorized(): void
|
||||||
|
{
|
||||||
|
$this->getJson(route('api.projects.index'))
|
||||||
|
->assertUnauthorized()
|
||||||
|
->assertJsonPath('message', 'Unauthenticated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_bearer_token_returns_unauthorized(): void
|
||||||
|
{
|
||||||
|
$this->withHeader('Authorization', 'Bearer wrong-key')
|
||||||
|
->getJson(route('api.projects.index'))
|
||||||
|
->assertUnauthorized()
|
||||||
|
->assertJsonPath('message', 'Unauthenticated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_valid_bearer_token_authenticates_as_owner(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'name' => 'Visible project',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $user, ['view']);
|
||||||
|
|
||||||
|
$this->withApplicationKey($user)
|
||||||
|
->getJson(route('api.projects.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.0.name', 'Visible project')
|
||||||
|
->assertJsonCount(1, 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_application_key_owner_permissions_still_apply(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$visibleProject = Project::factory()->create([
|
||||||
|
'name' => 'Visible project',
|
||||||
|
]);
|
||||||
|
Project::factory()->create([
|
||||||
|
'name' => 'Hidden project',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($visibleProject, $user, ['view']);
|
||||||
|
|
||||||
|
$this->withApplicationKey($user)
|
||||||
|
->getJson(route('api.projects.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.0.name', 'Visible project')
|
||||||
|
->assertJsonCount(1, 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_application_last_used_at_updates_after_use(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$plainTextKey = UserApplication::generatePlainTextKey();
|
||||||
|
$application = UserApplication::query()->create([
|
||||||
|
'key_hash' => UserApplication::hashKey($plainTextKey),
|
||||||
|
'name' => 'Claude Desktop',
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $user, ['view']);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer {$plainTextKey}")
|
||||||
|
->getJson(route('api.projects.index'))
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->assertNotNull($application->fresh()->last_used_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AssignedTicketsApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_assigned_tickets_are_grouped_by_visible_project(): void
|
||||||
|
{
|
||||||
|
$viewer = User::factory()->create();
|
||||||
|
$assignee = User::factory()->create([
|
||||||
|
'name' => 'Assigned User',
|
||||||
|
]);
|
||||||
|
$otherAssignee = User::factory()->create();
|
||||||
|
|
||||||
|
$olderProject = Project::factory()->create([
|
||||||
|
'key' => 'OLD',
|
||||||
|
'name' => 'Older project',
|
||||||
|
]);
|
||||||
|
$newerProject = Project::factory()->create([
|
||||||
|
'key' => 'NEW',
|
||||||
|
'name' => 'Newer project',
|
||||||
|
]);
|
||||||
|
$hiddenProject = Project::factory()->create([
|
||||||
|
'key' => 'HID',
|
||||||
|
'name' => 'Hidden project',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($olderProject, $viewer, ['view']);
|
||||||
|
$this->syncProjectPermissions($newerProject, $viewer, ['view']);
|
||||||
|
|
||||||
|
Ticket::factory()->create([
|
||||||
|
'assignee_user_id' => $assignee->id,
|
||||||
|
'project_id' => $olderProject->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'OLD-1',
|
||||||
|
'title' => 'Older visible ticket',
|
||||||
|
]);
|
||||||
|
Ticket::factory()->create([
|
||||||
|
'assignee_user_id' => $assignee->id,
|
||||||
|
'project_id' => $newerProject->id,
|
||||||
|
'sequence_number' => 2,
|
||||||
|
'ticket_key' => 'NEW-2',
|
||||||
|
'title' => 'Newer visible ticket',
|
||||||
|
]);
|
||||||
|
Ticket::factory()->create([
|
||||||
|
'assignee_user_id' => $assignee->id,
|
||||||
|
'project_id' => $hiddenProject->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'HID-1',
|
||||||
|
'title' => 'Hidden ticket',
|
||||||
|
]);
|
||||||
|
Ticket::factory()->create([
|
||||||
|
'assignee_user_id' => $otherAssignee->id,
|
||||||
|
'project_id' => $olderProject->id,
|
||||||
|
'sequence_number' => 3,
|
||||||
|
'ticket_key' => 'OLD-3',
|
||||||
|
'title' => 'Other user ticket',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->withApplicationKey($viewer)
|
||||||
|
->getJson(route('api.users.assigned-tickets', $assignee))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('meta.assignee.name', 'Assigned User')
|
||||||
|
->assertJsonPath('data.0.project.name', 'Newer project')
|
||||||
|
->assertJsonPath('data.0.tickets.0.ticket_key', 'NEW-2')
|
||||||
|
->assertJsonCount(1, 'data.0.tickets')
|
||||||
|
->assertJsonPath('data.1.project.name', 'Older project')
|
||||||
|
->assertJsonPath('data.1.tickets.0.ticket_key', 'OLD-1')
|
||||||
|
->assertJsonCount(1, 'data.1.tickets')
|
||||||
|
->assertJsonCount(2, 'data');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ class OpenApiDocumentationTest extends TestCase
|
|||||||
$this->assertIsArray($document);
|
$this->assertIsArray($document);
|
||||||
$this->assertSame(JSON_ERROR_NONE, json_last_error());
|
$this->assertSame(JSON_ERROR_NONE, json_last_error());
|
||||||
$this->assertSame('3.1.0', $document['openapi']);
|
$this->assertSame('3.1.0', $document['openapi']);
|
||||||
$this->assertSame([], $document['security']);
|
$this->assertSame([['ApplicationKey' => []]], $document['security']);
|
||||||
|
|
||||||
$expectedOperations = [
|
$expectedOperations = [
|
||||||
['get', '/projects'],
|
['get', '/projects'],
|
||||||
@@ -67,6 +67,7 @@ class OpenApiDocumentationTest extends TestCase
|
|||||||
['post', '/git-connections'],
|
['post', '/git-connections'],
|
||||||
['patch', '/git-connections/{gitConnection}'],
|
['patch', '/git-connections/{gitConnection}'],
|
||||||
['delete', '/git-connections/{gitConnection}'],
|
['delete', '/git-connections/{gitConnection}'],
|
||||||
|
['get', '/users/{user}/assigned-tickets'],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($expectedOperations as [$method, $path]) {
|
foreach ($expectedOperations as [$method, $path]) {
|
||||||
@@ -74,8 +75,13 @@ class OpenApiDocumentationTest extends TestCase
|
|||||||
$this->assertArrayHasKey($method, $document['paths'][$path]);
|
$this->assertArrayHasKey($method, $document['paths'][$path]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('ApplicationKey', $document['components']['securitySchemes']);
|
||||||
|
$this->assertSame('http', $document['components']['securitySchemes']['ApplicationKey']['type']);
|
||||||
|
$this->assertSame('bearer', $document['components']['securitySchemes']['ApplicationKey']['scheme']);
|
||||||
$this->assertArrayHasKey('ValidationError', $document['components']['schemas']);
|
$this->assertArrayHasKey('ValidationError', $document['components']['schemas']);
|
||||||
$this->assertArrayHasKey('TicketUpdateRequest', $document['components']['schemas']);
|
$this->assertArrayHasKey('TicketUpdateRequest', $document['components']['schemas']);
|
||||||
$this->assertArrayHasKey('UserGitConnectionRequest', $document['components']['schemas']);
|
$this->assertArrayHasKey('UserGitConnectionRequest', $document['components']['schemas']);
|
||||||
|
$this->assertArrayHasKey('AssignedTicketsPayloadEnvelope', $document['components']['schemas']);
|
||||||
|
$this->assertArrayHasKey('AssignedTicketsProjectGroup', $document['components']['schemas']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class ProjectApiTest extends TestCase
|
|||||||
|
|
||||||
$this->syncProjectPermissions($visibleProject, $user, ['view']);
|
$this->syncProjectPermissions($visibleProject, $user, ['view']);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->getJson(route('api.projects.index'))
|
->getJson(route('api.projects.index'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.0.name', 'Visible project')
|
->assertJsonPath('data.0.name', 'Visible project')
|
||||||
@@ -36,7 +36,7 @@ class ProjectApiTest extends TestCase
|
|||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$this->syncGlobalPermissions($user, ['create']);
|
$this->syncGlobalPermissions($user, ['create']);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->postJson(route('api.projects.store'), [
|
->postJson(route('api.projects.store'), [
|
||||||
'key' => 'api',
|
'key' => 'api',
|
||||||
'name' => 'API Project',
|
'name' => 'API Project',
|
||||||
@@ -61,20 +61,20 @@ class ProjectApiTest extends TestCase
|
|||||||
|
|
||||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->getJson(route('api.projects.show', $project))
|
->getJson(route('api.projects.show', $project))
|
||||||
->assertForbidden();
|
->assertForbidden();
|
||||||
|
|
||||||
$this->syncProjectPermissions($project, $user, ['view']);
|
$this->syncProjectPermissions($project, $user, ['view']);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->getJson(route('api.projects.show', $project))
|
->getJson(route('api.projects.show', $project))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.key', 'CORE');
|
->assertJsonPath('data.key', 'CORE');
|
||||||
|
|
||||||
$this->syncGlobalPermissions($admin, ['admin']);
|
$this->syncGlobalPermissions($admin, ['admin']);
|
||||||
|
|
||||||
$this->actingAs($admin)
|
$this->withApplicationKey($admin)
|
||||||
->deleteJson(route('api.projects.destroy', $project), [
|
->deleteJson(route('api.projects.destroy', $project), [
|
||||||
'confirmation_key' => 'CORE',
|
'confirmation_key' => 'CORE',
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class PublicApiDocumentationTest extends TestCase
|
|||||||
$document = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
$document = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
$this->assertSame('3.1.0', $document['openapi']);
|
$this->assertSame('3.1.0', $document['openapi']);
|
||||||
$this->assertSame([], $document['security']);
|
$this->assertSame([['ApplicationKey' => []]], $document['security']);
|
||||||
$this->assertArrayHasKey('/projects', $document['paths']);
|
$this->assertArrayHasKey('/projects', $document['paths']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ class SettingsApiTest extends TestCase
|
|||||||
{
|
{
|
||||||
[$user, $project] = $this->projectContext();
|
[$user, $project] = $this->projectContext();
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->getJson(route('api.projects.settings.board', $project))
|
->getJson(route('api.projects.settings.board', $project))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.backlogTicketStatuses.0.name', 'Backlog');
|
->assertJsonPath('data.backlogTicketStatuses.0.name', 'Backlog');
|
||||||
|
|
||||||
$columnId = $this->actingAs($user)
|
$columnId = $this->withApplicationKey($user)
|
||||||
->postJson(route('api.projects.settings.board.columns.store', $project), [
|
->postJson(route('api.projects.settings.board.columns.store', $project), [
|
||||||
'name' => 'In Review',
|
'name' => 'In Review',
|
||||||
])
|
])
|
||||||
@@ -30,7 +30,7 @@ class SettingsApiTest extends TestCase
|
|||||||
->assertJsonPath('data.name', 'In Review')
|
->assertJsonPath('data.name', 'In Review')
|
||||||
->json('data.id');
|
->json('data.id');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->patchJson(route('api.projects.settings.board.columns.update', [$project, $columnId]), [
|
->patchJson(route('api.projects.settings.board.columns.update', [$project, $columnId]), [
|
||||||
'name' => 'Review',
|
'name' => 'Review',
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
@@ -38,7 +38,7 @@ class SettingsApiTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.name', 'Review');
|
->assertJsonPath('data.name', 'Review');
|
||||||
|
|
||||||
$statusId = $this->actingAs($user)
|
$statusId = $this->withApplicationKey($user)
|
||||||
->postJson(route('api.projects.settings.board.statuses.store', $project), [
|
->postJson(route('api.projects.settings.board.statuses.store', $project), [
|
||||||
'board_column_id' => $columnId,
|
'board_column_id' => $columnId,
|
||||||
'name' => 'Ready',
|
'name' => 'Ready',
|
||||||
@@ -46,7 +46,7 @@ class SettingsApiTest extends TestCase
|
|||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('data.name', 'Ready')
|
->assertJsonPath('data.name', 'Ready')
|
||||||
->json('data.id');
|
->json('data.id');
|
||||||
$replacementStatusId = $this->actingAs($user)
|
$replacementStatusId = $this->withApplicationKey($user)
|
||||||
->postJson(route('api.projects.settings.board.statuses.store', $project), [
|
->postJson(route('api.projects.settings.board.statuses.store', $project), [
|
||||||
'board_column_id' => $columnId,
|
'board_column_id' => $columnId,
|
||||||
'name' => 'QA',
|
'name' => 'QA',
|
||||||
@@ -56,14 +56,14 @@ class SettingsApiTest extends TestCase
|
|||||||
|
|
||||||
$backlogStatus = $project->ticketStatuses()->whereNull('board_column_id')->firstOrFail();
|
$backlogStatus = $project->ticketStatuses()->whereNull('board_column_id')->firstOrFail();
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->patchJson(route('api.projects.settings.board.statuses.transitions.update', [$project, $backlogStatus]), [
|
->patchJson(route('api.projects.settings.board.statuses.transitions.update', [$project, $backlogStatus]), [
|
||||||
'allowed_ticket_status_ids' => [$statusId],
|
'allowed_ticket_status_ids' => [$statusId],
|
||||||
])
|
])
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.allowed_target_statuses.0.id', $statusId);
|
->assertJsonPath('data.allowed_target_statuses.0.id', $statusId);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->deleteJson(route('api.projects.settings.board.statuses.destroy', [$project, $statusId]))
|
->deleteJson(route('api.projects.settings.board.statuses.destroy', [$project, $statusId]))
|
||||||
->assertNoContent();
|
->assertNoContent();
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ class SettingsApiTest extends TestCase
|
|||||||
'id' => $replacementStatusId,
|
'id' => $replacementStatusId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->deleteJson(route('api.projects.settings.board.columns.destroy', [$project, $columnId]))
|
->deleteJson(route('api.projects.settings.board.columns.destroy', [$project, $columnId]))
|
||||||
->assertNoContent();
|
->assertNoContent();
|
||||||
}
|
}
|
||||||
@@ -80,12 +80,12 @@ class SettingsApiTest extends TestCase
|
|||||||
{
|
{
|
||||||
[$user, $project] = $this->projectContext();
|
[$user, $project] = $this->projectContext();
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->getJson(route('api.projects.settings.metadata', $project))
|
->getJson(route('api.projects.settings.metadata', $project))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.ticketTypes.0.name', 'Task');
|
->assertJsonPath('data.ticketTypes.0.name', 'Task');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->patchJson(route('api.projects.settings.metadata.update', $project), [
|
->patchJson(route('api.projects.settings.metadata.update', $project), [
|
||||||
'show_author' => false,
|
'show_author' => false,
|
||||||
'show_story_points' => false,
|
'show_story_points' => false,
|
||||||
@@ -93,7 +93,7 @@ class SettingsApiTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.show_author', false);
|
->assertJsonPath('data.show_author', false);
|
||||||
|
|
||||||
$typeId = $this->actingAs($user)
|
$typeId = $this->withApplicationKey($user)
|
||||||
->postJson(route('api.projects.settings.metadata.types.store', $project), [
|
->postJson(route('api.projects.settings.metadata.types.store', $project), [
|
||||||
'is_parent' => true,
|
'is_parent' => true,
|
||||||
'name' => 'Epic',
|
'name' => 'Epic',
|
||||||
@@ -102,7 +102,7 @@ class SettingsApiTest extends TestCase
|
|||||||
->assertJsonPath('data.name', 'Epic')
|
->assertJsonPath('data.name', 'Epic')
|
||||||
->json('data.id');
|
->json('data.id');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->patchJson(route('api.projects.settings.metadata.types.update', [$project, $typeId]), [
|
->patchJson(route('api.projects.settings.metadata.types.update', [$project, $typeId]), [
|
||||||
'name' => 'Initiative',
|
'name' => 'Initiative',
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
@@ -110,14 +110,14 @@ class SettingsApiTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.name', 'Initiative');
|
->assertJsonPath('data.name', 'Initiative');
|
||||||
|
|
||||||
$priorityId = $this->actingAs($user)
|
$priorityId = $this->withApplicationKey($user)
|
||||||
->postJson(route('api.projects.settings.metadata.priorities.store', $project), [
|
->postJson(route('api.projects.settings.metadata.priorities.store', $project), [
|
||||||
'name' => 'Urgent',
|
'name' => 'Urgent',
|
||||||
])
|
])
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->json('data.id');
|
->json('data.id');
|
||||||
|
|
||||||
$fieldId = $this->actingAs($user)
|
$fieldId = $this->withApplicationKey($user)
|
||||||
->postJson(route('api.projects.settings.metadata.fields.store', $project), [
|
->postJson(route('api.projects.settings.metadata.fields.store', $project), [
|
||||||
'field_type' => 'number',
|
'field_type' => 'number',
|
||||||
'label' => 'Risk',
|
'label' => 'Risk',
|
||||||
@@ -125,26 +125,26 @@ class SettingsApiTest extends TestCase
|
|||||||
->assertCreated()
|
->assertCreated()
|
||||||
->json('data.id');
|
->json('data.id');
|
||||||
|
|
||||||
$linkTypeId = $this->actingAs($user)
|
$linkTypeId = $this->withApplicationKey($user)
|
||||||
->postJson(route('api.projects.settings.metadata.link-types.store', $project), [
|
->postJson(route('api.projects.settings.metadata.link-types.store', $project), [
|
||||||
'name' => 'Relates to',
|
'name' => 'Relates to',
|
||||||
])
|
])
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->json('data.id');
|
->json('data.id');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->deleteJson(route('api.projects.settings.metadata.fields.destroy', [$project, $fieldId]))
|
->deleteJson(route('api.projects.settings.metadata.fields.destroy', [$project, $fieldId]))
|
||||||
->assertNoContent();
|
->assertNoContent();
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->deleteJson(route('api.projects.settings.metadata.link-types.destroy', [$project, $linkTypeId]))
|
->deleteJson(route('api.projects.settings.metadata.link-types.destroy', [$project, $linkTypeId]))
|
||||||
->assertNoContent();
|
->assertNoContent();
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->deleteJson(route('api.projects.settings.metadata.priorities.destroy', [$project, $priorityId]))
|
->deleteJson(route('api.projects.settings.metadata.priorities.destroy', [$project, $priorityId]))
|
||||||
->assertNoContent();
|
->assertNoContent();
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->deleteJson(route('api.projects.settings.metadata.types.destroy', [$project, $typeId]))
|
->deleteJson(route('api.projects.settings.metadata.types.destroy', [$project, $typeId]))
|
||||||
->assertNoContent();
|
->assertNoContent();
|
||||||
}
|
}
|
||||||
@@ -174,7 +174,7 @@ class SettingsApiTest extends TestCase
|
|||||||
|
|
||||||
[$user, $project] = $this->projectContext();
|
[$user, $project] = $this->projectContext();
|
||||||
|
|
||||||
$gitConnectionId = $this->actingAs($user)
|
$gitConnectionId = $this->withApplicationKey($user)
|
||||||
->postJson(route('api.git-connections.store'), [
|
->postJson(route('api.git-connections.store'), [
|
||||||
'api_token' => 'secret-token',
|
'api_token' => 'secret-token',
|
||||||
'base_url' => 'https://git.example.test/api/v1',
|
'base_url' => 'https://git.example.test/api/v1',
|
||||||
@@ -184,12 +184,12 @@ class SettingsApiTest extends TestCase
|
|||||||
->assertJsonPath('data.account_login', 'api-user')
|
->assertJsonPath('data.account_login', 'api-user')
|
||||||
->json('data.id');
|
->json('data.id');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->getJson(route('api.git-connections.index'))
|
->getJson(route('api.git-connections.index'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.0.id', $gitConnectionId);
|
->assertJsonPath('data.0.id', $gitConnectionId);
|
||||||
|
|
||||||
$repositoryConnectionId = $this->actingAs($user)
|
$repositoryConnectionId = $this->withApplicationKey($user)
|
||||||
->postJson(route('api.projects.settings.repositories.store', $project), [
|
->postJson(route('api.projects.settings.repositories.store', $project), [
|
||||||
'provider_repository_id' => '501',
|
'provider_repository_id' => '501',
|
||||||
'user_git_connection_id' => $gitConnectionId,
|
'user_git_connection_id' => $gitConnectionId,
|
||||||
@@ -198,17 +198,17 @@ class SettingsApiTest extends TestCase
|
|||||||
->assertJsonPath('data.repository_full_name', 'core-team/kanbangit')
|
->assertJsonPath('data.repository_full_name', 'core-team/kanbangit')
|
||||||
->json('data.id');
|
->json('data.id');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->getJson(route('api.projects.settings.repositories', $project))
|
->getJson(route('api.projects.settings.repositories', $project))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.repositoryConnections.0.id', $repositoryConnectionId)
|
->assertJsonPath('data.repositoryConnections.0.id', $repositoryConnectionId)
|
||||||
->assertJsonPath('data.availableRepositorySources.0.repositories.0.full_name', 'core-team/kanbangit');
|
->assertJsonPath('data.availableRepositorySources.0.repositories.0.full_name', 'core-team/kanbangit');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->deleteJson(route('api.projects.settings.repositories.destroy', [$project, $repositoryConnectionId]))
|
->deleteJson(route('api.projects.settings.repositories.destroy', [$project, $repositoryConnectionId]))
|
||||||
->assertNoContent();
|
->assertNoContent();
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->deleteJson(route('api.git-connections.destroy', $gitConnectionId))
|
->deleteJson(route('api.git-connections.destroy', $gitConnectionId))
|
||||||
->assertNoContent();
|
->assertNoContent();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class TicketApiTest extends TestCase
|
|||||||
$status = $project->ticketStatuses()->firstOrFail();
|
$status = $project->ticketStatuses()->firstOrFail();
|
||||||
$type = $project->ticketTypes()->where('is_parent', false)->firstOrFail();
|
$type = $project->ticketTypes()->where('is_parent', false)->firstOrFail();
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->getJson(route('api.projects.ticket-options', [
|
->getJson(route('api.projects.ticket-options', [
|
||||||
'project' => $project,
|
'project' => $project,
|
||||||
'source' => 'backlog',
|
'source' => 'backlog',
|
||||||
@@ -37,7 +37,7 @@ class TicketApiTest extends TestCase
|
|||||||
->assertJsonPath('data.defaultTicketStatusId', $status->id)
|
->assertJsonPath('data.defaultTicketStatusId', $status->id)
|
||||||
->assertJsonPath('data.ticketTypes.0.id', $type->id);
|
->assertJsonPath('data.ticketTypes.0.id', $type->id);
|
||||||
|
|
||||||
$createResponse = $this->actingAs($user)
|
$createResponse = $this->withApplicationKey($user)
|
||||||
->postJson(route('api.projects.tickets.store', $project), [
|
->postJson(route('api.projects.tickets.store', $project), [
|
||||||
'acceptance_criteria' => 'Can sign in',
|
'acceptance_criteria' => 'Can sign in',
|
||||||
'description' => 'Build the API-created ticket',
|
'description' => 'Build the API-created ticket',
|
||||||
@@ -53,12 +53,12 @@ class TicketApiTest extends TestCase
|
|||||||
|
|
||||||
$ticket = Ticket::query()->findOrFail($createResponse->json('data.id'));
|
$ticket = Ticket::query()->findOrFail($createResponse->json('data.id'));
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->getJson(route('api.projects.tickets.index', $project))
|
->getJson(route('api.projects.tickets.index', $project))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.0.ticket_key', 'CORE-1');
|
->assertJsonPath('data.0.ticket_key', 'CORE-1');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [
|
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [
|
||||||
'acceptance_criteria' => 'Can sign in from the API',
|
'acceptance_criteria' => 'Can sign in from the API',
|
||||||
'blocked_comment' => null,
|
'blocked_comment' => null,
|
||||||
@@ -74,7 +74,7 @@ class TicketApiTest extends TestCase
|
|||||||
->assertJsonPath('data.title', 'Updated API ticket')
|
->assertJsonPath('data.title', 'Updated API ticket')
|
||||||
->assertJsonPath('meta.message', 'CORE-1 was updated.');
|
->assertJsonPath('meta.message', 'CORE-1 was updated.');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->getJson(route('api.projects.tickets.show', [$project, $ticket]))
|
->getJson(route('api.projects.tickets.show', [$project, $ticket]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.ticket.ticket_key', 'CORE-1')
|
->assertJsonPath('data.ticket.ticket_key', 'CORE-1')
|
||||||
@@ -101,13 +101,13 @@ class TicketApiTest extends TestCase
|
|||||||
|
|
||||||
$ticket = $this->ticket($project, $backlogStatus);
|
$ticket = $this->ticket($project, $backlogStatus);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->getJson(route('api.projects.tickets.status-targets', [$project, $ticket]))
|
->getJson(route('api.projects.tickets.status-targets', [$project, $ticket]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.current_status.id', $backlogStatus->id)
|
->assertJsonPath('data.current_status.id', $backlogStatus->id)
|
||||||
->assertJsonPath('data.target_statuses.0.id', $doingStatus->id);
|
->assertJsonPath('data.target_statuses.0.id', $doingStatus->id);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->patchJson(route('api.projects.tickets.status.update', [$project, $ticket]), [
|
->patchJson(route('api.projects.tickets.status.update', [$project, $ticket]), [
|
||||||
'ticket_status_id' => $doingStatus->id,
|
'ticket_status_id' => $doingStatus->id,
|
||||||
])
|
])
|
||||||
@@ -157,7 +157,7 @@ class TicketApiTest extends TestCase
|
|||||||
'position' => 1,
|
'position' => 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->postJson(route('api.projects.tickets.links.store', [$project, $ticket]), [
|
->postJson(route('api.projects.tickets.links.store', [$project, $ticket]), [
|
||||||
'linked_ticket_id' => $linkedTicket->id,
|
'linked_ticket_id' => $linkedTicket->id,
|
||||||
'ticket_link_type_id' => $linkType->id,
|
'ticket_link_type_id' => $linkType->id,
|
||||||
@@ -181,7 +181,7 @@ class TicketApiTest extends TestCase
|
|||||||
'provider' => GitProviderKey::Gitea,
|
'provider' => GitProviderKey::Gitea,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->getJson(route('api.projects.tickets.branches.validate', [
|
->getJson(route('api.projects.tickets.branches.validate', [
|
||||||
'project' => $project,
|
'project' => $project,
|
||||||
'ticket' => $ticket,
|
'ticket' => $ticket,
|
||||||
@@ -191,7 +191,7 @@ class TicketApiTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.is_valid', true);
|
->assertJsonPath('data.is_valid', true);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->postJson(route('api.projects.tickets.branches.store', [$project, $ticket]), [
|
->postJson(route('api.projects.tickets.branches.store', [$project, $ticket]), [
|
||||||
'branch_name' => 'core-1-api-ticket',
|
'branch_name' => 'core-1-api-ticket',
|
||||||
'repository_connection_id' => $repositoryConnection->id,
|
'repository_connection_id' => $repositoryConnection->id,
|
||||||
@@ -219,12 +219,12 @@ class TicketApiTest extends TestCase
|
|||||||
'position' => 1,
|
'position' => 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->getJson(route('api.projects.backlog', $project))
|
->getJson(route('api.projects.backlog', $project))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.tickets.0.id', $ticket->id);
|
->assertJsonPath('data.tickets.0.id', $ticket->id);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->withApplicationKey($user)
|
||||||
->getJson(route('api.projects.board', $project))
|
->getJson(route('api.projects.board', $project))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.boardColumns.0.name', 'Todo');
|
->assertJsonPath('data.boardColumns.0.name', 'Todo');
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Models;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserApplication;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class UserApplicationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_application_keys_are_hashed_and_hidden(): void
|
||||||
|
{
|
||||||
|
$plainTextKey = UserApplication::generatePlainTextKey();
|
||||||
|
$keyHash = UserApplication::hashKey($plainTextKey);
|
||||||
|
|
||||||
|
$this->assertStringStartsWith('kg_app_', $plainTextKey);
|
||||||
|
$this->assertNotSame($plainTextKey, $keyHash);
|
||||||
|
$this->assertSame(64, strlen($keyHash));
|
||||||
|
$this->assertSame($keyHash, UserApplication::hashKey($plainTextKey));
|
||||||
|
|
||||||
|
$application = UserApplication::query()->create([
|
||||||
|
'key_hash' => $keyHash,
|
||||||
|
'name' => 'Claude Desktop',
|
||||||
|
'user_id' => User::factory()->create()->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('key_hash', $application->toArray());
|
||||||
|
$this->assertDatabaseHas('user_applications', [
|
||||||
|
'id' => $application->id,
|
||||||
|
'key_hash' => $keyHash,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseMissing('user_applications', [
|
||||||
|
'id' => $application->id,
|
||||||
|
'key_hash' => $plainTextKey,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Settings;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserApplication;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class UserApplicationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_applications_page_is_displayed(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
UserApplication::factory()->for($user)->create([
|
||||||
|
'name' => 'Claude Desktop',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('applications.edit'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('settings/Applications')
|
||||||
|
->where('applications.0.name', 'Claude Desktop')
|
||||||
|
->where('applicationKey', null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_users_can_create_applications_and_see_plaintext_key_once(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->post(route('applications.store'), [
|
||||||
|
'name' => 'Claude Desktop',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('applications.edit'))
|
||||||
|
->assertSessionHas('applicationKey')
|
||||||
|
->assertSessionHas('status', 'Claude Desktop was created. Save this key now; it will not be shown again.');
|
||||||
|
|
||||||
|
$plainTextKey = (string) $response->baseResponse->getSession()->get('applicationKey');
|
||||||
|
|
||||||
|
$this->assertStringStartsWith('kg_app_', $plainTextKey);
|
||||||
|
$this->assertDatabaseHas('user_applications', [
|
||||||
|
'name' => 'Claude Desktop',
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'key_hash' => UserApplication::hashKey($plainTextKey),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseMissing('user_applications', [
|
||||||
|
'name' => 'Claude Desktop',
|
||||||
|
'key_hash' => $plainTextKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('applications.edit'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->where('applicationKey', $plainTextKey),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('applications.edit'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->where('applicationKey', null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_users_can_delete_their_own_applications(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$application = UserApplication::factory()->for($user)->create([
|
||||||
|
'name' => 'Claude Desktop',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->delete(route('applications.destroy', $application))
|
||||||
|
->assertRedirect(route('applications.edit'))
|
||||||
|
->assertSessionHas('status', 'Claude Desktop was removed.');
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('user_applications', [
|
||||||
|
'id' => $application->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_users_cannot_delete_foreign_applications(): void
|
||||||
|
{
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$application = UserApplication::factory()->for($owner)->create();
|
||||||
|
|
||||||
|
$this->actingAs($otherUser)
|
||||||
|
->delete(route('applications.destroy', $application))
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('user_applications', [
|
||||||
|
'id' => $application->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use App\Enums\Permission;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\UserApplication;
|
||||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
|
|
||||||
@@ -55,6 +56,26 @@ abstract class TestCase extends BaseTestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function withApplicationKey(User $user, ?string $name = null): static
|
||||||
|
{
|
||||||
|
$plainTextKey = $this->createPlainTextApplicationKey($user, $name);
|
||||||
|
|
||||||
|
return $this->withHeader('Authorization', "Bearer {$plainTextKey}");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function createPlainTextApplicationKey(User $user, ?string $name = null): string
|
||||||
|
{
|
||||||
|
$plainTextKey = UserApplication::generatePlainTextKey();
|
||||||
|
|
||||||
|
UserApplication::query()->create([
|
||||||
|
'key_hash' => UserApplication::hashKey($plainTextKey),
|
||||||
|
'name' => $name ?? 'Test application',
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $plainTextKey;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, Permission|string> $permissions
|
* @param array<int, Permission|string> $permissions
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
|
|||||||
Reference in New Issue
Block a user