Compare commits

...

11 commits

15 changed files with 577 additions and 222 deletions

View file

@ -8,57 +8,76 @@ Read `ai/shared.md` first. This file covers backend-specific rules.
**Location:** `backend/`.
The application is still close to the Laravel starter structure. Do not
introduce a domain architecture, repository layer, service layer, or other
abstraction before the codebase and requested behavior justify it. Match
existing Laravel conventions and inspect similar code before adding a new
pattern.
The backend follows a domain-oriented structure. Existing areas use entities,
DTOs, repository interfaces, Eloquent implementations, use cases, and test
fakes. Extend those patterns when adding behavior to an established area. Do
not add speculative layers or interfaces that the requested behavior does not
need.
## Laravel patterns
The Vue application remains a separate project under `frontend/website/`.
Keep frontend source, dependencies, builds, and delivery out of the backend
unless the user explicitly asks to integrate them. The backend root route is
intentionally unclaimed; the built-in health endpoint is `/up`.
- Keep controllers thin. Put reusable business behavior in an appropriately
named application or domain class once the behavior warrants extraction.
- Use dedicated request validation rather than validating substantial payloads
inline in controllers.
- Let unexpected exceptions reach Laravel's exception handler. Catch only
exceptions that can be handled meaningfully at the current boundary.
## Code patterns
- Inspect similar domain code before adding a new entity, DTO, repository,
use case, controller action, or fake.
- Entities own domain state and behavior and expose descriptive methods.
- DTOs are explicit data containers for creation or transfer between seams.
- Repository interfaces define domain-facing persistence operations. Keep
Eloquent details in their implementations and register bindings in the
application provider.
- Put reusable business behavior in a use case. Give a use case a request DTO
when it accepts a payload; call `execute()` directly when it has no input.
- Document use-case exceptions with `@throws` when callers are expected to
handle them.
- Keep controllers thin. Translate HTTP input to use-case input and domain
output to the response contract.
- Catch only documented, expected exceptions at the controller boundary. Let
unexpected exceptions reach Laravel's exception handler.
- Fake repositories are in-memory implementations used by unit tests. Return
a new entity instance from create and lookup methods rather than exposing a
stored reference.
- Use Eloquent relationships and query scopes consistently rather than
duplicating query fragments.
- Avoid speculative interfaces and abstractions with only one trivial
implementation.
- The Vue application is a separate project under `frontend/website/`.
- Keep frontend source, dependencies, builds, and delivery out of the backend
unless the user explicitly asks to integrate them.
- The backend root route is intentionally unclaimed. The built-in health
endpoint is `/up`.
## Tests
## Unit tests
- Follow the existing PHPUnit organization under `tests/Unit/` and
`tests/Feature/`.
- Prefer `PHPUnit\Framework\TestCase` when a test only exercises plain PHP.
- Extend `Tests\TestCase` only when the test needs Laravel's container,
facades, database, routing, or HTTP kernel.
- HTTP feature tests extend `Tests\TestCase`.
- Use `RefreshDatabase` when a test reads or writes database state.
- Assert behavior at the appropriate seam:
- Unit tests cover isolated business behavior and edge cases.
- Feature tests cover routing, middleware, validation, persistence, and
response shape.
- Do not duplicate every business branch through the HTTP layer when unit
coverage already proves it. Feature tests should focus on wiring and the
public contract.
- Follow the existing organization under `tests/Unit/<Area>/`.
- Use fake repositories and fake collaborators for the behavior under test.
Construct unrelated dependency entities directly instead of routing them
through additional repositories.
- Test use-case branches at the use-case seam. Do not repeat every branch in
controller or HTTP tests.
- Plain entities, value objects, use cases, middleware, and controller units
should extend `PHPUnit\Framework\TestCase` when they do not need Laravel.
- Extend `Tests\TestCase` only when a test needs Laravel's container, facades,
database, routing, or HTTP kernel.
- Reserve direct Eloquent repository feature tests for persistence mapping,
query behavior, or database constraints that cannot be proven through a
plain unit test.
## Test database
## Feature tests
- Feature tests exercise the HTTP seam: routing, middleware, real Eloquent
bindings, cookies, persistence, validation, and response shape.
- Feature tests are additive to unit tests. Prefer a happy path and the
relevant authorization guard instead of duplicating all business branches.
- Place endpoint tests under `tests/Feature/<Area>/`, extend
`Tests\TestCase`, and use `RefreshDatabase` when database state is involved.
- Development and runtime use PostgreSQL through the local Unix socket.
- PHPUnit intentionally uses SQLite `:memory:` as configured in
`phpunit.xml`.
- Feature tests are self-contained and do not require the process-compose
PostgreSQL service.
PHPUnit uses SQLite `:memory:` as configured in `phpunit.xml`, so feature
tests are self-contained and do not need the worktree stack.
- Never point `RefreshDatabase` tests at the development PostgreSQL database.
- Keep mail set to the PHPUnit `array` transport unless a test explicitly
exercises a real mail integration.
- Keep mail on PHPUnit's `array` transport unless a test explicitly exercises
a real mail integration.
- Authentication uses a custom database-session cookie, not a Laravel guard.
`actingAs()` does not apply. Credentialed requests use
`withCredentials()` and `withUnencryptedCookie()` because API middleware
reads the raw cookie.
- Read an unencrypted response cookie with
`$response->getCookie($name, false)`.
## PHP rules
@ -80,7 +99,7 @@ pattern.
- Do not add production repository or model APIs solely to make seeding
convenient.
- Use existing lookup methods for cross-seeder relationships. When a group of
records only makes sense together, keep them in one seeder and retain local
records only makes sense together, keep it in one seeder and retain local
references.
## Migrations
@ -99,15 +118,12 @@ pattern.
- Once a production database exists, replace this policy with additive,
forward-only migrations.
## Before completing backend work
## Backend workflow
- Run the focused test during development.
- Run the full test suite before completion:
```sh
direnv exec "$(git rev-parse --show-toplevel)" php artisan test
```
- Run the Composer static-analysis scripts.
- Run the focused PHPUnit test while developing.
- Use `just backend-types-check` for Larastan and `just backend-test` for the
full PHPUnit suite during iteration.
- Fix failures caused by the change. Report unrelated baseline failures
precisely rather than hiding them or expanding scope without authorization.
precisely rather than expanding scope silently.
- The shared `just test-all` command is the required completion gate. Focused
backend recipes never replace it.

