Compare commits

...

4 Commits

Author SHA1 Message Date
Giuseppe Naponiello
032f6a08df users and institutions route, tests fixed 2026-06-23 12:29:02 +02:00
Giuseppe Naponiello
fe73662903 feature test su institution 2026-06-22 22:14:15 +02:00
Giuseppe Naponiello
cc4e174880 auth tests 2026-06-22 14:31:44 +02:00
Giuseppe Naponiello
34a8ebc6fb setup and render template file, create new logo in different shapes 2026-06-17 22:28:31 +02:00
130 changed files with 7625 additions and 171 deletions

View File

@@ -30,21 +30,18 @@ LOG_LEVEL=debug # warning/error in produzione
# Usati sia da Laravel sia dal container mysql (mappati nel compose).
DB_CONNECTION=mysql
DB_HOST=db # nome del servizio docker
DB_PORT=3306
DB_PORT=3306 # porta di connessione INTERNA (mysql nel container)
DB_HOST_PORT=3310 # porta pubblicata sull'host; ≠ 3306 per non collidere
DB_DATABASE=dyncoll
DB_USERNAME=dyncoll
DB_PASSWORD= # segreto: compilare
DB_ROOT_PASSWORD= # segreto: solo per il container mysql (root)
# --- Database legacy (v1) — sorgente per l'ETL `php artisan v1:import` --------
# Connessione in SOLA LETTURA verso il MySQL di dyncoll.v1.
# Attiva solo durante l'import; richiede rete docker condivisa o tunnel.
DB_LEGACY_CONNECTION=mysql
DB_LEGACY_HOST=dyncoll_v1_db # container/host del DB v1
DB_LEGACY_PORT=3306
DB_LEGACY_DATABASE=lund
DB_LEGACY_USERNAME=readonly
DB_LEGACY_PASSWORD= # segreto: compilare
# NB: le variabili DB_LEGACY_* (sorgente in SOLA LETTURA per l'ETL
# `php artisan v1:import`) NON stanno qui di proposito: servono solo durante la
# migrazione una-tantum da dyncoll.v1, non nella configurazione standard. Al
# momento del cutover aggiungerle a mano al .env con i valori corretti
# dell'ambiente (host/porta/credenziali possono differire dal locale).
# --- Redis (cache, sessioni, code, Horizon) ----------------------------------
REDIS_HOST=redis
@@ -62,6 +59,10 @@ FILESYSTEM_DISK=local
# --- Autenticazione (Sanctum / Fortify) --------------------------------------
SANCTUM_STATEFUL_DOMAINS=dyncoll-dev.local,localhost,localhost:8080
SESSION_DOMAIN=.dyncoll-dev.local
# Sicurezza del cookie di sessione (app servita in HTTPS via Traefik).
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
SESSION_HTTP_ONLY=true
# --- Mail (SMTP) -------------------------------------------------------------
# DEV: Mailpit (servizio nel docker-compose.override.yml). UI: http://localhost:8025

View File