View file

@ -4,7 +4,8 @@ Read `ai/shared.md` first. This file covers frontend-specific rules.
## Project context
**Stack:** Vue 3.5, TypeScript 6, Vite 8, Vue Router 5, Pinia 4, npm.
**Stack:** Vue 3.5, TypeScript 6, Vite 8, Vue Router 5, Pinia 4, Zod 4,
Cypress 15, npm.
**Location:** `frontend/website/`.
@ -12,18 +13,16 @@ The frontend is a standalone application. Keep its source, dependencies,
development server, and production build independent from the backend unless
the user explicitly requests integration.
The scaffold uses:
The application currently has route-level views, reusable authentication
components, a Pinia authentication store, Zod schemas, API URL handling, and
Cypress end-to-end specs. The main entry points are:
- `src/App.vue` as the root component.
- `src/main.ts` to create the app and install the router and Pinia.
- `src/router/index.ts` for routes.
- `src/stores/` for Pinia stores.
- `@` as an alias for `src/` in both Vite and TypeScript.
There are no views, shared components, API layer, or configured test suite
yet. Cypress is installed as a frontend development dependency, but there is
no Cypress configuration or npm test script. Do not invent an architecture
before requested behavior establishes one.
- `src/App.vue` for the root component.
- `src/main.ts` for app creation, Pinia, the router, and global styles.
- `src/router/index.ts` for routes and authentication guards.
- `src/stores/` for Pinia stores and API boundaries.
- `src/views/` and `src/components/` for route and reusable UI.
- `@` as the `src/` alias in Vite and TypeScript.
## Package management and commands
@ -36,32 +35,14 @@ Install dependencies in a fresh checkout or worktree:
direnv exec "$(git rev-parse --show-toplevel)" npm install
```
To start only the development server on the port assigned by the shell hook:
`npm run format` and `npm run lint` rewrite files. Their `format:check` and
`lint:check` counterparts are non-mutating completion checks. `npm run build`
runs type checking and the production build; generated `dist/` output is
ignored.
```sh
direnv exec "$(git rev-parse --show-toplevel)" \
npm run dev -- \
--host 127.0.0.1 \
--port "$VITE_PORT" \
--strictPort
```
`process-compose` starts the frontend as part of the full development stack.
The frontend remains directly accessible on `VITE_PORT`; Caddy does not proxy
it.
The available validation commands are:
```sh
direnv exec "$(git rev-parse --show-toplevel)" npm run format
direnv exec "$(git rev-parse --show-toplevel)" npm run lint
direnv exec "$(git rev-parse --show-toplevel)" npm run type-check
direnv exec "$(git rev-parse --show-toplevel)" npm run build
```
`npm run format` and `npm run lint` rewrite files. Review the resulting diff.
`npm run build` runs type checking and the production build. Build output
under `dist/` is generated and ignored.
`process-compose` starts the frontend as part of the complete development
stack. The frontend is directly accessible on `VITE_PORT`; Caddy serves the
backend and does not proxy the frontend.
## Vue conventions
@ -72,58 +53,69 @@ under `dist/` is generated and ignored.
`src/router/index.ts`.
- Keep page, component, composable, and store responsibilities distinct.
- Prefer small components with explicit props and emitted events.
- Keep component styles scoped until the project adopts a deliberate global
styling system.
- Use setup-style Pinia stores named `useXxxStore`.
- Inspect similar files before introducing a new component, composable,
store, or data-access pattern.
- Keep application-wide resets and base styles in `src/styles/main.css`.
Keep component and view styles scoped and match established Attainly visual
patterns.
- Inspect similar files before introducing a component, composable, store,
route, or data-access pattern.
## TypeScript
## TypeScript and runtime validation
- Preserve the strict TypeScript configuration and
`noUncheckedIndexedAccess`.
- Do not use `any`. Model unknown external values as `unknown`, then narrow or
validate them.
- Derive types from runtime schemas if the project adopts a schema library.
Do not maintain a hand-written type that can drift from its schema.
- Validate payloads at trust boundaries, especially backend responses and
user-submitted forms.
- Keep request and response types close to the API or store boundary that
owns them.
- Keep the `@` alias aligned across Vite, TypeScript, and any future test
configuration.
- Preserve strict TypeScript and `noUncheckedIndexedAccess`.
- Do not use `any`. Model unknown external values as `unknown`, then parse or
narrow them.
- Zod schemas are the source of truth for runtime payloads. Define a schema
and derive its TypeScript type with `z.infer` instead of maintaining a
parallel hand-written interface.
- Parse every backend response at the store or API boundary before placing it
in application state.
- Keep read and write schemas separate when their shapes differ.
- Validate user-submitted forms with a Zod object schema when the form has
meaningful validation rules. Surface field errors from the schema rather
than maintaining parallel regular expressions and error logic.
- Keep request and response schemas and types near the API or store boundary
that owns them.
- Keep the `@` alias aligned across Vite, TypeScript, and future test tooling.
## State and API access
- Use Pinia for shared client state. Keep component-local state in components.
- Keep server requests and response transformation at an API or store
boundary, not scattered through presentation components.
- Keep requests, response parsing, and response transformation at an API or
store boundary, not in presentation components.
- Represent loading, empty, success, validation-error, and unexpected-error
states explicitly.
- Do not cast unchecked JSON directly to an application interface.
- Keep read and write payload types separate when their shapes differ.
- Do not cast unchecked JSON to an application interface.
- Send cookie-backed API requests with the established credentials behavior.
## Testing
Cypress is installed, but no frontend test configuration or test script
exists yet.
- Cypress is configured under `cypress/` and runs through `npm run test:e2e`
or `just frontend-cypress-run`.
- Prefer the cheapest test seam that proves the behavior. Cypress covers
routing, browser forms, authentication flows, request wiring, and responsive
behavior.
- Mock backend calls in frontend-focused Cypress tests. Use the worktree
backend only for a deliberately end-to-end integration scenario.
- Assert both the request contract and the rendered response behavior when a
spec intercepts an API call.
- Keep mock payloads synchronized with exported store types and Zod schemas.
Add typed builders when payloads repeat across specs, and parse builder
output through the exported schema when practical.
- Authentication requests are mockable like every other frontend boundary.
Backend persistence, cookie creation, middleware, and mail behavior belong
in PHPUnit tests.
- No Vitest unit or component suite is configured. Do not claim that coverage.
If new pure logic or component behavior cannot be proved economically with
Cypress, establish the smallest appropriate Vitest setup test-first.
- Do not invent test commands or claim frontend tests passed.
- New frontend behavior must still follow the shared test-first workflow.
Establish the smallest appropriate test setup before implementing behavior
that needs it.
- Unit tests should cover pure transformations, composables, and store logic.
- Component tests should cover rendering, events, form behavior, and
conditional UI.
- End-to-end tests should cover routing, multi-page flows, and request wiring.
- Prefer the cheapest layer that proves the behavior.
- Mock backend requests in frontend tests. Do not retest backend persistence,
validation, authentication, or mail behavior through the frontend.
## Frontend workflow
## Before completing frontend work
- Run the focused test while developing once test tooling exists.
- Run the formatter, linter, type checker, production build, and every
configured test script affected by the change.
- Do not claim a green gate when a command fails. Report a baseline or
environmental failure precisely.
- Run the focused Cypress spec while developing when browser behavior changes.
- Run `npm run format` and `npm run lint` before committing frontend changes,
then review every rewrite.
- Use the focused `just frontend-*` recipes for development feedback.
- The shared `just test-all` command is the required completion gate. Focused
frontend checks never replace it.
- Do not claim a green gate when a command fails. Report baseline or
environmental failures precisely.

View file

@ -39,6 +39,24 @@ Use judgment for changes that cannot meaningfully be test-driven, such as
documentation-only edits or declarative environment configuration. Validate
those changes with the most relevant parser, formatter, dry run, or check.
## Maintaining these instructions
- Treat user steering as durable when it corrects or establishes a reusable
project rule for workflow, architecture, conventions, validation, safety,
or scope.
- When durable steering is received during work, update the appropriate
`ai/*.md` file in the active worktree before completing the task. Do not
wait for a separate request to maintain the instructions.
- Put repository-wide rules in `shared.md` and stack-specific rules in the
matching backend or frontend guide.
- Merge new guidance into the existing rule set. Keep it concise, remove
duplication, and resolve conflicts between shared and stack-specific text.
- Do not persist task-specific scope, temporary directions, secrets,
environment incidents, or instructions that conflict with higher-priority
guidance.
- Commit a durable instruction update separately from implementation unless
the task itself is solely an instruction change.
## Approval discipline
- Treat dependency installation, tests, static analysis, formatting, linting,
@ -64,58 +82,42 @@ those changes with the most relevant parser, formatter, dry run, or check.
- A worktree owns its own isolated stack. The flake shell hook assigns a
deterministic port offset and creates worktree-local PostgreSQL state.
- Start a worktree stack only when runtime or integration validation requires
it. Start it once from the worktree root, reuse it throughout validation,
and stop it once when finished.
- Do not start any service for PHPUnit, frontend formatting, linting, type
checking, or production builds.
- When authorized worktree stack control is necessary, operate it directly.
Do not ask the user to start or stop worktree services.
- For non-interactive use, start the stack detached and stop it when finished,
as shown below.
- Do not use `process-compose -t=false` for a detached stack. It can leave an
it. Start it once, reuse it throughout validation, and stop it once when
finished.
- PHPUnit, frontend formatting, linting, type checking, and production builds
do not require services. The Cypress completion suite does require the
worktree stack.
- When worktree stack control is necessary, operate it directly. Do not ask
the user to start or stop worktree services.
- Never use `process-compose -t=false` for a detached stack. It can leave an
orphaned PostgreSQL process holding the data directory.
- Non-interactive agent shells do not automatically load direnv. Bare project
commands can use missing tools, default ports, or paths from the main
checkout.
- Run project tooling that depends on the repository development environment
through direnv. This includes PHP, Composer, Artisan, npm, tests, builds,
database clients, and services.
- Resolve the direnv target from the worktree containing the agent's current
working directory. Never target the main checkout or a different worktree:
- Run PHP, Composer, Artisan, npm, tests, builds, database clients, and
services through the development environment.
- Resolve the direnv target from the worktree containing the current working
directory. Never target the main checkout or a different worktree:
```sh
direnv exec "$(git rev-parse --show-toplevel)" <command>
```
- Git and environment-neutral read-only file inspection do not need the
direnv wrapper.
- Worktree stack examples:
- Git and environment-neutral read-only inspection do not need direnv.
- Start and stop a worktree stack from its root:
```sh
direnv exec "$(git rev-parse --show-toplevel)" process-compose up -D
direnv exec "$(git rev-parse --show-toplevel)" process-compose down
```
- Run backend commands from `backend/`, or explicitly change into it in the
command. The direnv target remains the worktree root.
- Run frontend commands from `frontend/website/`.
- `process-compose` starts the frontend on the worktree's assigned port. To
start only the frontend:
```sh
direnv exec "$(git rev-parse --show-toplevel)" \
npm run dev -- \
--host 127.0.0.1 \
--port "$VITE_PORT" \
--strictPort
```
- Run backend commands from `backend/` and frontend commands from
`frontend/website/`. The direnv target remains the worktree root.
- When a normally valid check fails because a required service is down,
surface the environmental failure. Do not skip the check or silently switch
to a different database or service.
surface the environmental failure. Do not skip the check, change databases,
or claim the work is complete.
- PHPUnit is self-contained and uses in-memory SQLite. It does not require the
PostgreSQL stack unless a future test is explicitly designed as a real
PostgreSQL integration test.
PostgreSQL stack unless a future test explicitly targets real PostgreSQL.
## Code style
@ -155,14 +157,13 @@ those changes with the most relevant parser, formatter, dry run, or check.
- Do not include drive-by formatter or linter changes. Restore them or land
them as a separate formatting commit.
- If a check fails on untouched code, do not bundle an unrelated fix. Report
the pre-existing failure or handle it as its own explicitly scoped change.
the baseline failure or handle it as its own explicitly scoped change.
## Branching
- Never implement features directly in the main checkout or on
`master`/`main`.
- Use a dedicated worktree under
`<repo-root>/.worktrees/<branch>`.
- Use a dedicated worktree under `<repo-root>/.worktrees/<branch>`.
- Create it with:
```sh
@ -183,10 +184,10 @@ those changes with the most relevant parser, formatter, dry run, or check.
```
- Never symlink `backend/vendor` or `frontend/website/node_modules` from
another checkout. Dependency paths and generated files must remain
another checkout. Dependencies and generated files must remain
worktree-local.
- The shell hook installs backend dependencies but does not install frontend
dependencies. From `frontend/website/`, provision them with:
- The shell hook installs backend dependencies but not frontend dependencies.
Install frontend dependencies from `frontend/website/`:
```sh
direnv exec "$(git rev-parse --show-toplevel)" npm install
@ -196,59 +197,35 @@ Do not push anything. Make commits as the TDD workflow requires.
## Before completing a change
Run the smallest relevant checks while iterating, then run every repository
gate affected by the change.
A change is not complete until the worktree stack is ready and the unified
gate passes against that worktree:
### Backend
- Run tests from `backend/`:
```sh
direnv exec "$(git rev-parse --show-toplevel)" php artisan test
```
- Run the Composer checks defined by `backend/composer.json`:
```sh
direnv exec "$(git rev-parse --show-toplevel)" composer types:check
direnv exec "$(git rev-parse --show-toplevel)" composer test
```
- Do not claim a green gate when a command fails. If the failure predates the
change, report the precise baseline failure.
### Frontend
- Run these commands from `frontend/website/`:
```sh
direnv exec "$(git rev-parse --show-toplevel)" npm run format
direnv exec "$(git rev-parse --show-toplevel)" npm run lint
direnv exec "$(git rev-parse --show-toplevel)" npm run type-check
direnv exec "$(git rev-parse --show-toplevel)" npm run build
```
- The formatter and linters rewrite files. Review their changes before
committing.
- No frontend test runner is configured yet. Do not claim unit, component, or
end-to-end test coverage until the relevant scripts exist and pass.
### Environment and integration
- For Nix or shell-hook changes, run:
```sh
direnv exec "$(git rev-parse --show-toplevel)" nix fmt
direnv exec "$(git rev-parse --show-toplevel)" nix flake check
```
- For service configuration changes, run:
1. Start the stack detached from the worktree root:
```sh
direnv exec "$(git rev-parse --show-toplevel)" \
process-compose --dry-run
process-compose up -D
```
- When a change affects runtime wiring, start the worktree's stack and verify
the relevant endpoint or service against that worktree.
- If you started the stack only for validation, stop it before finishing.
2. Poll `process-compose process list` until every process is running and
ready.
3. Run the complete gate from the worktree root:
```sh
direnv exec "$(git rev-parse --show-toplevel)" just test-all
```
4. Do not hand-assemble a substitute from focused commands. `test-all` runs
frontend format and lint checks, frontend type checking, Larastan, the
production build, PHPUnit, and Cypress in fail-fast order.
5. Everything must pass before completion. Report exact baseline or
environmental failures rather than hiding them.
6. If the stack was started only for validation, stop it when finished:
```sh
direnv exec "$(git rev-parse --show-toplevel)" process-compose down
```
Focused `just` recipes are for iteration only. For Nix or shell-hook changes,
also run `nix fmt` and `nix flake check`. For service configuration changes,
also run `process-compose --dry-run`.

View file

@ -0,0 +1,17 @@
<?php
namespace App\Auth\UseCases\Logout;
use App\Auth\SessionRepository;
class Logout
{
public function __construct(
private SessionRepository $sessionRepository,
) {}
public function execute(string $token): void
{
$this->sessionRepository->deleteByToken($token);
}
}

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
use App\Auth\UseCases\AuthenticateUser\AuthenticateUserRequest;
use App\Auth\UseCases\CreateSession\CreateSession;
use App\Auth\UseCases\Logout\Logout;
use App\Exceptions\BadRequestException;
use App\Exceptions\UnauthorizedException;
use App\Http\Middleware\AuthMiddleware;
@ -19,6 +20,7 @@ class AuthController extends Controller
public function __construct(
private AuthenticateUser $authenticateUser,
private CreateSession $createSession,
private Logout $logout,
) {}
public function login(Request $request): JsonResponse
@ -71,6 +73,28 @@ class AuthController extends Controller
]);
}
public function logout(Request $request): JsonResponse
{
$token = $request->cookie(AuthMiddleware::COOKIE_NAME);
if (is_string($token) && $token !== '') {
$this->logout->execute($token);
}
$response = new JsonResponse(null, 204);
return $response->withCookie(Cookie::create(
name: AuthMiddleware::COOKIE_NAME,
value: '',
expire: 1,
path: '/',
domain: null,
secure: false,
httpOnly: true,
raw: false,
sameSite: Cookie::SAMESITE_LAX,
));
}
/**
* @return array{id: int, email: string}
*/

View file

@ -8,4 +8,5 @@ Route::post('/login', [AuthController::class, 'login']);
Route::middleware(AuthMiddleware::class)->group(function (): void {
Route::get('/me', [AuthController::class, 'me']);
Route::post('/logout', [AuthController::class, 'logout']);
});

View file

@ -0,0 +1,58 @@
<?php
namespace Tests\Feature\Auth;
use App\Auth\CreateSessionDto;
use App\Auth\SessionRepository;
use App\Http\Middleware\AuthMiddleware;
use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto;
use App\User\UserRepository;
use DateTimeImmutable;
use DateTimeZone;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class LogoutEndpointTest extends TestCase
{
use RefreshDatabase;
public function test_logout_deletes_session_and_clears_cookie(): void
{
$now = new DateTimeImmutable(
'2026-07-31T12:00:00',
new DateTimeZone('UTC'),
);
$user = app(UserRepository::class)->create(new CreateUserDto(
email: new EmailAddress('user@example.com'),
passwordHash: 'hashed-password',
));
app(SessionRepository::class)->create(new CreateSessionDto(
token: 'session-token',
user: $user,
createdAt: $now,
expiresAt: $now->modify('+7 days'),
));
$response = $this->withCredentials()
->withUnencryptedCookie(
AuthMiddleware::COOKIE_NAME,
'session-token',
)->postJson('/api/logout');
$response->assertNoContent();
$response->assertCookieExpired(AuthMiddleware::COOKIE_NAME);
$this->assertNull(
app(SessionRepository::class)->findByToken('session-token'),
);
}
public function test_logout_rejects_a_request_without_a_cookie(): void
{
$response = $this->postJson('/api/logout');
$response
->assertStatus(401)
->assertExactJson(['error' => 'unauthenticated']);
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace Tests\Unit\Auth\UseCases;
use App\Auth\CreateSessionDto;
use App\Auth\UseCases\Logout\Logout;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeSessionRepository;
class LogoutTest extends TestCase
{
private FakeSessionRepository $sessionRepository;
private Logout $useCase;
protected function setUp(): void
{
$this->sessionRepository = new FakeSessionRepository;
$this->useCase = new Logout($this->sessionRepository);
}
public function test_existing_token_session_is_removed(): void
{
$now = new DateTimeImmutable(
'2026-07-31T12:00:00',
new DateTimeZone('UTC'),
);
$this->sessionRepository->create(new CreateSessionDto(
token: 'session-token',
user: new User(
id: 7,
email: new EmailAddress('user@example.com'),
passwordHash: 'hashed-password',
),
createdAt: $now,
expiresAt: $now->modify('+7 days'),
));
$this->useCase->execute('session-token');
$this->assertNull(
$this->sessionRepository->findByToken('session-token'),
);
}
public function test_unknown_token_does_not_throw(): void
{
$this->useCase->execute('unknown-token');
$this->assertNull(
$this->sessionRepository->findByToken('unknown-token'),
);
}
}

View file

@ -4,6 +4,7 @@ namespace Tests\Unit\Http\Controllers;
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
use App\Auth\UseCases\CreateSession\CreateSession;
use App\Auth\UseCases\Logout\Logout;
use App\Http\Controllers\AuthController;
use App\Http\Middleware\AuthMiddleware;
use App\Shared\ValueObject\EmailAddress;
@ -45,9 +46,11 @@ class AuthControllerTest extends TestCase
new DateTimeZone('UTC'),
)),
);
$logout = new Logout($this->sessionRepository);
$this->controller = new AuthController(
$authenticateUser,
$createSession,
$logout,
);
}
@ -117,6 +120,37 @@ class AuthControllerTest extends TestCase
);
}
public function test_logout_deletes_session_and_clears_cookie(): void
{
$this->createUser('correct-password');
$this->controller->login(new Request([
'email' => 'user@example.com',
'password' => 'correct-password',
]));
$request = new Request;
$request->cookies->set(
AuthMiddleware::COOKIE_NAME,
'session-token',
);
$response = $this->controller->logout($request);
$this->assertSame(204, $response->getStatusCode());
$this->assertNull(
$this->sessionRepository->findByToken('session-token'),
);
$cookies = $response->headers->getCookies();
$this->assertCount(1, $cookies);
$this->assertSame(
AuthMiddleware::COOKIE_NAME,
$cookies[0]->getName(),
);
$this->assertSame('', $cookies[0]->getValue());
$this->assertSame(1, $cookies[0]->getExpiresTime());
$this->assertTrue($cookies[0]->isHttpOnly());
$this->assertSame('lax', $cookies[0]->getSameSite());
}
private function createUser(string $password): void
{
$this->userRepository->create(new CreateUserDto(

View file

@ -3,6 +3,40 @@ const authenticatedUser = {
email: 'user@example.com',
}
function interceptLogoutFlow(): void {
let authenticated = true
cy.intercept('GET', '**/api/me', (request) => {
if (authenticated) {
request.alias = 'me'
request.reply({
statusCode: 200,
body: { user: authenticatedUser },
})
return
}
request.alias = 'loggedOutMe'
request.reply({
statusCode: 401,
body: { error: 'unauthenticated' },
})
})
cy.intercept('POST', '**/api/logout', (request) => {
expect(request.headers.accept).to.equal('application/json')
authenticated = false
request.reply({ statusCode: 204 })
}).as('logout')
}
function visitDashboardAndLogout(): void {
cy.visit('/dashboard')
cy.wait('@me')
cy.contains('button', 'Log out').click()
cy.wait('@logout')
cy.wait('@loggedOutMe')
}
describe('session authentication', () => {
it('restores an authenticated session on a protected route', () => {
cy.intercept('GET', '**/api/me', {
@ -42,6 +76,18 @@ describe('session authentication', () => {
cy.location('pathname').should('equal', '/dashboard')
})
it('redirects a restored session away from the home route', () => {
cy.intercept('GET', '**/api/me', {
statusCode: 200,
body: { user: authenticatedUser },
}).as('me')
cy.visit('/')
cy.wait('@me')
cy.location('pathname').should('equal', '/dashboard')
})
it('rejects a malformed authenticated-user response', () => {
cy.intercept('GET', '**/api/me', {
statusCode: 200,
@ -53,4 +99,23 @@ describe('session authentication', () => {
cy.location('pathname').should('equal', '/login')
})
it('logs out and redirects to login', () => {
interceptLogoutFlow()
visitDashboardAndLogout()
cy.location('pathname').should('equal', '/login')
})
it('keeps protected routes inaccessible after logout', () => {
interceptLogoutFlow()
visitDashboardAndLogout()
cy.visit('/dashboard')
cy.wait('@loggedOutMe')
cy.location('pathname').should('equal', '/login')
cy.location('search').should('include', 'redirect=/dashboard')
})
})

View file

@ -10,10 +10,14 @@
"build-only": "vite build",
"type-check": "vue-tsc --build",
"test:e2e": "cypress run",
"lint": "run-s \"lint:*\"",
"lint": "run-s lint:oxlint lint:eslint",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "oxfmt src/"
"lint:check": "run-s lint:oxlint:check lint:eslint:check",
"lint:oxlint:check": "oxlint .",
"lint:eslint:check": "eslint . --cache",
"format": "oxfmt src/",
"format:check": "oxfmt --check src/"
},
"dependencies": {
"pinia": "^4.0.2",

View file

@ -9,6 +9,9 @@ const router = createRouter({
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
meta: {
guestOnly: true,
},
},
{
path: '/login',

View file

@ -101,6 +101,20 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function logout(): Promise<void> {
try {
await fetch(`${API_BASE}/api/logout`, {
method: 'POST',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
} finally {
user.value = null
}
}
return {
user,
loading,
@ -108,5 +122,6 @@ export const useAuthStore = defineStore('auth', () => {
isAuthenticated,
fetchMe,
login,
logout,
}
})

View file

@ -1,11 +1,23 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import BrandWordmark from '@/components/BrandWordmark.vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const router = useRouter()
async function handleLogout(): Promise<void> {
await authStore.logout()
await router.push({ name: 'login' })
}
</script>
<template>
<main class="dashboard-page">
<header>
<BrandWordmark theme="dark" />
<button type="button" class="logout-button" @click="handleLogout">Log out</button>
</header>
<section>
@ -29,10 +41,41 @@ import BrandWordmark from '@/components/BrandWordmark.vue'
}
header {
display: flex;
width: min(100%, 76rem);
align-items: center;
justify-content: space-between;
margin: 0 auto;
}
.logout-button {
min-height: 2.75rem;
padding: 0.7rem 1.1rem;
border: 0;
border-radius: 0.7rem;
color: #fffdf7;
background: #183a31;
box-shadow: 0 0.55rem 1.2rem rgb(24 58 49 / 14%);
font-size: 0.82rem;
font-weight: 750;
cursor: pointer;
transition:
background-color 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
.logout-button:hover {
background: #285c4e;
box-shadow: 0 0.7rem 1.4rem rgb(24 58 49 / 18%);
transform: translateY(-1px);
}
.logout-button:focus-visible {
outline: 3px solid rgb(86 127 112 / 34%);
outline-offset: 0.25rem;
}
section {
width: min(100%, 42rem);
margin: clamp(6rem, 18vh, 12rem) auto 0;

View file

@ -3,5 +3,53 @@ set shell := ["bash", "-c"]
default:
@just --list
# Full completion gate. Start the worktree stack before running it because
# Cypress exercises the frontend and its backend wiring.
test-all:
@echo "==> frontend format + lint checks"
just frontend-format-check
just frontend-lint-check
@echo "==> frontend type check"
just frontend-type-check
@echo "==> backend static analysis"
just backend-types-check
@echo "==> frontend production build"
just frontend-build
@echo "==> backend tests"
just backend-test
@echo "==> frontend Cypress tests"
just frontend-cypress-run
# Backend
backend-test *args:
cd backend && php artisan test {{args}}
backend-types-check:
cd backend && composer types:check
fresh:
cd backend && php artisan migrate:fresh --seed
# Frontend
frontend-format:
cd frontend/website && npm run format
frontend-format-check:
cd frontend/website && npm run format:check
frontend-lint:
cd frontend/website && npm run lint
frontend-lint-check:
cd frontend/website && npm run lint:check
frontend-type-check:
cd frontend/website && npm run type-check
frontend-build:
cd frontend/website && npm run build-only
frontend-cypress-run:
cd frontend/website && npm run test:e2e