@@ -12,7 +12,7 @@ DC := docker compose
EXEC := $(DC) exec -u $(UID):$(GID) backend
.DEFAULT_GOAL := help
.PHONY: help up down build logs composer be-install be-update artisan migrate import tinker fe-install fe-add fe-rebuild fe-dev fe-build permissions
.PHONY: help up down build logs composer be-install be-update artisan migrate import tinker test fe-install fe-add fe-rebuild fe-dev fe-build fe-lint-css fe-lint-css-fix permissions
help: ## Mostra questo aiuto
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}'
@@ -42,6 +42,20 @@ import: ## ETL da v1 (container)
$(EXEC) php artisan v1:import
tinker: ## REPL artisan (container)
$(EXEC) php artisan tinker
test: ## Suite di test sul DB dedicato db-test (es: make test c="--filter=AuthTest")
# Le -e iniettano VERE env nel processo di test → finiscono in $$_SERVER, che
# l'env() di Laravel legge per primo. Così vincono sul container (DB_HOST=db,
# DB_DATABASE=dyncoll) SENZA dipendere dai force di phpunit.xml. DB_HOST=db-test
# = isolamento fisico: la suite non può nemmeno raggiungere il db di sviluppo.
$(DC) exec -u $(UID):$(GID) \
-e APP_ENV=testing \
-e DB_HOST=db-test \
-e DB_DATABASE=dyncoll_test \
-e CACHE_STORE=array \
-e SESSION_DRIVER=array \
-e QUEUE_CONNECTION=sync \
-e MAIL_MAILER=array \
backend php artisan test $(c)
## --- Frontend / Node ---
# In dev Vite gira NEL container (make up): HMR via bind-mount, node_modules musl
@@ -58,7 +72,10 @@ fe-dev: ## ALTERNATIVA: vite dev server su host senza Docker (conflitto porta 51
cd frontend && npm run dev
fe-build: ## build di produzione su host (smoke test locale)
cd frontend && npm run build
fe-lint-css: ## Stylelint (check) nel container — gira anche nel pre-commit
$(DC) exec -T frontend npm run lint:css
fe-lint-css-fix: ## Stylelint con --fix (container come UID host: scrive sul bind-mount)
$(DC) exec -u $(UID):$(GID) -T frontend npm run lint:css:fix
## --- Manutenzione ---
permissions: ## Permessi storage scrivibili da php-fpm (www-data) — richiede sudo, una tantum
sudo chgrp -R www-data backend/storage backend/bootstrap/cache

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
],
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => $this->passwordRules(),
], [
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Actions;
use App\Exceptions\CannotDeleteSystemUserException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* Cancellazione definitiva di un utente conforme al GDPR (diritto all'oblio).
*
* Non rimuove la riga (va preservata l'integrità referenziale verso i contenuti
* e la traccia di audit): i contenuti posseduti vengono riassegnati all'utente
* di sistema e i dati personali anonimizzati in-place. Il record resta
* soft-deleted ma, marcato `anonymized_at`, esce dal cestino e dalle liste.
*
* Idempotente: un utente già anonimizzato viene ignorato.
*/
class PurgeUserAction
{
/**
* Modelli posseduti (con colonna `user_id`) da riassegnare all'utente di
* sistema prima dell'anonimizzazione. Aggiungere qui i modelli di contenuto
* man mano che vengono creati (devono usare SoftDeletes).
*
* @var list<class-string>
*/
private const OWNED_MODELS = [];
public function execute(User $user): void
{
if ($user->is_system) {
throw CannotDeleteSystemUserException::make();
}
if ($user->anonymized_at !== null) {
return; // già anonimizzato: idempotente
}
$system = User::where('is_system', true)->firstOrFail();
DB::transaction(function () use ($user, $system) {
$this->reassignContent($user, $system);
$this->anonymize($user);
});
}
/**
* Riassegna all'utente di sistema tutti i contenuti posseduti, inclusi
* quelli a loro volta nel cestino.
*/
private function reassignContent(User $user, User $system): void
{
foreach (self::OWNED_MODELS as $model) {
$model::withTrashed()
->where('user_id', $user->id)
->update(['user_id' => $system->id]);
}
}
/**
* Azzera i dati personali mantenendo la riga per l'integrità referenziale.
*/
private function anonymize(User $user): void
{
$user->forceFill([
'name' => 'Deleted user',
'email' => "deleted-{$user->id}@anonymized.invalid",
'password' => Hash::make(Str::random(60)),
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
'two_factor_setup_completed_at' => null,
'email_verified_at' => null,
'remember_token' => null,
'must_change_password' => false,
'anonymized_at' => now(),
])->save();
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Actions\PurgeUserAction;
use App\Models\User;
use Illuminate\Console\Command;
/**
* Anonimizza (GDPR) gli utenti soft-deleted oltre la retention, riassegnandone
* i contenuti all'utente di sistema. Idempotente, pensato per esecuzione
* schedulata giornaliera (vedi routes/console.php).
*/
class PurgeAnonymizableUsers extends Command
{
protected $signature = 'users:purge
{--days=30 : Days kept in the trash before anonymization}
{--dry-run : Show the affected users without anonymizing}';
protected $description = 'Anonymize users deleted beyond the retention window (GDPR right to be forgotten)';
public function handle(PurgeUserAction $purge): int
{
$days = (int) $this->option('days');
$dryRun = (bool) $this->option('dry-run');
$users = User::onlyTrashed()
->where('is_system', false)
->whereNull('anonymized_at')
->where('deleted_at', '<=', now()->subDays($days))
->get();
if ($users->isEmpty()) {
$this->info('No users to anonymize.');
return self::SUCCESS;
}
foreach ($users as $user) {
if ($dryRun) {
$this->line(sprintf(' [dry-run] #%d %s', $user->id, $user->email));
continue;
}
$purge->execute($user);
$this->line(sprintf(' ✓ anonymized user #%d', $user->id));
}
$this->info(($dryRun ? '[dry-run] ' : '').'Processed users: '.$users->count());
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Console\Commands;
use App\Etl\Contracts\Importer;
use App\Etl\Importers\InstitutionImporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* ETL idempotente dal DB legacy (v1) allo schema v2.
*
* Orchestra gli importer in ORDINE DI DIPENDENZA (institutions prima delle entità
* che le referenziano, es. artifacts). Ogni importer legge dalla connessione
* `legacy` e scrive su quella di default. Serve sia per i refresh di sviluppo
* sia per la migrazione di cutover.
*/
class V1ImportCommand extends Command
{
protected $signature = 'v1:import
{--only=* : Limit to specific importers (e.g. --only=institutions)}
{--dry-run : Read and count without writing}';
protected $description = 'Import data from the legacy (v1) database into the v2 schema (idempotent)';
/**
* Importer in ordine di dipendenza.
*
* @var list<class-string<Importer>>
*/
private array $importers = [
InstitutionImporter::class,
];
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$only = array_map('strtolower', (array) $this->option('only'));
try {
DB::connection('legacy')->getPdo();
} catch (Throwable $e) {
$this->error('Legacy connection unavailable: '.$e->getMessage());
$this->line('Set DB_LEGACY_* in the environment (see config/database.php → connections.legacy).');
return self::FAILURE;
}
if ($dryRun) {
$this->warn('DRY-RUN: no data will be written.');
}
$hadError = false;
foreach ($this->importers as $class) {
/** @var Importer $importer */
$importer = app($class);
if ($only !== [] && ! in_array($importer->key(), $only, true)) {
continue;
}
$this->info("{$importer->label()}");
try {
$summary = $importer->import($dryRun);
} catch (Throwable $e) {
$this->error(" {$importer->key()} failed: ".$e->getMessage());
$hadError = true;
continue;
}
foreach ($summary->warnings as $warning) {
$this->warn(' ! '.$warning);
}
$this->line(sprintf(
' %screated %d, updated %d, skipped %d (total %d)',
$dryRun ? '[dry-run] ' : '',
$summary->created,
$summary->updated,
$summary->skipped,
$summary->total(),
));
}
return $hadError ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Enums;
/**
* Tipologia di risorsa web collegata a un'istituzione.
*
* Vocabolario stabile, controllato dallo sviluppatore: backed enum (non MySQL
* ENUM lookup table). Gli artifact useranno un enum separato (ArtifactLinkType)
* con un proprio vocabolario.
*/
enum InstitutionLinkType: string
{
case Official = 'official';
case Ticketing = 'ticketing';
case Social = 'social';
case Catalogue = 'catalogue';
case Other = 'other';
/**
* Etichetta leggibile per la UI (inglese: lingua ufficiale dell'applicazione).
*/
public function label(): string
{
return match ($this) {
self::Official => 'Official website',
self::Ticketing => 'Online ticketing',
self::Social => 'Social media',
self::Catalogue => 'Online catalogue',
self::Other => 'Other resource',
};
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Etl\Contracts;
use App\Etl\ImportSummary;
/**
* Un importer ETL trasforma una porzione del DB legacy (v1) nello schema v2.
*
* Contratto comune a tutti gli importer (institutions, poi artifacts, ...): il
* comando `v1:import` li orchestra in ordine di dipendenza. Ogni import deve
* essere IDEMPOTENTE (upsert su `legacy_id`) e leggere SOLO dalla connessione
* `legacy`, scrivere SOLO sulla connessione di default (v2).
*/
interface Importer
{
/**
* Chiave breve per il filtro `--only` (es. "institutions").
*/
public function key(): string;
/**
* Etichetta leggibile per l'output del comando.
*/
public function label(): string;
/**
* Esegue l'import. In dry-run non scrive nulla, ma calcola comunque i conteggi.
*/
public function import(bool $dryRun): ImportSummary;
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Etl;
/**
* Esito di un import: conteggi e avvisi non bloccanti (da stampare nel comando).
*/
class ImportSummary
{
public int $created = 0;
public int $updated = 0;
public int $skipped = 0;
/** @var list<string> */
public array $warnings = [];
public function warn(string $message): void
{
$this->warnings[] = $message;
}
public function total(): int
{
return $this->created + $this->updated + $this->skipped;
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Etl\Importers;
use App\Enums\InstitutionLinkType;
use App\Etl\Contracts\Importer;
use App\Etl\ImportSummary;
use App\Models\Institution;
use App\Models\InstitutionLink;
use App\Models\Lists\InstitutionCategory;
use Illuminate\Support\Facades\DB;
/**
* Importa le istituzioni da v1 (`institution`) nello schema v2.
*
* - Upsert su `legacy_id` idempotente (re-run aggiorna, non duplica).
* - `category` (v1) `category_id` (v2): mappa 1:1 perché il lookup è seedato con
* gli stessi id legacy. Categoria assente nel lookup (es. 1 "uncategorized") skip + warning.
* - `uuid` NON copiato dal legacy: lo rigenera il model (orderedUuid) alla creazione.
* - `url` (campo singolo v1) riga `institution_links` con type=official, solo se valorizzato.
* - Pulizia dati: trim su name/abbreviation/address/city/logo.
*
* NB l'auditing è già off in console (audit.console=false), quindi l'import non genera audit.
* NB la migrazione dei FILE logo (da v1 a storage/app/public) è fuori scope di questa fetta:
* qui si copia solo il valore stringa della colonna.
*/
class InstitutionImporter implements Importer
{
private const SOURCE = 'legacy';
public function key(): string
{
return 'institutions';
}
public function label(): string
{
return 'Institutions';
}
public function import(bool $dryRun): ImportSummary
{
$summary = new ImportSummary;
/** @var list<int> $validCategoryIds */
$validCategoryIds = InstitutionCategory::query()->pluck('id')->map(intval(...))->all();
if ($validCategoryIds === []) {
$summary->warn('Lookup institution_categories vuoto: esegui prima `db:seed`. Import saltato.');
return $summary;
}
$rows = DB::connection(self::SOURCE)
->table('institution')
->orderBy('id')
->get();
DB::transaction(function () use ($rows, $dryRun, $summary, $validCategoryIds): void {
foreach ($rows as $row) {
$this->importRow($row, $dryRun, $validCategoryIds, $summary);
}
});
return $summary;
}
/**
* Importa (upsert) una singola istituzione legacy. Categoria assente nel
* lookup v2 skip con warning.
*
* @param list<int> $validCategoryIds
*/
private function importRow(object $row, bool $dryRun, array $validCategoryIds, ImportSummary $summary): void
{
$categoryId = (int) $row->category;
if (! in_array($categoryId, $validCategoryIds, true)) {
$summary->warn(sprintf(
"Institution legacy #%d ('%s'): category %d assente nel lookup v2 → saltata.",
$row->id, trim((string) $row->name), $categoryId,
));
$summary->skipped++;
return;
}
if ($dryRun) {
Institution::query()->where('legacy_id', (int) $row->id)->exists()
? $summary->updated++
: $summary->created++;
return;
}
$institution = Institution::updateOrCreate(
['legacy_id' => (int) $row->id],
$this->mapAttributes($row, $categoryId),
);
$institution->wasRecentlyCreated ? $summary->created++ : $summary->updated++;
$this->syncOfficialLink($institution, (string) ($row->url ?? ''));
}
/**
* Mappa una riga legacy sugli attributi del model v2 (con pulizia dei dati).
*
* @return array<string, mixed>
*/
private function mapAttributes(object $row, int $categoryId): array
{
return [
'category_id' => $categoryId,
'name' => trim((string) $row->name),
'abbreviation' => trim((string) $row->abbreviation),
'address' => trim((string) $row->address),
'city' => trim((string) $row->city),
'lat' => $row->lat,
'lon' => $row->lon,
'logo' => trim((string) $row->logo),
'color' => $this->cleanColor($row->color),
'is_storage_place' => (bool) $row->is_storage_place,
];
}
/**
* Colore valido o fallback al default v2.
*/
private function cleanColor(mixed $color): string
{
$color = trim((string) ($color ?? ''));
return $color !== '' ? $color : '#c5cae9';
}
/**
* Sincronizza il link "official" dall'unico `url` legacy. Idempotente
* (upsert su institution_id + type). URL vuoto/blank nessun link.
*/
private function syncOfficialLink(Institution $institution, string $url): void
{
$url = trim($url);
if ($url === '') {
return;
}
InstitutionLink::updateOrCreate(
[
'institution_id' => $institution->id,
'type' => InstitutionLinkType::Official,
],
[
'url' => $url,
'sort_order' => 0,
],
);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Sollevata quando si tenta di anonimizzare/eliminare l'utente di sistema,
* che deve restare sempre presente come destinatario dei contenuti riassegnati.
*/
class CannotDeleteSystemUserException extends RuntimeException
{
public static function make(): self
{
return new self('The system user cannot be deleted.');
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Http\Traits\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
class AuthController extends Controller
{
use ApiResponse;
/**
* Utente autenticato corrente (include l'accessor setup_status).
* Risposta al primo livello, come da convenzione Sanctum SPA.
*/
public function me(Request $request): JsonResponse
{
return response()->json($request->user());
}
/**
* Logout: chiude la sessione stateful e ripulisce i cookie.
* Sovrascrive la rotta omonima di Fortify (registrata dopo ha precedenza).
*/
public function logout(Request $request): JsonResponse
{
$this->tearDownSession($request);
return $this->forgetAuthCookies($this->messageResponse('auth.logout_success'));
}
/**
* Cancellazione self-service dell'account: soft-delete + logout.
* L'anonimizzazione definitiva (GDPR) la fa l'Admin o la retention.
*/
public function destroyAccount(Request $request): JsonResponse
{
$user = $request->user();
abort_if($user->is_system, 403);
$user->delete();
$this->tearDownSession($request);
return $this->forgetAuthCookies($this->messageResponse('account.deleted'));
}
/**
* Invalida la sessione corrente e rigenera il token CSRF.
*/
private function tearDownSession(Request $request): void
{
Auth::guard('web')->logout();
if ($request->hasSession()) {
$request->session()->invalidate();
$request->session()->regenerateToken();
}
}
/**
* Marca per la cancellazione i cookie di sessione e XSRF lato client.
*/
private function forgetAuthCookies(JsonResponse $response): JsonResponse
{
return $response
->withCookie(Cookie::forget(config('session.cookie')))
->withCookie(Cookie::forget('XSRF-TOKEN'));
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreInstitutionCategoryRequest;
use App\Http\Requests\UpdateInstitutionCategoryRequest;
use App\Http\Traits\ApiResponse;
use App\Models\Lists\InstitutionCategory;
use Illuminate\Http\JsonResponse;
/**
* CRUD del lookup categorie di istituzione (scrittura riservata agli Admin).
*
* NB gli id 2/3/4/6 sono allineati al legacy e usati dall'ETL: cancellarne uno
* in uso è bloccato (409). Nessun soft delete su questo lookup.
*/
class InstitutionCategoryController extends Controller
{
use ApiResponse;
/**
* Elenco delle categorie.
*/
public function index(): JsonResponse
{
return $this->collectionResponse(InstitutionCategory::orderBy('value')->get());
}
/**
* Crea una categoria.
*/
public function store(StoreInstitutionCategoryRequest $request): JsonResponse
{
$category = InstitutionCategory::create($request->validated());
return $this->createdResponse($category);
}
/**
* Dettaglio di una categoria.
*/
public function show(InstitutionCategory $institutionCategory): JsonResponse
{
return $this->okResponse($institutionCategory);
}
/**
* Aggiorna una categoria.
*/
public function update(
UpdateInstitutionCategoryRequest $request,
InstitutionCategory $institutionCategory
): JsonResponse {
$institutionCategory->update($request->validated());
return $this->updatedResponse($institutionCategory);
}
/**
* Elimina una categoria (se non assegnata ad alcuna istituzione).
*/
public function destroy(InstitutionCategory $institutionCategory): JsonResponse
{
if ($institutionCategory->isInUse()) {
return $this->conflictResponse('Cannot delete: category assigned to at least one institution.');
}
$institutionCategory->delete();
return $this->deletedResponse();
}
/**
* Indica se la categoria è in uso (bloccata per l'eliminazione).
*/
public function usage(InstitutionCategory $institutionCategory): JsonResponse
{
return response()->json([
'in_use' => $institutionCategory->isInUse(),
]);
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\IndexInstitutionRequest;
use App\Http\Requests\StoreInstitutionRequest;
use App\Http\Requests\UpdateInstitutionRequest;
use App\Http\Traits\ApiResponse;
use App\Models\Institution;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
/**
* CRUD delle istituzioni (scrittura riservata agli Admin via tier di rotta).
*
* Identificate verso l'esterno dallo `uuid` (route key), non dall'id interno.
* Soft delete: destroy = cestina, restore = ripristina, forceDestroy = elimina
* definitivamente (e rimuove il file logo + i link via cascade FK).
*/
class InstitutionController extends Controller
{
use ApiResponse;
/** Sottocartella del disco `public` dove vivono i loghi. */
private const LOGO_DIR = 'institution_logo';
/**
* Elenco paginato, con filtri opzionali (ricerca, categoria, cestino).
*/
public function index(IndexInstitutionRequest $request): JsonResponse
{
$query = Institution::query()
->with('category')
->orderBy('name');
$this->applyTrashed($query, $request->input('trashed'));
$this->applySearch($query, $request->input('search'));
if ($request->filled('category_id')) {
$query->where('category_id', $request->integer('category_id'));
}
$institutions = $query->paginate($request->integer('per_page') ?: 20);
return $this->paginatedCollectionResponse($institutions);
}
/**
* Crea un'istituzione (con upload del logo).
*/
public function store(StoreInstitutionRequest $request): JsonResponse
{
$data = $request->validated();
$data['logo'] = $this->storeLogo($request->file('logo'));
$institution = Institution::create($data);
return $this->createdResponse($institution->load('category'));
}
/**
* Dettaglio (con categoria e link).
*/
public function show(Institution $institution): JsonResponse
{
return $this->okResponse($institution->load(['category', 'links']));
}
/**
* Aggiorna un'istituzione. Logo opzionale: se presente sostituisce il vecchio.
*/
public function update(UpdateInstitutionRequest $request, Institution $institution): JsonResponse
{
$data = $request->validated();
if ($request->hasFile('logo')) {
$this->deleteLogo($institution->logo);
$data['logo'] = $this->storeLogo($request->file('logo'));
} else {
unset($data['logo']);
}
$institution->update($data);
return $this->updatedResponse($institution->load('category'));
}
/**
* Cestina (soft delete).
*/
public function destroy(Institution $institution): JsonResponse
{
$institution->delete();
return $this->deletedResponse();
}
/**
* Ripristina un'istituzione cestinata.
*/
public function restore(Institution $institution): JsonResponse
{
$institution->restore();
return $this->restoredResponse($institution->load('category'));
}
/**
* Elimina definitivamente (rimuove il file logo; i link cadono in cascata DB).
*/
public function forceDestroy(Institution $institution): JsonResponse
{
$this->deleteLogo($institution->logo);
$institution->forceDelete();
return $this->deletedResponse();
}
/**
* Salva il file logo sul disco `public` e ne restituisce il path relativo.
*/
private function storeLogo(UploadedFile $file): string
{
return $file->store(self::LOGO_DIR, 'public');
}
/**
* Rimuove un file logo dal disco `public`, se presente.
*/
private function deleteLogo(?string $path): void
{
if (filled($path)) {
Storage::disk('public')->delete($path);
}
}
/**
* Include i soft-deleted: `with` (tutti) o `only` (solo cestinati).
*
* @param Builder<Institution> $query
*/
private function applyTrashed(Builder $query, ?string $trashed): void
{
match ($trashed) {
'with' => $query->withTrashed(),
'only' => $query->onlyTrashed(),
default => null,
};
}
/**
* Ricerca parziale su nome, città e sigla.
*
* @param Builder<Institution> $query
*/
private function applySearch(Builder $query, ?string $search): void
{
if (blank($search)) {
return;
}
$term = '%'.$search.'%';
$query->where(function (Builder $q) use ($term): void {
$q->where('name', 'like', $term)
->orWhere('city', 'like', $term)
->orWhere('abbreviation', 'like', $term);
});
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreInstitutionLinkRequest;
use App\Http\Requests\UpdateInstitutionLinkRequest;
use App\Http\Traits\ApiResponse;
use App\Models\Institution;
use App\Models\InstitutionLink;
use Illuminate\Http\JsonResponse;
/**
* Risorse web di un'istituzione (sito ufficiale, ticketing, social, ...).
*
* Risorsa annidata e scoped sotto `institutions/{institution}`: il link deve
* appartenere all'istituzione del path. Nessun soft delete (solo Institution lo ha).
*/
class InstitutionLinkController extends Controller
{
use ApiResponse;
/**
* Elenco dei link di un'istituzione, ordinati.
*/
public function index(Institution $institution): JsonResponse
{
$links = $institution->links()->orderBy('sort_order')->get();
return $this->collectionResponse($links);
}
/**
* Aggiunge un link all'istituzione.
*/
public function store(StoreInstitutionLinkRequest $request, Institution $institution): JsonResponse
{
$link = $institution->links()->create($request->validated());
return $this->createdResponse($link);
}
/**
* Dettaglio di un link.
*/
public function show(Institution $institution, InstitutionLink $link): JsonResponse
{
return $this->okResponse($link);
}
/**
* Aggiorna un link.
*/
public function update(UpdateInstitutionLinkRequest $request, Institution $institution, InstitutionLink $link): JsonResponse
{
$link->update($request->validated());
return $this->updatedResponse($link);
}
/**
* Elimina un link.
*/
public function destroy(Institution $institution, InstitutionLink $link): JsonResponse
{
$link->delete();
return $this->deletedResponse();
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\IndexUserRequest;
use App\Http\Traits\ApiResponse;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
/**
* Gestione utenti riservata agli amministratori (sola lettura per ora:
* la creazione/invito e la modifica arriveranno con la gestione account).
*
* Ogni utente è serializzato col ruolo correlato e l'accessor `setup_status`
* (password_required / 2fa_setup_required / complete), utile alla dashboard admin.
*/
class UserController extends Controller
{
use ApiResponse;
/**
* Elenco paginato degli utenti, con filtri opzionali (ricerca, ruolo, cestino).
*/
public function index(IndexUserRequest $request): JsonResponse
{
$query = User::query()
->with('role')
->orderBy('name');
$this->applyTrashed($query, $request->input('trashed'));
$this->applySearch($query, $request->input('search'));
if ($request->filled('role_id')) {
$query->where('role_id', $request->integer('role_id'));
}
$users = $query->paginate($request->integer('per_page') ?: 20);
return $this->paginatedCollectionResponse($users);
}
/**
* Dettaglio di un singolo utente (anche cestinato/anonimizzato).
*/
public function show(User $user): JsonResponse
{
$user->load('role');
return $this->okResponse($user);
}
/**
* Include i soft-deleted nell'elenco: `with` (tutti) o `only` (solo cestinati).
*
* @param Builder<User> $query
*/
private function applyTrashed(Builder $query, ?string $trashed): void
{
match ($trashed) {
'with' => $query->withTrashed(),
'only' => $query->onlyTrashed(),
default => null,
};
}
/**
* Filtra per nome o email (ricerca parziale, case-insensitive).
*
* @param Builder<User> $query
*/
private function applySearch(Builder $query, ?string $search): void
{
if (blank($search)) {
return;
}
$term = '%'.$search.'%';
$query->where(function (Builder $q) use ($term): void {
$q->where('name', 'like', $term)
->orWhere('email', 'like', $term);
});
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Http\Traits\ApiResponse;
use App\Models\Lists\UserPosition;
use Illuminate\Http\JsonResponse;
class UserPositionController extends Controller
{
use ApiResponse;
/**
* Elenco delle position.
*/
public function index(): JsonResponse
{
return $this->collectionResponse(UserPosition::all());
}
/**
* Dettaglio di una position.
*/
public function show(UserPosition $userPosition): JsonResponse
{
return $this->okResponse($userPosition);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use App\Http\Traits\ApiResponse;
use App\Models\Lists\UserRole;
use Illuminate\Http\JsonResponse;
class UserRoleController extends Controller
{
use ApiResponse;
/**
* Elenco dei ruoli.
*/
public function index(): JsonResponse
{
return $this->collectionResponse(UserRole::all());
}
/**
* Dettaglio di un ruolo.
*/
public function show(UserRole $userRole): JsonResponse
{
return $this->okResponse($userRole);
}
/**
* Numero di utenti con questo ruolo (statistica per Admin).
*/
public function usage(UserRole $userRole): JsonResponse
{
return response()->json([
'user_count' => $userRole->users()->count(),
]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureSetupComplete
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): mixed
{
$user = $request->user();
if (! $user) {
return $next($request);
}
$setupStatus = null;
if ($user->must_change_password) {
$setupStatus = 'password_required';
} elseif (is_null($user->two_factor_setup_completed_at)) {
$setupStatus = '2fa_setup_required';
}
if ($setupStatus !== null) {
return response()->json([
'setup_status' => $setupStatus,
], 403);
}
return $next($request);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ForceJsonResponse
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$request->headers->set('Accept', 'application/json');
return $next($request);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class IsAdmin
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check() || Auth::user()->role?->name !== 'Admin') {
abort(403);
}
return $next($request);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class IndexInstitutionRequest extends FormRequest
{
/**
* L'autorizzazione è gestita dal middleware di rotta (tier.app).
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'search' => ['nullable', 'string', 'max:255'],
'category_id' => ['nullable', 'integer', 'exists:institution_categories,id'],
'trashed' => ['nullable', Rule::in(['with', 'only'])],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class IndexUserRequest extends FormRequest
{
/**
* L'autorizzazione (auth + admin + setup) è gestita dal middleware di rotta.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'search' => ['nullable', 'string', 'max:255'],
'role_id' => ['nullable', 'integer', 'exists:user_roles,id'],
'trashed' => ['nullable', Rule::in(['with', 'only'])],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreInstitutionCategoryRequest extends FormRequest
{
/**
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'value' => ['required', 'string', 'max:25', 'unique:institution_categories,value'],
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests;
use App\Enums\InstitutionLinkType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreInstitutionLinkRequest extends FormRequest
{
/**
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
*/
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->mergeIfMissing(['sort_order' => 0]);
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'url' => ['required', 'url', 'max:2000'],
'type' => ['required', Rule::enum(InstitutionLinkType::class)],
'label' => ['nullable', 'string', 'max:100'],
'sort_order' => ['required', 'integer', 'min:0'],
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreInstitutionRequest extends FormRequest
{
/**
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
*/
public function authorize(): bool
{
return true;
}
/**
* Default per i campi con valore di colonna (color, is_storage_place):
* così non si inseriscono mai NULL su colonne NOT NULL.
*/
protected function prepareForValidation(): void
{
$this->mergeIfMissing([
'color' => '#c5cae9',
'is_storage_place' => true,
]);
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'category_id' => ['required', 'integer', 'exists:institution_categories,id'],
'name' => ['required', 'string', 'max:255', 'unique:institutions,name'],
'abbreviation' => ['required', 'string', 'max:5', 'unique:institutions,abbreviation'],
'address' => ['required', 'string', 'max:255'],
'city' => ['required', 'string', 'max:100'],
'lat' => ['required', 'numeric', 'between:-90,90'],
'lon' => ['required', 'numeric', 'between:-180,180'],
'logo' => ['required', 'image', 'max:5120'],
'color' => ['required', 'string', 'max:50'],
'is_storage_place' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateInstitutionCategoryRequest extends FormRequest
{
/**
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$id = $this->route('institution_category')->id;
return [
'value' => ['required', 'string', 'max:25', Rule::unique('institution_categories', 'value')->ignore($id)],
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests;
use App\Enums\InstitutionLinkType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateInstitutionLinkRequest extends FormRequest
{
/**
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
*/
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->mergeIfMissing(['sort_order' => 0]);
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'url' => ['required', 'url', 'max:2000'],
'type' => ['required', Rule::enum(InstitutionLinkType::class)],
'label' => ['nullable', 'string', 'max:100'],
'sort_order' => ['required', 'integer', 'min:0'],
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateInstitutionRequest extends FormRequest
{
/**
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$id = $this->route('institution')->id;
return [
'category_id' => ['required', 'integer', 'exists:institution_categories,id'],
'name' => ['required', 'string', 'max:255', Rule::unique('institutions', 'name')->ignore($id)],
'abbreviation' => ['required', 'string', 'max:5', Rule::unique('institutions', 'abbreviation')->ignore($id)],
'address' => ['required', 'string', 'max:255'],
'city' => ['required', 'string', 'max:100'],
'lat' => ['required', 'numeric', 'between:-90,90'],
'lon' => ['required', 'numeric', 'between:-180,180'],
// Opzionale in update: se assente si mantiene il logo esistente.
'logo' => ['nullable', 'image', 'max:5120'],
'color' => ['required', 'string', 'max:50'],
'is_storage_place' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Http\Traits;
use Illuminate\Http\JsonResponse;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Risposte JSON standardizzate per i controller API.
*
* I messaggi sono presi da config/messages.php. Passando un $prefix si possono
* usare messaggi specifici per risorsa (es. "user_roles.created"); senza prefix
* si usano le chiavi generiche di primo livello.
*/
trait ApiResponse
{
/**
* 200 Lista paginata: { message, data, meta }.
*/
protected function paginatedCollectionResponse(LengthAwarePaginator $paginator, ?string $prefix = null): JsonResponse
{
return response()->json([
'message' => config($prefix ? "messages.{$prefix}.index" : 'messages.index'),
'data' => $paginator->items(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
], 200);
}
/**
* 200 Collezione di risorse.
*/
protected function collectionResponse(mixed $collection, ?string $prefix = null): JsonResponse
{
return response()->json([
'message' => config($prefix ? "messages.{$prefix}.index" : 'messages.index'),
'data' => $collection,
], 200);
}
/**
* 200 Singola risorsa.
*/
protected function okResponse(mixed $data, ?string $prefix = null): JsonResponse
{
return response()->json([
'message' => config($prefix ? "messages.{$prefix}.show" : 'messages.show'),
'data' => $data,
], 200);
}
/**
* 201 Risorsa creata.
*/
protected function createdResponse(mixed $model, ?string $prefix = null): JsonResponse
{
return response()->json([
'message' => config($prefix ? "messages.{$prefix}.created" : 'messages.created'),
'data' => $model,
], 201);
}
/**
* 200 Risorsa aggiornata.
*/
protected function updatedResponse(mixed $model, ?string $prefix = null): JsonResponse
{
return response()->json([
'message' => config($prefix ? "messages.{$prefix}.updated" : 'messages.updated'),
'data' => $model,
], 200);
}
/**
* 200 Risorsa eliminata.
*/
protected function deletedResponse(?string $prefix = null): JsonResponse
{
return response()->json([
'message' => config($prefix ? "messages.{$prefix}.deleted" : 'messages.deleted'),
], 200);
}
/**
* 200 Risorsa ripristinata (soft delete restore).
*/
protected function restoredResponse(mixed $model, ?string $prefix = null): JsonResponse
{
return response()->json([
'message' => config($prefix ? "messages.{$prefix}.restored" : 'messages.restored'),
'data' => $model,
], 200);
}
/**
* 409 Conflitto: operazione bloccata (es. risorsa in uso).
*/
protected function conflictResponse(string $message): JsonResponse
{
return response()->json([
'message' => $message,
'in_use' => true,
], 409);
}
/**
* Risposta con solo messaggio chiave config es. "auth.login_success".
*/
protected function messageResponse(string $configKey, int $status = 200): JsonResponse
{
return response()->json([
'message' => config("messages.{$configKey}"),
], $status);
}
/**
* Risposta con messaggio e dati chiave config es. "auth.login_success".
*/
protected function dataResponse(mixed $data, string $configKey, int $status = 200): JsonResponse
{
return response()->json([
'message' => config("messages.{$configKey}"),
'data' => $data,
], $status);
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Models;
use App\Models\Lists\InstitutionCategory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use OwenIt\Auditing\Auditable as AuditableTrait;
use OwenIt\Auditing\Contracts\Auditable;
class Institution extends Model implements Auditable
{
use AuditableTrait;
use HasFactory;
use SoftDeletes;
protected $fillable = [
'category_id',
'name',
'abbreviation',
'address',
'city',
'lat',
'lon',
'logo',
'color',
'is_storage_place',
'legacy_id',
];
/**
* Identificatore esterno stabile, esposto nelle rotte pubbliche/LOD e usato
* come token nei canonical URI Linked Art. NON fillable: generato qui sotto
* (o impostato esplicitamente dall'ETL), mai via mass assignment.
*/
protected static function booted(): void
{
static::creating(function (Institution $institution): void {
if (empty($institution->uuid)) {
$institution->uuid = (string) Str::orderedUuid();
}
});
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'category_id' => 'integer',
'lat' => 'float',
'lon' => 'float',
'is_storage_place' => 'boolean',
'legacy_id' => 'integer',
];
}
/**
* La rotta pubblica risolve per uuid, non per id auto-increment.
*/
public function getRouteKeyName(): string
{
return 'uuid';
}
/**
* Categoria dell'istituzione.
*
* @return BelongsTo<InstitutionCategory, $this>
*/
public function category(): BelongsTo
{
return $this->belongsTo(InstitutionCategory::class, 'category_id');
}
/**
* Risorse web collegate (sito ufficiale, ticketing, social, ...).
*
* @return HasMany<InstitutionLink, $this>
*/
public function links(): HasMany
{
return $this->hasMany(InstitutionLink::class);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use App\Enums\InstitutionLinkType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use OwenIt\Auditing\Auditable as AuditableTrait;
use OwenIt\Auditing\Contracts\Auditable;
class InstitutionLink extends Model implements Auditable
{
use AuditableTrait;
use HasFactory;
protected $fillable = [
'institution_id',
'url',
'type',
'label',
'sort_order',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'institution_id' => 'integer',
'type' => InstitutionLinkType::class,
'sort_order' => 'integer',
];
}
/**
* Istituzione a cui appartiene il link.
*
* @return BelongsTo<Institution, $this>
*/
public function institution(): BelongsTo
{
return $this->belongsTo(Institution::class);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models\Lists;
use App\Models\Institution;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use OwenIt\Auditing\Auditable as AuditableTrait;
use OwenIt\Auditing\Contracts\Auditable;
/**
* Lookup delle categorie di istituzione.
*
* Tabella controllata: gli id sono fissi e coincidono con quelli del DB legacy
* (v1) così l'ETL mappa institutions.category_id 1:1 senza tradurre. La categoria
* legacy 1 ("uncategorized", mai usata) NON viene riportata.
*/
class InstitutionCategory extends Model implements Auditable
{
use AuditableTrait;
use HasFactory;
// Id allineati al legacy (vedi seeder). Nessun id 1 e nessun id 5.
public const LIBRARY = 'library';
public const MUSEUM = 'museum';
public const PUBLIC_ADMINISTRATION = 'public administration';
public const RESEARCH_INSTITUTE = 'research institute';
protected $table = 'institution_categories';
protected $fillable = [
'value',
];
/**
* Istituzioni che appartengono a questa categoria.
*
* @return HasMany<Institution, $this>
*/
public function institutions(): HasMany
{
return $this->hasMany(Institution::class, 'category_id');
}
/**
* La categoria è assegnata ad almeno un'istituzione.
*/
public function isInUse(): bool
{
return $this->institutions()->exists();
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models\Lists;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use OwenIt\Auditing\Auditable as AuditableTrait;
use OwenIt\Auditing\Contracts\Auditable;
class UserPosition extends Model implements Auditable
{
use AuditableTrait;
use HasFactory;
public const PROFESSOR = 'Professor';
public const RESEARCHER = 'Researcher';
public const PHD = 'PhD';
public const STUDENT = 'Student';
public const ADMINISTRATIVE = 'Administrative personnel';
protected $table = 'user_positions';
protected $fillable = ['value'];
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models\Lists;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use OwenIt\Auditing\Auditable as AuditableTrait;
use OwenIt\Auditing\Contracts\Auditable;
class UserRole extends Model implements Auditable
{
use AuditableTrait;
use HasFactory;
public const ADMIN = 'Admin';
public const SUPERVISOR = 'Supervisor';
public const USER = 'User';
public const GUEST = 'Guest';
protected $table = 'user_roles';
protected $fillable = [
'name',
'description',
];
/**
* Utenti che hanno questo ruolo.
*
* @return HasMany<User, $this>
*/
public function users(): HasMany
{
return $this->hasMany(User::class, 'role_id');
}
}

View File

@@ -2,20 +2,59 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use App\Models\Lists\UserRole;
use App\Notifications\ResetPasswordNotification;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
use OwenIt\Auditing\Contracts\Auditable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
class User extends Authenticatable implements Auditable, MustVerifyEmail
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
use \OwenIt\Auditing\Auditable;
use TwoFactorAuthenticatable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $appends = ['setup_status'];
protected $auditExclude = ['password', 'remember_token', 'two_factor_secret'];
protected $fillable = [
'name',
'email',
'password',
'role_id',
'is_system',
'must_change_password',
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_setup_completed_at',
'email_verified_at',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
];
/**
* Get the attributes that should be cast.
@@ -27,6 +66,43 @@ class User extends Authenticatable
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'role_id' => 'integer',
'must_change_password' => 'boolean',
'two_factor_setup_completed_at' => 'datetime',
'two_factor_confirmed_at' => 'datetime',
'is_system' => 'boolean',
'anonymized_at' => 'datetime',
];
}
/**
* Ruolo applicativo dell'utente.
*
* @return BelongsTo<UserRole, $this>
*/
public function role(): BelongsTo
{
return $this->belongsTo(UserRole::class, 'role_id');
}
/**
* Send the password reset notification.
*/
public function sendPasswordResetNotification($token)
{
$this->notify(new ResetPasswordNotification($token));
}
public function getSetupStatusAttribute(): string
{
if ($this->must_change_password) {
return 'password_required';
}
if (is_null($this->two_factor_setup_completed_at)) {
return '2fa_setup_required';
}
return 'complete';
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ResetPasswordNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(public readonly string $token)
{
// Il token viene passato al costruttore e reso disponibile come proprietà pubblica
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->line('The introduction to the notification.')
->action('Notification Action', url('/'))
->line('Thank you for using our application!');
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
//
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class WelcomeNotification extends Notification
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct()
{
//
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->line('The introduction to the notification.')
->action('Notification Action', url('/'))
->line('Thank you for using our application!');
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
//
];
}
}

View File

@@ -2,6 +2,9 @@
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +22,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
// Rate limiter delle rotte pubbliche (gruppo `throttle:public` in routes/api.php).
// Nessun utente loggato → si limita per IP. Tarare il numero sul traffico reale.
RateLimiter::for('public', fn (Request $request) => Limit::perMinute(60)->by($request->ip()));
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class);
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
RateLimiter::for('passkeys', function (Request $request) {
$credentialId = $request->input('credential.id');
return Limit::perMinute(10)->by(
($credentialId ?: $request->session()->getId()).'|'.$request->ip()
);
});
}
}

View File

@@ -1,18 +1,51 @@
<?php
use App\Http\Middleware\EnsureSetupComplete;
use App\Http\Middleware\ForceJsonResponse;
use App\Http\Middleware\IsAdmin;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
api: __DIR__.'/../routes/api.php', // NOSONAR
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
then: function (): void {
// Auto-discovery: ogni file in routes/api/ è caricato come gruppo sotto
// prefisso `api` e stack `api`. Aggiungere un modello = creare un file lì,
// senza toccare questo bootstrap. glob() ordina alfabeticamente → ordine
// di registrazione deterministico. (Dopo aver aggiunto un file, se usi la
// route cache rilancia `route:cache`, come per qualunque nuova rotta.)
foreach (glob(base_path('routes/api/*.php')) as $routeFile) {
Route::middleware('api')->prefix('api')->group($routeFile);
}
},
)
->withMiddleware(function (Middleware $middleware): void {
//
// per Sanctum
$middleware->statefulApi();
// App API-only: nessuna rotta 'login' esiste. Senza questo, Laravel usa
// il suo default redirectGuestsTo(route('login')) e un utente non
// autenticato che richiede una pagina senza Accept: application/json
// (es. browser diretto) genera RouteNotFoundException (500) invece di 401.
$middleware->redirectGuestsTo(null);
// Configurazione CORS
$middleware->preventRequestForgery(except: ['api/*']);
$middleware->alias([
'admin' => IsAdmin::class,
'setup.complete' => EnsureSetupComplete::class,
]);
// Tier riusabili nei file di routes/api/ — fonte di verità unica.
// (nomi `tier.*` per non confliggere con l'alias `admin`.)
$middleware->group('tier.app', ['auth:sanctum', 'verified', 'setup.complete']);
$middleware->group('tier.admin', ['auth:sanctum', 'verified', 'admin', 'setup.complete']);
$middleware->api(append: [ForceJsonResponse::class]);
$middleware->trustProxies(at: '*');
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(

View File

@@ -1,7 +1,9 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\FortifyServiceProvider;
return [
AppServiceProvider::class,
FortifyServiceProvider::class,
];

View File

@@ -3,7 +3,7 @@
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"keywords": ["laravel", "framework", "v.13.15.0"],
"license": "MIT",
"require": {
"php": "^8.3",

204
backend/config/audit.php Normal file
View File

@@ -0,0 +1,204 @@
<?php
use OwenIt\Auditing\Models\Audit;
use OwenIt\Auditing\Resolvers\IpAddressResolver;
use OwenIt\Auditing\Resolvers\UrlResolver;
use OwenIt\Auditing\Resolvers\UserAgentResolver;
use OwenIt\Auditing\Resolvers\UserResolver;
return [
'enabled' => env('AUDITING_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Audit Implementation
|--------------------------------------------------------------------------
|
| Define which Audit model implementation should be used.
|
*/
'implementation' => Audit::class,
/*
|--------------------------------------------------------------------------
| User Morph prefix & Guards
|--------------------------------------------------------------------------
|
| Define the morph prefix and authentication guards for the User resolver.
|
*/
'user' => [
'morph_prefix' => 'user',
'guards' => [
'web',
'api',
],
'resolver' => UserResolver::class,
],
/*
|--------------------------------------------------------------------------
| Audit Resolvers
|--------------------------------------------------------------------------
|
| Define the IP Address, User Agent and URL resolver implementations.
|
*/
'resolvers' => [
'ip_address' => IpAddressResolver::class,
'user_agent' => UserAgentResolver::class,
'url' => UrlResolver::class,
],
/*
|--------------------------------------------------------------------------
| Audit Events
|--------------------------------------------------------------------------
|
| The Eloquent events that trigger an Audit.
|
*/
'events' => [
'created',
'updated',
'deleted',
'restored',
],
/*
|--------------------------------------------------------------------------
| Strict Mode
|--------------------------------------------------------------------------
|
| Enable the strict mode when auditing?
|
*/
'strict' => false,
/*
|--------------------------------------------------------------------------
| Global exclude
|--------------------------------------------------------------------------
|
| Have something you always want to exclude by default? - add it here.
| Note that this is overwritten (not merged) with local exclude
|
*/
'exclude' => [],
/*
|--------------------------------------------------------------------------
| Empty Values
|--------------------------------------------------------------------------
|
| Should Audit records be stored when the recorded old_values & new_values
| are both empty?
|
| Some events may be empty on purpose. Use allowed_empty_values to exclude
| those from the empty values check. For example when auditing
| model retrieved events which will never have new and old values.
|
|
*/
'empty_values' => true,
'allowed_empty_values' => [
'retrieved',
],
/*
|--------------------------------------------------------------------------
| Allowed Array Values
|--------------------------------------------------------------------------
|
| Should the array values be audited?
|
| By default, array values are not allowed. This is to prevent performance
| issues when storing large amounts of data. You can override this by
| setting allow_array_values to true.
*/
'allowed_array_values' => false,
/*
|--------------------------------------------------------------------------
| Audit Timestamps
|--------------------------------------------------------------------------
|
| Should the created_at, updated_at and deleted_at timestamps be audited?
|
*/
'timestamps' => false,
/*
|--------------------------------------------------------------------------
| Audit Threshold
|--------------------------------------------------------------------------
|
| Specify a threshold for the amount of Audit records a model can have.
| Zero means no limit.
|
*/
'threshold' => 0,
/*
|--------------------------------------------------------------------------
| Audit Driver
|--------------------------------------------------------------------------
|
| The default audit driver used to keep track of changes.
|
*/
'driver' => 'database',
/*
|--------------------------------------------------------------------------
| Audit Driver Configurations
|--------------------------------------------------------------------------
|
| Available audit drivers and respective configurations.
|
*/
'drivers' => [
'database' => [
'table' => 'audits',
'connection' => null,
],
],
/*
|--------------------------------------------------------------------------
| Audit Queue Configurations
|--------------------------------------------------------------------------
|
| Available audit queue configurations.
|
*/
'queue' => [
'enable' => false,
'connection' => 'sync',
'queue' => 'default',
'delay' => 0,
],
/*
|--------------------------------------------------------------------------
| Audit Console
|--------------------------------------------------------------------------
|
| Whether console events should be audited (eg. php artisan db:seed).
|
*/
'console' => false,
];

45
backend/config/cors.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your settings for cross-origin resource sharing
| or "CORS". This determines what cross-origin operations may execute
| in web browsers. You are free to adjust these settings as needed.
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
*/
// Le rotte Fortify (login/logout/2FA/reset) sono sotto prefisso `api/`,
// quindi `api/*` le copre tutte; `sanctum/csrf-cookie` rilascia il cookie XSRF.
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
// Origini autorizzate per le richieste cross-origin con credenziali (cookie).
// Con `supports_credentials => true` NON è ammesso il wildcard `*`: serve la
// lista esatta delle origini. In dev l'unica che porta il cookie di sessione è
// APP_URL, perché SESSION_DOMAIN è scopato a `.dyncoll-dev.local`.
// FRONTEND_URL (se valorizzato) fa da override e accetta più origini separate
// da virgola; altrimenti si usa APP_URL.
'allowed_origins' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('FRONTEND_URL', env('APP_URL', 'https://dyncoll-dev.local')))
))),
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];

View File

@@ -17,7 +17,7 @@ return [
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
'default' => env('DB_CONNECTION', 'mysql'),
/*
|--------------------------------------------------------------------------
@@ -84,6 +84,31 @@ return [
]) : [],
],
/*
* Sorgente legacy (dyncoll v1) in sola lettura per l'ETL `php artisan v1:import`.
* Le DB_LEGACY_* NON stanno in .env.example: si impostano nell'ambiente che esegue
* l'import (dev: dump di produzione restorato in locale; cutover: DB v1 reale).
* `strict=false` per tollerare il sql_mode del DB v1.
*/
'legacy' => [
'driver' => 'mysql',
'url' => env('DB_LEGACY_URL'),
'host' => env('DB_LEGACY_HOST', '127.0.0.1'),
'port' => env('DB_LEGACY_PORT', '3306'),
'database' => env('DB_LEGACY_DATABASE', 'dyncoll'),
'username' => env('DB_LEGACY_USERNAME', 'root'),
'password' => env('DB_LEGACY_PASSWORD', ''),
'charset' => env('DB_LEGACY_CHARSET', 'utf8mb4'),
'collation' => env('DB_LEGACY_COLLATION', 'utf8mb4_0900_ai_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => false,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('DB_LEGACY_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),

160
backend/config/fortify.php Normal file
View File

@@ -0,0 +1,160 @@
<?php
use Laravel\Fortify\Features;
return [
/*
|--------------------------------------------------------------------------
| Fortify Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Fortify will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Fortify Password Broker
|--------------------------------------------------------------------------
|
| Here you may specify which password broker Fortify can use when a user
| is resetting their password. This configured value should match one
| of your password brokers setup in your "auth" configuration file.
|
*/
'passwords' => 'users',
/*
|--------------------------------------------------------------------------
| Username / Email
|--------------------------------------------------------------------------
|
| This value defines which model attribute should be considered as your
| application's "username" field. Typically, this might be the email
| address of the users but you are free to change this value here.
|
| Out of the box, Fortify expects forgot password and reset password
| requests to have a field named 'email'. If the application uses
| another name for the field you may define it below as needed.
|
*/
'username' => 'email',
'email' => 'email',
/*
|--------------------------------------------------------------------------
| Lowercase Usernames
|--------------------------------------------------------------------------
|
| This value defines whether usernames should be lowercased before saving
| them in the database, as some database system string fields are case
| sensitive. You may disable this for your application if necessary.
|
*/
'lowercase_usernames' => true,
/*
|--------------------------------------------------------------------------
| Home Path
|--------------------------------------------------------------------------
|
| Here you may configure the path where users will get redirected during
| authentication or password reset when the operations are successful
| and the user is authenticated. You are free to change this value.
|
*/
'home' => '/home',
/*
|--------------------------------------------------------------------------
| Fortify Routes Prefix / Subdomain
|--------------------------------------------------------------------------
|
| Here you may specify which prefix Fortify will assign to all the routes
| that it registers with the application. If necessary, you may change
| subdomain under which all of the Fortify routes will be available.
|
*/
'prefix' => 'api',
'domain' => null,
/*
|--------------------------------------------------------------------------
| Fortify Routes Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Fortify will assign to the routes
| that it registers with the application. If necessary, you may change
| these middleware but typically this provided default is preferred.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
|
| By default, Fortify will throttle logins to five requests per minute for
| every email and IP address combination. However, if you would like to
| specify a custom rate limiter to call then you may specify it here.
|
*/
'limiters' => [
'login' => 'login',
'two-factor' => 'two-factor',
'passkeys' => 'passkeys',
],
/*
|--------------------------------------------------------------------------
| Register View Routes
|--------------------------------------------------------------------------
|
| Here you may specify if the routes returning views should be disabled as
| you may not need them when building your own application. This may be
| especially true if you're writing a custom single-page application.
|
*/
'views' => false,
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of the Fortify features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features or you can even remove all of these if you need to.
|
*/
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::emailVerification(),
Features::updateProfileInformation(),
Features::updatePasswords(),
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
// 'window' => 0,
]),
],
];

View File

@@ -0,0 +1,37 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Messaggi CRUD generici
|--------------------------------------------------------------------------
|
| Usati dal trait App\Http\Traits\ApiResponse quando non viene passato un
| prefisso specifico per risorsa. Per messaggi dedicati a una risorsa basta
| aggiungere un sotto-array (es. 'user_roles' => ['created' => '...']).
|
*/
'index' => 'List retrieved successfully.',
'show' => 'Resource retrieved successfully.',
'created' => 'Resource created successfully.',
'updated' => 'Resource updated successfully.',
'deleted' => 'Resource deleted successfully.',
'restored' => 'Resource restored successfully.',
/*
|--------------------------------------------------------------------------
| Autenticazione / account
|--------------------------------------------------------------------------
*/
'auth' => [
'logout_success' => 'Logged out successfully.',
],
'account' => [
'deleted' => 'Account deleted successfully.',
],
];

102
backend/config/sanctum.php Normal file
View File

@@ -0,0 +1,102 @@
<?php
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(
',',
env(
'SANCTUM_STATEFUL_DOMAINS',
sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
)
)
),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'sanctum' => [
'driver' => 'sanctum',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => AuthenticateSession::class,
'encrypt_cookies' => EncryptCookies::class,
'validate_csrf_token' => ValidateCsrfToken::class,
],
];

View File

@@ -0,0 +1,34 @@
<?php
namespace Database\Factories;
use App\Models\Institution;
use App\Models\Lists\InstitutionCategory;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Institution>
*/
class InstitutionFactory extends Factory
{
protected $model = Institution::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'category_id' => InstitutionCategory::factory(),
'name' => fake()->unique()->company(),
'abbreviation' => strtoupper(fake()->unique()->lexify('?????')),
'address' => fake()->streetAddress(),
'city' => fake()->city(),
'lat' => fake()->latitude(),
'lon' => fake()->longitude(),
'logo' => 'default.jpg',
'color' => '#c5cae9',
'is_storage_place' => true,
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Database\Factories;
use App\Enums\InstitutionLinkType;
use App\Models\Institution;
use App\Models\InstitutionLink;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<InstitutionLink>
*/
class InstitutionLinkFactory extends Factory
{
protected $model = InstitutionLink::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'institution_id' => Institution::factory(),
'url' => fake()->url(),
'type' => InstitutionLinkType::Official,
'label' => null,
'sort_order' => 0,
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Database\Factories\Lists;
use App\Models\Lists\InstitutionCategory;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<InstitutionCategory>
*/
class InstitutionCategoryFactory extends Factory
{
protected $model = InstitutionCategory::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'value' => fake()->unique()->word(),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Database\Factories\Lists;
use App\Models\Lists\UserPosition;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<UserPosition>
*/
class UserPositionFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
protected $model = UserPosition::class;
public function definition(): array
{
return [
'value' => fake()->unique()->word(),
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Database\Factories\Lists;
use App\Models\Lists\UserRole;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<UserRole>
*/
class UserRoleFactory extends Factory
{
protected $model = UserRole::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => ucfirst($this->faker->unique()->word()),
'description' => $this->faker->sentence(),
];
}
}

View File

@@ -0,0 +1,28 @@
<?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::table('users', function (Blueprint $table) {
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};

View File

@@ -0,0 +1,29 @@
<?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_roles', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('description')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_roles');
}
};

View File

@@ -0,0 +1,27 @@
<?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::table('users', function (Blueprint $table) {
$table->unsignedBigInteger('role_id')->nullable()->after('id');
$table->foreign('role_id')->references('id')->on('user_roles')->onDelete('set null');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropForeign(['role_id']);
$table->dropColumn('role_id');
});
}
};

View File

@@ -0,0 +1,28 @@
<?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::table('users', function (Blueprint $table) {
$table->boolean('must_change_password')->default(true)->after('password');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('must_change_password');
});
}
};

View File

@@ -0,0 +1,30 @@
<?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::table('users', function (Blueprint $table) {
// Utente di sistema: riceve i contenuti riassegnati e non è eliminabile.
$table->boolean('is_system')->default(false)->after('role_id');
// Traccia l'avvenuta anonimizzazione (GDPR). Il record resta ai fini
// di integrità referenziale ma esce dal cestino e dalle liste utenti.
$table->timestamp('anonymized_at')->nullable()->after('deleted_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['is_system', 'anonymized_at']);
});
}
};

View File

@@ -0,0 +1,42 @@
<?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::table('users', function (Blueprint $table) {
$table->text('two_factor_secret')
->after('password')
->nullable();
$table->text('two_factor_recovery_codes')
->after('two_factor_secret')
->nullable();
$table->timestamp('two_factor_confirmed_at')
->after('two_factor_recovery_codes')
->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
]);
});
}
};

View File

@@ -0,0 +1,48 @@
<?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
{
$connection = config('audit.drivers.database.connection', config('database.default'));
$table = config('audit.drivers.database.table', 'audits');
Schema::connection($connection)->create($table, function (Blueprint $table) {
$morphPrefix = config('audit.user.morph_prefix', 'user');
$table->bigIncrements('id');
$table->string($morphPrefix.'_type')->nullable();
$table->unsignedBigInteger($morphPrefix.'_id')->nullable();
$table->string('event');
$table->morphs('auditable');
$table->text('old_values')->nullable();
$table->text('new_values')->nullable();
$table->text('url')->nullable();
$table->ipAddress('ip_address')->nullable();
$table->string('user_agent', 1023)->nullable();
$table->string('tags')->nullable();
$table->timestamps();
$table->index([$morphPrefix.'_id', $morphPrefix.'_type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$connection = config('audit.drivers.database.connection', config('database.default'));
$table = config('audit.drivers.database.table', 'audits');
Schema::connection($connection)->drop($table);
}
};

View File

@@ -0,0 +1,27 @@
<?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::table('users', function (Blueprint $table) {
$table->timestamp('two_factor_setup_completed_at')
->nullable()
->after('two_factor_confirmed_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('two_factor_setup_completed_at');
});
}
};

View File

@@ -0,0 +1,28 @@
<?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('institution_categories', function (Blueprint $table) {
$table->id();
$table->string('value', 25)->unique();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('institution_categories');
}
};

View File

@@ -0,0 +1,45 @@
<?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('institutions', function (Blueprint $table) {
$table->id();
$table->foreignId('category_id')
->constrained('institution_categories')
->cascadeOnUpdate()
->restrictOnDelete();
$table->string('name')->unique();
$table->string('abbreviation', 5)->unique();
$table->string('address');
$table->string('city', 100)->index();
$table->decimal('lat', 10, 6);
$table->decimal('lon', 10, 6);
$table->string('logo');
$table->string('color', 50)->default('#c5cae9');
$table->uuid('uuid')->unique();
$table->boolean('is_storage_place')->default(true);
// Id del record nel DB legacy (v1): chiave di upsert per l'ETL e per
// risolvere le FK delle tabelle dipendenti (es. artifact.owner).
$table->unsignedBigInteger('legacy_id')->nullable()->unique();
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('institutions');
}
};

View File

@@ -0,0 +1,37 @@
<?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('institution_links', function (Blueprint $table) {
$table->id();
$table->foreignId('institution_id')
->constrained()
->cascadeOnDelete();
$table->string('url', 2000);
// Vincolato a App\Enums\InstitutionLinkType (cast + validazione lato app).
$table->string('type', 30);
$table->string('label', 100)->nullable();
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['institution_id', 'sort_order']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('institution_links');
}
};

View File

@@ -0,0 +1,28 @@
<?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_positions', function (Blueprint $table) {
$table->id();
$table->string('value', 25)->unique();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_positions');
}
};

View File

@@ -2,7 +2,6 @@
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
@@ -11,15 +10,16 @@ class DatabaseSeeder extends Seeder
use WithoutModelEvents;
/**
* Seed the application's database.
* Seed dell'applicazione. L'ordine conta: i ruoli prima dell'utente di
* sistema (che richiede il ruolo Admin).
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
$this->call([
UserRoleSeeder::class,
SystemUserSeeder::class,
InstitutionCategorySeeder::class,
UserPositionSeeder::class,
]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Database\Seeders;
use App\Models\Lists\InstitutionCategory;
use Illuminate\Database\Seeder;
class InstitutionCategorySeeder extends Seeder
{
/**
* Categorie di istituzione. Idempotente.
*
* Gli id sono FISSI e coincidono con il DB legacy (v1) così l'ETL mappa
* institutions.category_id 1:1 senza tradurre. La categoria 1 ("uncategorized",
* mai usata) e la 5 (inesistente nel legacy) sono volutamente assenti.
*/
public function run(): void
{
$categories = [
2 => InstitutionCategory::LIBRARY,
3 => InstitutionCategory::MUSEUM,
4 => InstitutionCategory::PUBLIC_ADMINISTRATION,
6 => InstitutionCategory::RESEARCH_INSTITUTE,
];
foreach ($categories as $id => $value) {
InstitutionCategory::updateOrCreate(
['id' => $id],
['value' => $value],
);
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Database\Seeders;
use App\Models\Lists\UserRole;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* Crea l'utente di sistema: riceve i contenuti riassegnati dagli utenti
* cancellati (flusso GDPR / oblio) e non è eliminabile accessibile
* (password casuale, nessun login previsto). Idempotente.
*
* Dipende da UserRoleSeeder (serve il ruolo Admin).
*/
class SystemUserSeeder extends Seeder
{
public const EMAIL = 'system@dyncoll.local';
public function run(): void
{
$adminRoleId = UserRole::where('name', UserRole::ADMIN)->value('id');
User::updateOrCreate(
['email' => self::EMAIL],
[
'name' => 'Dyncoll System',
'password' => Hash::make(Str::random(60)),
'role_id' => $adminRoleId,
'is_system' => true,
'must_change_password' => false,
'email_verified_at' => now(),
'two_factor_setup_completed_at' => now(),
]
);
$this->command?->info('✅ System user ready: '.self::EMAIL);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Database\Seeders;
use App\Models\Lists\UserPosition;
use Illuminate\Database\Seeder;
class UserPositionSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$positions = [
UserPosition::PROFESSOR,
UserPosition::RESEARCHER,
UserPosition::PHD,
UserPosition::STUDENT,
UserPosition::ADMINISTRATIVE,
];
foreach ($positions as $position) {
UserPosition::updateOrCreate(
['value' => $position]
);
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Database\Seeders;
use App\Models\Lists\UserRole;
use Illuminate\Database\Seeder;
class UserRoleSeeder extends Seeder
{
/**
* Ruoli applicativi di base. Idempotente.
* (description in inglese: lingua ufficiale dell'applicazione.)
*/
public function run(): void
{
UserRole::updateOrCreate(
['name' => UserRole::ADMIN],
['description' => 'Full access to all features.'
.' Manages users, configurations and global data supervision.'
.' Can create, read, update and delete any record.']
);
UserRole::updateOrCreate(
['name' => UserRole::SUPERVISOR],
['description' => 'Limited access to certain features.'
.' Can manage users but not administrators.'
.' Can create, read, update and delete both own and other users\' records.']
);
UserRole::updateOrCreate(
['name' => UserRole::USER],
['description' => 'Day-to-day operational use. Can access all records'
.' and create new ones, but can only update or delete their own.']
);
UserRole::updateOrCreate(
['name' => UserRole::GUEST],
['description' => 'Very limited access. Can only view information'
.' not publicly available, with no editing capabilities.']
);
}
}

View File

@@ -18,19 +18,25 @@
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
<!-- IMPORTANTE: la sicurezza del DB NON dipende da questo file. Nel container
le env (DB_HOST=db, DB_DATABASE=dyncoll, APP_ENV=local, ...) sono REALI e
finiscono in $_SERVER, che l'env() di Laravel legge PRIMA: il force qui sotto
NON le sovrascrive. L'override autoritativo arriva dalle -e di `make test`
(DB_HOST=db-test, DB_DATABASE=dyncoll_test). Questi force servono solo come
rete per i driver non-DB se qualcuno lancia `php artisan test` a mano; il
redirect verso db-test resta garantito dal guard in Tests\TestCase. -->
<env name="APP_ENV" value="testing" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
<env name="CACHE_STORE" value="array" force="true"/>
<env name="DB_CONNECTION" value="mysql" force="true"/>
<env name="DB_DATABASE" value="dyncoll_test" force="true"/>
<env name="MAIL_MAILER" value="array" force="true"/>
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="SESSION_DRIVER" value="array" force="true"/>
<env name="PULSE_ENABLED" value="false" force="true"/>
<env name="TELESCOPE_ENABLED" value="false" force="true"/>
<env name="NIGHTWATCH_ENABLED" value="false" force="true"/>
</php>
</phpunit>

36
backend/routes/api.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
use App\Http\Controllers\AuthController;
use Illuminate\Support\Facades\Route;
// File "core": rotte di sessione e pubbliche trasversali. Le rotte per modello
// stanno in routes/api/*.php, caricate automaticamente dal then: in bootstrap/app.php
// (prefisso `api` + tier riusabili `tier.app` / `tier.admin`).
$AUTH = 'auth:sanctum';
// -----------------------------------------------------------------------
// Rotte pubbliche (nessun auth), protette dal throttling.
// Limiter `public` definito in App\Providers\AppServiceProvider::boot()
// (60 req/min per IP) — modificare lì il limite.
// -----------------------------------------------------------------------
Route::middleware('throttle:public')->group(function () {
// Solo endpoint GET (index/show) leggibili da chiunque.
});
// -----------------------------------------------------------------------
// Tier "transitional": autenticato, MA setup eventualmente incompleto
// (password forzata da cambiare e/o 2FA non ancora configurata).
//
// ⚠️ NON aggiungere `setup.complete` qui: queste rotte devono restare
// raggiungibili PROPRIO durante il setup, altrimenti deadlock. In particolare
// il frontend legge GET /user per scoprire `setup_status` e decidere cosa
// mostrare. Le azioni di setup vero (cambio password, abilitazione 2FA) e la
// verifica email le registra Fortify, già auth-only e fuori da `setup.complete`.
// -----------------------------------------------------------------------
Route::middleware($AUTH)->group(function () {
Route::get('/user', [AuthController::class, 'me']);
// Sovrascrive la rotta logout di Fortify (registrata dopo → precedenza).
Route::post('/logout', [AuthController::class, 'logout']);
Route::delete('/account', [AuthController::class, 'destroyAccount']);
});

View File

@@ -0,0 +1,23 @@
<?php
use App\Http\Controllers\InstitutionCategoryController;
use Illuminate\Support\Facades\Route;
// rotte pubbliche con throttling
Route::middleware('throttle:public')->group(function () {
Route::apiResource('institution-categories', InstitutionCategoryController::class)
->only(['index', 'show']);
});
// Prefisso `api` e tier applicati dal loader in bootstrap/app.php.
// Lettura: utente pienamente operativo.
Route::middleware('tier.app')->group(function () {
Route::get('institution-categories/{institution_category}/usage', [InstitutionCategoryController::class, 'usage']);
});
// Scrittura: solo Admin.
Route::middleware('tier.admin')->group(function () {
Route::apiResource('institution-categories', InstitutionCategoryController::class)
->only(['store', 'update', 'destroy']);
});

View File

@@ -0,0 +1,34 @@
<?php
use App\Http\Controllers\InstitutionController;
use App\Http\Controllers\InstitutionLinkController;
use Illuminate\Support\Facades\Route;
// Prefisso `api` e tier (`tier.app` / `tier.admin`) applicati dal loader in
// bootstrap/app.php. Le istituzioni si risolvono per `uuid` (route key del model).
// rotte pubbliche con throttling
Route::middleware('throttle:public')->group(function () {
Route::apiResource('institutions', InstitutionController::class)->only(['index', 'show']);
Route::apiResource('institutions.links', InstitutionLinkController::class)
->only(['index', 'show'])
->scoped();
});
// Lettura: utente pienamente operativo.
Route::middleware('tier.app')->group(function () {
//
});
// Scrittura + soft delete: solo Admin.
Route::middleware('tier.admin')->group(function () {
Route::apiResource('institutions', InstitutionController::class)->only(['store', 'update', 'destroy']);
// Soft delete: ripristino e cancellazione definitiva (binding sui cestinati).
Route::post('institutions/{institution}/restore', [InstitutionController::class, 'restore'])->withTrashed();
Route::delete('institutions/{institution}/force', [InstitutionController::class, 'forceDestroy'])->withTrashed();
Route::apiResource('institutions.links', InstitutionLinkController::class)
->only(['store', 'update', 'destroy'])
->scoped();
});

View File

@@ -0,0 +1,12 @@
<?php
use App\Http\Controllers\UserPositionController;
use Illuminate\Support\Facades\Route;
// Prefisso `api` e stack `api` sono applicati dal loader in bootstrap/app.php.
// I tier `tier.app` / `tier.admin` sono definiti lì come middleware group.
// Lookup fissa, non gestibile da interfaccia: solo lettura.
Route::middleware('throttle:public')->group(function () {
Route::apiResource('user-positions', UserPositionController::class)->only(['index', 'show']);
});

View File

@@ -0,0 +1,17 @@
<?php
use App\Http\Controllers\UserRoleController;
use Illuminate\Support\Facades\Route;
// Prefisso `api` e stack `api` sono applicati dal loader in bootstrap/app.php.
// I tier `tier.app` / `tier.admin` sono definiti lì come middleware group.
// Lookup fissa, non gestibile da interfaccia: solo lettura.
Route::middleware('tier.app')->group(function () {
Route::apiResource('user-roles', UserRoleController::class)->only(['index', 'show']);
});
// Statistiche: solo Admin.
Route::middleware('tier.admin')->group(function () {
Route::get('user-roles/{user_role}/usage', [UserRoleController::class, 'usage']);
});

View File

@@ -0,0 +1,11 @@
<?php
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;
// Gestione utenti (sola lettura): area admin.
Route::middleware('tier.admin')->group(function () {
Route::get('users', [UserController::class, 'index']);
// withTrashed: l'admin può ispezionare anche utenti cestinati/anonimizzati.
Route::get('users/{user}', [UserController::class, 'show'])->withTrashed();
});

View File

@@ -2,7 +2,11 @@
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
// Anonimizzazione GDPR degli utenti oltre la retention (richiede lo scheduler attivo).
Schedule::command('users:purge')->daily();

View File

@@ -0,0 +1,74 @@
<?php
namespace Tests\Concerns;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* Costruisce una connessione `legacy` SQLite in-memory che replica le tabelle v1
* lette dall'ETL (`institution`), così gli importer si testano senza un MySQL v1.
*/
trait CreatesLegacyDatabase
{
/** Riconfigura la connessione `legacy` su SQLite :memory: e ne crea lo schema v1. */
protected function fakeLegacyConnection(): void
{
config()->set('database.connections.legacy', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
'foreign_key_constraints' => false,
]);
DB::purge('legacy');
$schema = Schema::connection('legacy');
$schema->dropIfExists('institution');
$schema->create('institution', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary();
$table->unsignedBigInteger('category');
$table->string('name', 255);
$table->string('abbreviation', 5);
$table->string('address', 255);
$table->decimal('lat', 10, 6);
$table->decimal('lon', 10, 6);
$table->string('url', 2000)->nullable();
$table->string('logo', 255);
$table->string('uuid', 36);
$table->string('color', 50)->nullable();
$table->string('city', 100);
$table->boolean('is_storage_place');
});
}
/**
* Inserisce una riga `institution` legacy e ne restituisce l'id.
*
* @param array<string, mixed> $overrides
*/
protected function insertLegacyInstitution(array $overrides = []): int
{
$row = array_merge([
'id' => fake()->unique()->numberBetween(1, 1_000_000),
'category' => 3,
'name' => 'Some Museum',
'abbreviation' => 'SM',
'address' => 'Main Street 1',
'lat' => 55.704660,
'lon' => 13.191007,
'url' => 'https://example.org/',
'logo' => 'default.jpg',
'uuid' => (string) Str::uuid(),
'color' => '#abcdef',
'city' => 'Lund',
'is_storage_place' => 1,
], $overrides);
DB::connection('legacy')->table('institution')->insert($row);
return (int) $row['id'];
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Tests\Concerns;
use App\Models\Lists\UserRole;
use App\Models\User;
/**
* Helper per costruire utenti nei vari stati del setup, senza ripetere ovunque
* lo stesso array di attributi. La factory di default crea un utente verificato
* ma con must_change_password=true (default di colonna) setup incompleto: qui
* forniamo scorciatoie per gli stati che servono ai test (operativo, admin,
* di sistema, setup incompleto, non verificato).
*/
trait CreatesUsers
{
/** Ruolo applicativo, riusato se già presente (idempotente in-test). */
protected function role(string $name): UserRole
{
return UserRole::firstOrCreate(['name' => $name], ['description' => $name.' role']);
}
/** Utente pienamente operativo: verificato, setup completo, ruolo User. */
protected function operationalUser(array $overrides = []): User
{
return User::factory()->create(array_merge([
'role_id' => $this->role(UserRole::USER)->id,
'email_verified_at' => now(),
'must_change_password' => false,
'two_factor_setup_completed_at' => now(),
], $overrides));
}
/** Operativo con ruolo Admin → supera tier.admin / IsAdmin. */
protected function adminUser(array $overrides = []): User
{
return $this->operationalUser(array_merge([
'role_id' => $this->role(UserRole::ADMIN)->id,
], $overrides));
}
/** Utente di sistema (destinatario dei contenuti riassegnati, non eliminabile). */
protected function systemUser(array $overrides = []): User
{
return $this->operationalUser(array_merge([
'is_system' => true,
'role_id' => $this->role(UserRole::ADMIN)->id,
], $overrides));
}
/** Autenticato ma setup incompleto (deve cambiare password). */
protected function setupIncompleteUser(array $overrides = []): User
{
return $this->operationalUser(array_merge([
'must_change_password' => true,
], $overrides));
}
/** Setup completo ma email non verificata → blocco di `verified`. */
protected function unverifiedUser(array $overrides = []): User
{
return $this->operationalUser(array_merge([
'email_verified_at' => null,
], $overrides));
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Tests\Feature\Auth;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Verifica la scala di accesso (tier.app / tier.admin) attraverso rotte reali:
* - tier.app user-roles index (auth + verified + setup.complete)
* - tier.admin→ users index (+ admin)
* Copre i rami di EnsureSetupComplete, del middleware `verified` e di IsAdmin.
*/
class AccessTierTest extends TestCase
{
use RefreshDatabase;
private const APP_ROUTE = '/api/user-roles';
private const ADMIN_ROUTE = '/api/users';
public function test_tier_app_allows_a_fully_operational_user(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
$this->getJson(self::APP_ROUTE)->assertOk();
}
public function test_tier_app_requires_authentication(): void
{
$this->getJson(self::APP_ROUTE)->assertUnauthorized();
}
public function test_tier_app_blocks_user_who_must_change_password(): void
{
$this->actingAs($this->setupIncompleteUser(), 'sanctum');
$this->getJson(self::APP_ROUTE)
->assertForbidden()
->assertJsonPath('setup_status', 'password_required');
}
public function test_tier_app_blocks_user_without_two_factor_setup(): void
{
$user = $this->operationalUser(['two_factor_setup_completed_at' => null]);
$this->actingAs($user, 'sanctum');
$this->getJson(self::APP_ROUTE)
->assertForbidden()
->assertJsonPath('setup_status', '2fa_setup_required');
}
public function test_tier_app_blocks_unverified_user(): void
{
$this->actingAs($this->unverifiedUser(), 'sanctum');
$this->getJson(self::APP_ROUTE)->assertForbidden();
}
public function test_tier_admin_allows_admin(): void
{
$this->actingAs($this->adminUser(), 'sanctum');
$this->getJson(self::ADMIN_ROUTE)->assertOk();
}
public function test_tier_admin_forbids_non_admin(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
$this->getJson(self::ADMIN_ROUTE)->assertForbidden();
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace Tests\Feature\Auth;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthControllerTest extends TestCase
{
use RefreshDatabase;
public function test_me_returns_authenticated_user_with_setup_status(): void
{
$user = $this->operationalUser();
$this->actingAs($user, 'sanctum');
$response = $this->getJson('/api/user');
$response->assertOk()
->assertJsonPath('id', $user->id)
->assertJsonPath('email', $user->email)
->assertJsonPath('setup_status', 'complete');
// Gli attributi sensibili non devono mai essere serializzati.
$this->assertArrayNotHasKey('password', $response->json());
$this->assertArrayNotHasKey('two_factor_secret', $response->json());
$this->assertArrayNotHasKey('two_factor_recovery_codes', $response->json());
}
public function test_me_is_reachable_during_setup(): void
{
// La rotta /user è nel tier "transitional": deve restare accessibile anche
// a setup incompleto, così il frontend può leggere setup_status.
$user = $this->setupIncompleteUser();
$this->actingAs($user, 'sanctum');
$this->getJson('/api/user')
->assertOk()
->assertJsonPath('setup_status', 'password_required');
}
public function test_me_requires_authentication(): void
{
$this->getJson('/api/user')->assertUnauthorized();
}
public function test_logout_succeeds_and_forgets_cookies(): void
{
$user = $this->operationalUser();
$this->actingAs($user, 'sanctum');
$response = $this->postJson('/api/logout');
$response->assertOk()
->assertJsonPath('message', config('messages.auth.logout_success'))
->assertCookieExpired('XSRF-TOKEN');
}
public function test_destroy_account_soft_deletes_the_user(): void
{
$user = $this->operationalUser();
$this->actingAs($user, 'sanctum');
$this->deleteJson('/api/account')
->assertOk()
->assertJsonPath('message', config('messages.account.deleted'));
$this->assertSoftDeleted('users', ['id' => $user->id]);
}
public function test_destroy_account_is_forbidden_for_the_system_user(): void
{
$user = $this->systemUser();
$this->actingAs($user, 'sanctum');
$this->deleteJson('/api/account')->assertForbidden();
$this->assertDatabaseHas('users', ['id' => $user->id, 'deleted_at' => null]);
}
public function test_session_endpoints_require_authentication(): void
{
$this->postJson('/api/logout')->assertUnauthorized();
$this->deleteJson('/api/account')->assertUnauthorized();
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Guardia di sicurezza: garantisce che la suite giri sul DB di test dedicato
* (servizio db-test), non sul database di sviluppo `dyncoll`. È volutamente
* read-only (non usa RefreshDatabase): qui assertiamo che l'host e il nome DB
* siano quelli di test. La protezione "dura" che aborta PRIMA di un eventuale
* migrate:fresh sta nel guard beforeRefreshingDatabase() di Tests\TestCase.
*/
class EnvironmentSafetyTest extends TestCase
{
public function test_suite_runs_against_the_dedicated_test_database(): void
{
$this->assertSame('testing', app()->environment());
$this->assertSame('mysql', config('database.default'));
$this->assertSame('db-test', config('database.connections.mysql.host'));
$this->assertSame('dyncoll_test', DB::connection()->getDatabaseName());
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace Tests\Feature\Etl;
use App\Etl\Importers\InstitutionImporter;
use App\Models\Institution;
use Database\Seeders\InstitutionCategorySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Concerns\CreatesLegacyDatabase;
use Tests\TestCase;
class InstitutionImporterTest extends TestCase
{
use CreatesLegacyDatabase;
use RefreshDatabase;
/** Categorie v2 con gli id legacy fissi (2,3,4,6). */
private function seedV2Categories(): void
{
$this->seed(InstitutionCategorySeeder::class);
}
public function test_it_imports_an_institution_and_creates_the_official_link(): void
{
$this->seedV2Categories();
$this->fakeLegacyConnection();
$this->insertLegacyInstitution([
'id' => 1,
'category' => 3,
'name' => 'Blekinge Museum',
'abbreviation' => 'BLM',
'city' => 'Karlskrona',
'url' => 'https://blekingemuseum.se/',
]);
$summary = (new InstitutionImporter)->import(false);
$this->assertSame(1, $summary->created);
$this->assertDatabaseHas('institutions', [
'legacy_id' => 1,
'category_id' => 3,
'name' => 'Blekinge Museum',
'city' => 'Karlskrona',
]);
$institution = Institution::where('legacy_id', 1)->firstOrFail();
$this->assertNotEmpty($institution->uuid);
$this->assertDatabaseHas('institution_links', [
'institution_id' => $institution->id,
'type' => 'official',
'url' => 'https://blekingemuseum.se/',
]);
}
public function test_it_creates_no_link_when_the_url_is_blank(): void
{
$this->seedV2Categories();
$this->fakeLegacyConnection();
$this->insertLegacyInstitution(['id' => 21, 'url' => ' ']);
(new InstitutionImporter)->import(false);
$this->assertDatabaseCount('institution_links', 0);
}
public function test_it_trims_textual_fields(): void
{
$this->seedV2Categories();
$this->fakeLegacyConnection();
$this->insertLegacyInstitution(['id' => 20, 'name' => 'Lund University Library ']);
(new InstitutionImporter)->import(false);
$this->assertDatabaseHas('institutions', ['legacy_id' => 20, 'name' => 'Lund University Library']);
}
public function test_it_falls_back_to_the_default_color_when_legacy_color_is_blank(): void
{
$this->seedV2Categories();
$this->fakeLegacyConnection();
$this->insertLegacyInstitution(['id' => 1, 'color' => null]);
(new InstitutionImporter)->import(false);
$this->assertDatabaseHas('institutions', ['legacy_id' => 1, 'color' => '#c5cae9']);
}
public function test_it_skips_institutions_with_an_unknown_category(): void
{
$this->seedV2Categories();
$this->fakeLegacyConnection();
$this->insertLegacyInstitution(['id' => 99, 'category' => 999]);
$summary = (new InstitutionImporter)->import(false);
$this->assertSame(1, $summary->skipped);
$this->assertNotEmpty($summary->warnings);
$this->assertDatabaseMissing('institutions', ['legacy_id' => 99]);
}
public function test_it_regenerates_the_uuid_instead_of_copying_the_legacy_one(): void
{
$this->seedV2Categories();
$this->fakeLegacyConnection();
$legacyUuid = '00000000-0000-0000-0000-000000000000';
$this->insertLegacyInstitution(['id' => 1, 'uuid' => $legacyUuid]);
(new InstitutionImporter)->import(false);
$this->assertNotSame($legacyUuid, Institution::where('legacy_id', 1)->value('uuid'));
}
public function test_it_is_idempotent_and_keeps_the_uuid_stable(): void
{
$this->seedV2Categories();
$this->fakeLegacyConnection();
$this->insertLegacyInstitution(['id' => 1]);
$first = (new InstitutionImporter)->import(false);
$uuid = Institution::where('legacy_id', 1)->value('uuid');
$second = (new InstitutionImporter)->import(false);
$this->assertSame(1, $first->created);
$this->assertSame(0, $second->created);
$this->assertSame(1, $second->updated);
$this->assertDatabaseCount('institutions', 1);
$this->assertSame($uuid, Institution::where('legacy_id', 1)->value('uuid'));
}
public function test_dry_run_writes_nothing(): void
{
$this->seedV2Categories();
$this->fakeLegacyConnection();
$this->insertLegacyInstitution(['id' => 1]);
$summary = (new InstitutionImporter)->import(true);
$this->assertSame(1, $summary->created);
$this->assertDatabaseCount('institutions', 0);
}
public function test_dry_run_counts_an_existing_institution_as_updated(): void
{
$this->seedV2Categories();
$this->fakeLegacyConnection();
$this->insertLegacyInstitution(['id' => 1]);
(new InstitutionImporter)->import(false);
$summary = (new InstitutionImporter)->import(true);
$this->assertSame(0, $summary->created);
$this->assertSame(1, $summary->updated);
$this->assertDatabaseCount('institutions', 1);
}
public function test_it_warns_and_skips_when_the_v2_lookup_is_empty(): void
{
// Nessun seed delle categorie v2.
$this->fakeLegacyConnection();
$this->insertLegacyInstitution(['id' => 1]);
$summary = (new InstitutionImporter)->import(false);
$this->assertNotEmpty($summary->warnings);
$this->assertSame(0, $summary->total());
$this->assertDatabaseCount('institutions', 0);
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace Tests\Feature\Etl;
use Database\Seeders\InstitutionCategorySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\CreatesLegacyDatabase;
use Tests\TestCase;
class V1ImportCommandTest extends TestCase
{
use CreatesLegacyDatabase;
use RefreshDatabase;
public function test_it_imports_and_reports_counts(): void
{
$this->seed(InstitutionCategorySeeder::class);
$this->fakeLegacyConnection();
$this->insertLegacyInstitution(['id' => 1]);
$this->artisan('v1:import')
->expectsOutputToContain('created 1, updated 0, skipped 0')
->assertSuccessful();
$this->assertDatabaseCount('institutions', 1);
}
public function test_dry_run_writes_nothing(): void
{
$this->seed(InstitutionCategorySeeder::class);
$this->fakeLegacyConnection();
$this->insertLegacyInstitution(['id' => 1]);
$this->artisan('v1:import --dry-run')
->expectsOutputToContain('DRY-RUN')
->assertSuccessful();
$this->assertDatabaseCount('institutions', 0);
}
public function test_only_filter_skips_unmatched_importers(): void
{
$this->seed(InstitutionCategorySeeder::class);
$this->fakeLegacyConnection();
$this->insertLegacyInstitution(['id' => 1]);
$this->artisan('v1:import --only=nonexistent')->assertSuccessful();
$this->assertDatabaseCount('institutions', 0);
}
public function test_it_prints_importer_warnings(): void
{
$this->seed(InstitutionCategorySeeder::class);
$this->fakeLegacyConnection();
$this->insertLegacyInstitution(['id' => 99, 'category' => 999]);
$this->artisan('v1:import')
->expectsOutputToContain('category 999')
->assertSuccessful();
}
public function test_it_fails_when_an_importer_throws(): void
{
// Connessione raggiungibile (getPdo ok) ma senza la tabella `institution`:
// l'import lancia, il comando intercetta e fallisce.
config()->set('database.connections.legacy', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
DB::purge('legacy');
$this->seed(InstitutionCategorySeeder::class);
$this->artisan('v1:import')
->expectsOutputToContain('failed')
->assertFailed();
}
public function test_it_fails_when_the_legacy_connection_is_unavailable(): void
{
config()->set('database.connections.legacy', [
'driver' => 'sqlite',
'database' => '/nonexistent/path/legacy.sqlite',
'prefix' => '',
]);
DB::purge('legacy');
$this->artisan('v1:import')
->expectsOutputToContain('Legacy connection unavailable')
->assertFailed();
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace Tests\Feature;
use App\Models\Institution;
use App\Models\Lists\InstitutionCategory;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class InstitutionCategoryControllerTest extends TestCase
{
use RefreshDatabase;
// --- Lettura (tier.app) --------------------------------------------------
public function test_index_lists_categories(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
InstitutionCategory::factory()->count(2)->create();
$this->getJson('/api/institution-categories')
->assertOk()
->assertJsonStructure(['message', 'data' => [['id', 'value']]])
->assertJsonCount(2, 'data');
}
public function test_show_returns_a_single_category(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
$category = InstitutionCategory::factory()->create();
$this->getJson("/api/institution-categories/{$category->id}")
->assertOk()
->assertJsonPath('data.id', $category->id);
}
public function test_usage_reflects_whether_the_category_is_used(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
$free = InstitutionCategory::factory()->create();
$used = InstitutionCategory::factory()->create();
Institution::factory()->create(['category_id' => $used->id]);
$this->getJson("/api/institution-categories/{$free->id}/usage")->assertJsonPath('in_use', false);
$this->getJson("/api/institution-categories/{$used->id}/usage")->assertJsonPath('in_use', true);
}
// --- Scrittura (tier.admin) ---------------------------------------------
public function test_admin_can_create_a_category(): void
{
$this->actingAs($this->adminUser(), 'sanctum');
$this->postJson('/api/institution-categories', ['value' => 'gallery'])
->assertCreated()
->assertJsonPath('data.value', 'gallery');
$this->assertDatabaseHas('institution_categories', ['value' => 'gallery']);
}
public function test_create_rejects_a_duplicate_value(): void
{
$this->actingAs($this->adminUser(), 'sanctum');
InstitutionCategory::factory()->create(['value' => 'gallery']);
$this->postJson('/api/institution-categories', ['value' => 'gallery'])
->assertStatus(422)
->assertJsonValidationErrors('value');
}
public function test_admin_can_update_a_category(): void
{
$this->actingAs($this->adminUser(), 'sanctum');
$category = InstitutionCategory::factory()->create();
$this->putJson("/api/institution-categories/{$category->id}", ['value' => 'archive'])
->assertOk()
->assertJsonPath('data.value', 'archive');
}
public function test_cannot_delete_a_category_in_use(): void
{
$this->actingAs($this->adminUser(), 'sanctum');
$category = InstitutionCategory::factory()->create();
Institution::factory()->create(['category_id' => $category->id]);
$this->deleteJson("/api/institution-categories/{$category->id}")
->assertStatus(409)
->assertJsonPath('in_use', true);
$this->assertDatabaseHas('institution_categories', ['id' => $category->id]);
}
public function test_admin_can_delete_a_free_category(): void
{
$this->actingAs($this->adminUser(), 'sanctum');
$category = InstitutionCategory::factory()->create();
$this->deleteJson("/api/institution-categories/{$category->id}")->assertOk();
$this->assertDatabaseMissing('institution_categories', ['id' => $category->id]);
}
public function test_a_non_admin_cannot_write(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
$this->postJson('/api/institution-categories', ['value' => 'x'])->assertForbidden();
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Tests\Feature;
use App\Models\Lists\InstitutionCategory;
use Database\Seeders\InstitutionCategorySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class InstitutionCategorySeederTest extends TestCase
{
use RefreshDatabase;
public function test_it_seeds_the_categories_with_fixed_legacy_ids(): void
{
$this->seed(InstitutionCategorySeeder::class);
$this->assertSame(4, InstitutionCategory::count());
$this->assertSame('library', InstitutionCategory::find(2)?->value);
$this->assertSame('museum', InstitutionCategory::find(3)?->value);
$this->assertSame('public administration', InstitutionCategory::find(4)?->value);
$this->assertSame('research institute', InstitutionCategory::find(6)?->value);
}
public function test_it_excludes_uncategorized_and_the_gap_ids(): void
{
$this->seed(InstitutionCategorySeeder::class);
$this->assertNull(InstitutionCategory::find(1));
$this->assertNull(InstitutionCategory::find(5));
}
public function test_it_is_idempotent(): void
{
$this->seed(InstitutionCategorySeeder::class);
$this->seed(InstitutionCategorySeeder::class);
$this->assertSame(4, InstitutionCategory::count());
}
}

View File

@@ -0,0 +1,251 @@
<?php
namespace Tests\Feature;
use App\Models\Institution;
use App\Models\InstitutionLink;
use App\Models\Lists\InstitutionCategory;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class InstitutionControllerTest extends TestCase
{
use RefreshDatabase;
private const BASE_ROUTE = '/api/institutions';
private const TEST_MUSEUM = 'Test Museum';
private const JSON_HEADERS = ['Accept' => 'application/json'];
private const OLD_LOGO_PATH = 'institution_logo/old.jpg';
private const GONE_LOGO_PATH = 'institution_logo/gone.jpg';
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
private function validPayload(array $overrides = []): array
{
return array_merge([
'category_id' => InstitutionCategory::factory()->create()->id,
'name' => self::TEST_MUSEUM,
'abbreviation' => 'TM',
'address' => 'Some Road 1',
'city' => 'Lund',
'lat' => 55.7,
'lon' => 13.19,
'color' => '#ffffff',
'is_storage_place' => true,
'logo' => UploadedFile::fake()->image('logo.png'),
], $overrides);
}
// --- Lettura (tier.app) --------------------------------------------------
public function test_index_lists_institutions_for_an_operational_user(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
Institution::factory()->count(2)->create();
$this->getJson(self::BASE_ROUTE)
->assertOk()
->assertJsonStructure(['message', 'data', 'meta' => ['current_page', 'total']])
->assertJsonCount(2, 'data');
}
public function test_index_filters_by_category_and_search(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
$category = InstitutionCategory::factory()->create();
Institution::factory()->create(['category_id' => $category->id, 'name' => 'Blekinge Museum']);
Institution::factory()->create(['name' => 'Other Place']);
$this->getJson(self::BASE_ROUTE."?category_id={$category->id}")
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.name', 'Blekinge Museum');
$this->getJson(self::BASE_ROUTE.'?search=Blekinge')
->assertOk()
->assertJsonCount(1, 'data');
}
public function test_index_can_list_only_trashed(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
$trashed = Institution::factory()->create();
Institution::factory()->create();
$trashed->delete();
$this->getJson(self::BASE_ROUTE)->assertJsonCount(1, 'data');
$this->getJson(self::BASE_ROUTE.'?trashed=only')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $trashed->id);
$this->getJson(self::BASE_ROUTE.'?trashed=with')->assertJsonCount(2, 'data');
}
public function test_show_returns_institution_with_category_and_links(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
$institution = Institution::factory()->create();
InstitutionLink::factory()->create(['institution_id' => $institution->id]);
$this->getJson(self::BASE_ROUTE."/{$institution->uuid}")
->assertOk()
->assertJsonPath('data.uuid', $institution->uuid)
->assertJsonStructure(['data' => ['category', 'links']]);
}
public function test_show_resolves_by_uuid_not_id(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
$institution = Institution::factory()->create();
// L'id interno non è una route key valida.
$this->getJson(self::BASE_ROUTE."/{$institution->id}")->assertNotFound();
}
// --- Scrittura (tier.admin) ---------------------------------------------
public function test_admin_can_create_an_institution_with_a_logo(): void
{
Storage::fake('public');
$this->actingAs($this->adminUser(), 'sanctum');
$this->post(self::BASE_ROUTE, $this->validPayload(), self::JSON_HEADERS)
->assertCreated()
->assertJsonPath('data.name', self::TEST_MUSEUM)
->assertJsonStructure(['data' => ['uuid', 'category']]);
$institution = Institution::firstWhere('name', self::TEST_MUSEUM);
$this->assertNotNull($institution);
$this->assertNotEmpty($institution->uuid);
$this->assertTrue(Storage::disk('public')->exists($institution->logo));
}
public function test_create_applies_default_color_and_storage_flag(): void
{
Storage::fake('public');
$this->actingAs($this->adminUser(), 'sanctum');
$payload = $this->validPayload();
unset($payload['color'], $payload['is_storage_place']);
$this->post(self::BASE_ROUTE, $payload, self::JSON_HEADERS)->assertCreated();
$this->assertDatabaseHas('institutions', [
'name' => self::TEST_MUSEUM,
'color' => '#c5cae9',
'is_storage_place' => true,
]);
}
public function test_create_validates_required_fields(): void
{
$this->actingAs($this->adminUser(), 'sanctum');
$this->postJson(self::BASE_ROUTE, [])
->assertStatus(422)
->assertJsonValidationErrors(['category_id', 'name', 'abbreviation', 'lat', 'lon', 'logo']);
}
public function test_admin_can_update_an_institution_keeping_the_logo(): void
{
$this->actingAs($this->adminUser(), 'sanctum');
$institution = Institution::factory()->create(['logo' => 'institution_logo/keep.jpg', 'name' => 'Old']);
$payload = $this->validPayload(['name' => 'New Name', 'category_id' => $institution->category_id]);
unset($payload['logo']);
$this->put(self::BASE_ROUTE."/{$institution->uuid}", $payload, self::JSON_HEADERS)
->assertOk()
->assertJsonPath('data.name', 'New Name');
$this->assertSame('institution_logo/keep.jpg', $institution->fresh()->logo);
}
public function test_update_replaces_the_logo_when_a_new_one_is_uploaded(): void
{
Storage::fake('public');
$this->actingAs($this->adminUser(), 'sanctum');
Storage::disk('public')->put(self::OLD_LOGO_PATH, 'x');
$institution = Institution::factory()->create(['logo' => self::OLD_LOGO_PATH]);
$payload = $this->validPayload(['category_id' => $institution->category_id]);
$this->put(self::BASE_ROUTE."/{$institution->uuid}", $payload, self::JSON_HEADERS)->assertOk();
$this->assertFalse(Storage::disk('public')->exists(self::OLD_LOGO_PATH));
$this->assertTrue(Storage::disk('public')->exists($institution->fresh()->logo));
}
public function test_admin_can_soft_delete_an_institution(): void
{
$this->actingAs($this->adminUser(), 'sanctum');
$institution = Institution::factory()->create();
$this->deleteJson(self::BASE_ROUTE."/{$institution->uuid}")->assertOk();
$this->assertSoftDeleted($institution);
}
public function test_admin_can_restore_a_trashed_institution(): void
{
$this->actingAs($this->adminUser(), 'sanctum');
$institution = Institution::factory()->create();
$institution->delete();
$this->postJson(self::BASE_ROUTE."/{$institution->uuid}/restore")
->assertOk()
->assertJsonPath('data.uuid', $institution->uuid);
$this->assertNotSoftDeleted($institution);
}
public function test_admin_can_force_delete_an_institution_and_its_logo_and_links(): void
{
Storage::fake('public');
$this->actingAs($this->adminUser(), 'sanctum');
Storage::disk('public')->put(self::GONE_LOGO_PATH, 'x');
$institution = Institution::factory()->create(['logo' => self::GONE_LOGO_PATH]);
$link = InstitutionLink::factory()->create(['institution_id' => $institution->id]);
$institution->delete();
$this->deleteJson(self::BASE_ROUTE."/{$institution->uuid}/force")->assertOk();
$this->assertDatabaseMissing('institutions', ['id' => $institution->id]);
$this->assertDatabaseMissing('institution_links', ['id' => $link->id]);
Storage::assertMissing(self::GONE_LOGO_PATH);
}
// --- Autorizzazione ------------------------------------------------------
public function test_a_non_admin_cannot_write(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
$institution = Institution::factory()->create();
$this->postJson(self::BASE_ROUTE, [])->assertForbidden();
$this->deleteJson(self::BASE_ROUTE."/{$institution->uuid}")->assertForbidden();
}
public function test_guests_can_read_institutions(): void
{
Institution::factory()->create();
$this->getJson(self::BASE_ROUTE)->assertOk();
}
public function test_guests_cannot_write_institutions(): void
{
$institution = Institution::factory()->create();
$this->postJson(self::BASE_ROUTE, [])->assertUnauthorized();
$this->deleteJson(self::BASE_ROUTE."/{$institution->uuid}")->assertUnauthorized();
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace Tests\Feature;
use App\Enums\InstitutionLinkType;
use App\Models\Institution;
use App\Models\InstitutionLink;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class InstitutionLinkControllerTest extends TestCase
{
use RefreshDatabase;
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
private function validPayload(array $overrides = []): array
{
return array_merge([
'url' => 'https://example.org',
'type' => InstitutionLinkType::Official->value,
'label' => 'Official site',
'sort_order' => 1,
], $overrides);
}
// --- Lettura (tier.app) --------------------------------------------------
public function test_index_lists_links_of_an_institution_ordered(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
$institution = Institution::factory()->create();
$second = InstitutionLink::factory()->create(['institution_id' => $institution->id, 'sort_order' => 2]);
$first = InstitutionLink::factory()->create(['institution_id' => $institution->id, 'sort_order' => 1]);
$this->getJson("/api/institutions/{$institution->uuid}/links")
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.id', $first->id)
->assertJsonPath('data.1.id', $second->id);
}
public function test_show_returns_a_single_link(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
$institution = Institution::factory()->create();
$link = InstitutionLink::factory()->create(['institution_id' => $institution->id]);
$this->getJson("/api/institutions/{$institution->uuid}/links/{$link->id}")
->assertOk()
->assertJsonPath('data.id', $link->id);
}
public function test_show_returns_not_found_when_the_link_belongs_to_another_institution(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
$institution = Institution::factory()->create();
$otherInstitution = Institution::factory()->create();
$link = InstitutionLink::factory()->create(['institution_id' => $otherInstitution->id]);
$this->getJson("/api/institutions/{$institution->uuid}/links/{$link->id}")->assertNotFound();
}
// --- Scrittura (tier.admin) ---------------------------------------------
public function test_admin_can_add_a_link_to_an_institution(): void
{
$this->actingAs($this->adminUser(), 'sanctum');
$institution = Institution::factory()->create();
$this->postJson("/api/institutions/{$institution->uuid}/links", $this->validPayload())
->assertCreated()
->assertJsonPath('data.url', 'https://example.org')
->assertJsonPath('data.institution_id', $institution->id);
$this->assertDatabaseHas('institution_links', [
'institution_id' => $institution->id,
'url' => 'https://example.org',
]);
}
public function test_store_defaults_sort_order_to_zero_when_missing(): void
{
$this->actingAs($this->adminUser(), 'sanctum');
$institution = Institution::factory()->create();
$payload = $this->validPayload();
unset($payload['sort_order']);
$this->postJson("/api/institutions/{$institution->uuid}/links", $payload)
->assertCreated()
->assertJsonPath('data.sort_order', 0);
}
public function test_store_validates_required_fields(): void
{
$this->actingAs($this->adminUser(), 'sanctum');
$institution = Institution::factory()->create();
$this->postJson("/api/institutions/{$institution->uuid}/links", [])
->assertStatus(422)
->assertJsonValidationErrors(['url', 'type']);
}
public function test_admin_can_update_a_link(): void
{
$this->actingAs($this->adminUser(), 'sanctum');
$institution = Institution::factory()->create();
$link = InstitutionLink::factory()->create(['institution_id' => $institution->id]);
$this->putJson(
"/api/institutions/{$institution->uuid}/links/{$link->id}",
$this->validPayload(['url' => 'https://updated.example.org'])
)
->assertOk()
->assertJsonPath('data.url', 'https://updated.example.org');
$this->assertSame('https://updated.example.org', $link->fresh()->url);
}
public function test_admin_can_delete_a_link(): void
{
$this->actingAs($this->adminUser(), 'sanctum');
$institution = Institution::factory()->create();
$link = InstitutionLink::factory()->create(['institution_id' => $institution->id]);
$this->deleteJson("/api/institutions/{$institution->uuid}/links/{$link->id}")->assertOk();
$this->assertDatabaseMissing('institution_links', ['id' => $link->id]);
}
// --- Autorizzazione ------------------------------------------------------
public function test_a_non_admin_cannot_write(): void
{
$this->actingAs($this->operationalUser(), 'sanctum');
$institution = Institution::factory()->create();
$this->postJson("/api/institutions/{$institution->uuid}/links", $this->validPayload())->assertForbidden();
}
public function test_guests_can_read_links(): void
{
$institution = Institution::factory()->create();
InstitutionLink::factory()->create(['institution_id' => $institution->id]);
$this->getJson("/api/institutions/{$institution->uuid}/links")->assertOk();
}
public function test_guests_cannot_write_links(): void
{
$institution = Institution::factory()->create();
$this->postJson("/api/institutions/{$institution->uuid}/links", $this->validPayload())->assertUnauthorized();
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Tests\Feature\Models;
use App\Enums\InstitutionLinkType;
use App\Models\Institution;
use App\Models\InstitutionLink;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class InstitutionLinkTest extends TestCase
{
use RefreshDatabase;
public function test_type_is_cast_to_enum(): void
{
$link = InstitutionLink::factory()->create(['type' => InstitutionLinkType::Ticketing]);
$this->assertInstanceOf(InstitutionLinkType::class, $link->fresh()->type);
$this->assertSame(InstitutionLinkType::Ticketing, $link->fresh()->type);
}
public function test_it_belongs_to_an_institution(): void
{
$institution = Institution::factory()->create();
$link = InstitutionLink::factory()->create(['institution_id' => $institution->id]);
$this->assertTrue($link->institution->is($institution));
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Tests\Feature\Models;
use App\Models\Institution;
use App\Models\InstitutionLink;
use App\Models\Lists\InstitutionCategory;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class InstitutionTest extends TestCase
{
use RefreshDatabase;
public function test_uuid_is_generated_on_create(): void
{
$institution = Institution::factory()->create();
$this->assertNotEmpty($institution->uuid);
$this->assertSame(36, strlen((string) $institution->uuid));
}
public function test_two_institutions_get_distinct_uuids(): void
{
$a = Institution::factory()->create();
$b = Institution::factory()->create();
$this->assertNotSame($a->uuid, $b->uuid);
}
public function test_a_provided_uuid_is_not_overwritten(): void
{
$uuid = (string) Str::uuid();
$institution = Institution::factory()->make();
$institution->uuid = $uuid;
$institution->save();
$this->assertSame($uuid, $institution->fresh()->uuid);
}
public function test_route_key_is_the_uuid(): void
{
$this->assertSame('uuid', (new Institution)->getRouteKeyName());
}
public function test_attributes_are_cast(): void
{
$institution = Institution::factory()->create([
'is_storage_place' => 1,
'lat' => '55.700000',
'lon' => '13.190000',
'legacy_id' => '42',
]);
$fresh = $institution->fresh();
$this->assertIsBool($fresh->is_storage_place);
$this->assertIsFloat($fresh->lat);
$this->assertIsFloat($fresh->lon);
$this->assertSame(42, $fresh->legacy_id);
}
public function test_it_belongs_to_a_category(): void
{
$category = InstitutionCategory::factory()->create();
$institution = Institution::factory()->create(['category_id' => $category->id]);
$this->assertTrue($institution->category->is($category));
}
public function test_a_category_has_many_institutions(): void
{
$category = InstitutionCategory::factory()->create();
Institution::factory()->count(2)->create(['category_id' => $category->id]);
$this->assertCount(2, $category->institutions);
}
public function test_it_has_many_links(): void
{
$institution = Institution::factory()->create();
InstitutionLink::factory()->create(['institution_id' => $institution->id]);
$this->assertCount(1, $institution->refresh()->links);
}
public function test_it_is_soft_deleted(): void
{
$institution = Institution::factory()->create();
$institution->delete();
$this->assertSoftDeleted($institution);
$this->assertSame(0, Institution::count());
$this->assertSame(1, Institution::withTrashed()->count());
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PurgeAnonymizableUsersCommandTest extends TestCase
{
use RefreshDatabase;
/** Crea un utente cestinato con deleted_at retrodatato di $days giorni. */
private function trashedDaysAgo(int $days): User
{
$user = $this->operationalUser();
$user->delete();
User::withTrashed()->whereKey($user->id)->update(['deleted_at' => now()->subDays($days)]);
return $user->fresh();
}
public function test_it_anonymizes_users_past_the_retention_window(): void
{
$this->systemUser();
$old = $this->trashedDaysAgo(40);
$this->artisan('users:purge', ['--days' => 30])->assertSuccessful();
$old->refresh();
$this->assertNotNull($old->anonymized_at);
$this->assertSame('Deleted user', $old->name);
}
public function test_it_skips_users_still_within_retention(): void
{
$this->systemUser();
$recent = $this->trashedDaysAgo(5);
$this->artisan('users:purge', ['--days' => 30])->assertSuccessful();
$recent->refresh();
$this->assertNull($recent->anonymized_at);
}
public function test_dry_run_does_not_modify_anyone(): void
{
$this->systemUser();
$old = $this->trashedDaysAgo(40);
$this->artisan('users:purge', ['--days' => 30, '--dry-run' => true])->assertSuccessful();
$old->refresh();
$this->assertNull($old->anonymized_at);
}
public function test_it_never_touches_the_system_user(): void
{
$system = $this->systemUser();
// Anche se (per assurdo) cestinato e vecchio, l'utente di sistema è escluso.
$system->delete();
User::withTrashed()->whereKey($system->id)->update(['deleted_at' => now()->subDays(99)]);
$this->artisan('users:purge', ['--days' => 30])->assertSuccessful();
$this->assertNull($system->fresh()->anonymized_at);
}
public function test_it_reports_when_there_is_nothing_to_do(): void
{
$this->systemUser();
$this->artisan('users:purge', ['--days' => 30])
->expectsOutputToContain('No users to anonymize.')
->assertSuccessful();
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Tests\Feature;
use App\Actions\PurgeUserAction;
use App\Exceptions\CannotDeleteSystemUserException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PurgeUserActionTest extends TestCase
{
use RefreshDatabase;
private function purge(): PurgeUserAction
{
return app(PurgeUserAction::class);
}
public function test_it_anonymizes_personal_data_in_place(): void
{
$this->systemUser();
$user = $this->operationalUser(['name' => 'Mario Rossi']);
$this->purge()->execute($user);
$user->refresh();
$this->assertSame('Deleted user', $user->name);
$this->assertSame("deleted-{$user->id}@anonymized.invalid", $user->email);
$this->assertNotNull($user->anonymized_at);
$this->assertNull($user->email_verified_at);
$this->assertNull($user->two_factor_setup_completed_at);
$this->assertFalse($user->must_change_password);
}
public function test_it_is_idempotent_on_already_anonymized_users(): void
{
$this->systemUser();
$user = $this->operationalUser(['name' => 'Keep Me', 'anonymized_at' => now()]);
$this->purge()->execute($user);
$user->refresh();
// Già anonimizzato → uscita anticipata: nessuna riscrittura del nome.
$this->assertSame('Keep Me', $user->name);
}
public function test_it_refuses_to_anonymize_the_system_user(): void
{
$system = $this->systemUser();
$this->expectException(CannotDeleteSystemUserException::class);
$this->purge()->execute($system);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Tests\Feature;
use App\Models\Lists\UserRole;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Database\Seeders\SystemUserSeeder;
use Database\Seeders\UserRoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SeederTest extends TestCase
{
use RefreshDatabase;
public function test_user_role_seeder_creates_the_base_roles(): void
{
$this->seed(UserRoleSeeder::class);
$this->assertSame(4, UserRole::count());
foreach ([UserRole::ADMIN, UserRole::SUPERVISOR, UserRole::USER, UserRole::GUEST] as $name) {
$this->assertDatabaseHas('user_roles', ['name' => $name]);
}
}
public function test_role_descriptions_are_in_english(): void
{
$this->seed(UserRoleSeeder::class);
$this->assertStringContainsString(
'Full access',
(string) UserRole::where('name', UserRole::ADMIN)->value('description')
);
}
public function test_user_role_seeder_is_idempotent(): void
{
$this->seed(UserRoleSeeder::class);
$this->seed(UserRoleSeeder::class);
$this->assertSame(4, UserRole::count());
}
public function test_system_user_seeder_creates_a_locked_admin_account(): void
{
$this->seed(UserRoleSeeder::class);
$this->seed(SystemUserSeeder::class);
$system = User::where('email', SystemUserSeeder::EMAIL)->firstOrFail();
$this->assertTrue($system->is_system);
$this->assertFalse($system->must_change_password);
$this->assertSame(UserRole::ADMIN, $system->role->name);
$this->assertSame('complete', $system->setup_status);
}
public function test_system_user_seeder_is_idempotent(): void
{
$this->seed(UserRoleSeeder::class);
$this->seed(SystemUserSeeder::class);
$this->seed(SystemUserSeeder::class);
$this->assertSame(1, User::where('email', SystemUserSeeder::EMAIL)->count());
}
public function test_database_seeder_runs_the_full_chain(): void
{
$this->seed(DatabaseSeeder::class);
$this->assertSame(4, UserRole::count());
$this->assertDatabaseHas('users', ['email' => SystemUserSeeder::EMAIL, 'is_system' => true]);
}
}

Some files were not shown because too many files have changed in this diff Show More