Compare commits

...

8 commits

32 changed files with 2882 additions and 16 deletions

View file

@ -31,5 +31,27 @@ services:
# Ensure that host.docker.internal is correctly defined on Linux
- host.docker.internal:host-gateway
tty: true
# Standalone React/Vite SPA (dev only — not in compose.prod.yaml; see #33/#35).
frontend:
image: node:22-alpine
working_dir: /app
volumes:
- ./frontend:/app
# Named volume shadows the bind-mounted node_modules so native deps
# (esbuild/rollup) are built for the container's arch, not the host's.
- frontend_node_modules:/app/node_modules
ports:
- "${VITE_PORT:-5173}:5173"
command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 5173"
depends_on:
- php
environment:
# Reliable file-watching under the bind-mount.
CHOKIDAR_USEPOLLING: "true"
tty: true
###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ###
volumes:
frontend_node_modules:

2
frontend/.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
*.woff binary
*.woff2 binary

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
frontend/.oxlintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

4
frontend/.prettierignore Normal file
View file

@ -0,0 +1,4 @@
node_modules
dist
package-lock.json
*.tsbuildinfo

View file

@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2
}

38
frontend/README.md Normal file
View file

@ -0,0 +1,38 @@
# Buckets — Frontend
Standalone React SPA for the Buckets budgeting app. Talks to the Symfony +
API Platform backend over a cookie-session API (`/api`).
## Stack
- Vite + React + TypeScript (strict)
- Tailwind 4 (`@tailwindcss/vite`) — dark-only "80s terminal" theme
- oxlint (lint) + Prettier (format)
## Development
The frontend runs as the `frontend` service in the project's compose stack
(podman-compose, dev-only — defined in `compose.override.yaml`). Bring the stack
up from the repo root (`dev-up`) and the Vite dev server is published on
**http://localhost:5173**.
The dev server proxies `/api` to the backend container (`http://php:80`) so the
session cookie flows same-origin — no CORS in dev.
Run commands inside the container:
```
podman exec buckets-budget_frontend_1 npm run build # tsc -b && vite build
podman exec buckets-budget_frontend_1 npm run lint # oxlint
podman exec buckets-budget_frontend_1 npm run format # prettier --write
podman exec buckets-budget_frontend_1 npm run format:check # prettier --check
```
## Structure
- `src/theme.css` — Tailwind import + the 80s terminal theme (OKLCH tokens, glow,
7-segment font)
- `src/components/ui/` — shared primitives (Button, Panel, DigitalProgressBar)
- `src/components/layout/` — the app shell (AppLayout)
- `src/lib/api.ts` — cookie-session API client (get/post/patch/delete, JSON-LD)
- `src/types/api.ts` — Hydra/JSON-LD envelope types

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BUCKETS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1768
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
frontend/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"format": "prettier --write .",
"format:check": "prettier --check .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.1",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"oxlint": "^1.69.0",
"prettier": "^3.9.1",
"tailwindcss": "^4.3.1",
"typescript": "~6.0.2",
"vite": "^8.1.0"
}
}

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#191919" />
<text x="16" y="24" font-family="monospace" font-size="24" font-weight="bold" text-anchor="middle" fill="#ef4444">B</text>
</svg>

After

Width:  |  Height:  |  Size: 242 B

Binary file not shown.

23
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,23 @@
import AppLayout from './components/layout/AppLayout'
import Button from './components/ui/Button'
import DigitalProgressBar from './components/ui/DigitalProgressBar'
import Panel from './components/ui/Panel'
// Scaffold-only demo composition proving the primitives + theme render.
// Replaced by real routed pages in the frontend feature tickets (#52#56).
function App() {
return (
<AppLayout>
<Panel className="mx-auto max-w-md space-y-4">
<h2 className="text-primary font-bold uppercase tracking-wider">
Emergency Fund
</h2>
<DigitalProgressBar current={750} capacity={1000} />
<p className="font-digital text-primary text-xl">$750 / $1000</p>
<Button glow>+ Add Bucket</Button>
</Panel>
</AppLayout>
)
}
export default App

View file

@ -0,0 +1,18 @@
import type { ReactNode } from 'react'
interface AppLayoutProps {
children: ReactNode
}
export default function AppLayout({ children }: AppLayoutProps) {
return (
<div className="bg-background text-foreground min-h-screen font-mono">
<header className="glow-red border-primary border-b-4 px-6 py-4">
<h1 className="text-primary text-2xl font-bold uppercase tracking-widest">
BUCKETS
</h1>
</header>
<main className="p-6">{children}</main>
</div>
)
}

View file

@ -0,0 +1,19 @@
import type { ButtonHTMLAttributes } from 'react'
import { cn } from '../../lib/cn'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
glow?: boolean
}
export default function Button({ glow, className, ...props }: ButtonProps) {
return (
<button
className={cn(
'border-primary text-primary hover:bg-primary hover:text-primary-foreground border-2 bg-background px-4 py-2 font-mono font-bold uppercase tracking-wider transition-colors',
glow && 'glow-red',
className,
)}
{...props}
/>
)
}

View file

@ -0,0 +1,62 @@
interface DigitalProgressBarProps {
current: number
capacity: number
}
const BAR_COUNT = 20
/**
* Color zones by bar index, driven by the theme's chart tokens (chart-1 red
* chart-2 amber chart-3 green) so they stay in sync with the palette. `token`
* feeds the glow via color-mix; `className` is the Tailwind utility for the fill
* both reference the same source so they can't drift. `upTo` is the exclusive
* upper bound of each zone.
*/
const ZONES = [
{ upTo: 10, token: 'var(--chart-1)', className: 'bg-chart-1' },
{ upTo: 18, token: 'var(--chart-2)', className: 'bg-chart-2' },
{ upTo: BAR_COUNT, token: 'var(--chart-3)', className: 'bg-chart-3' },
] as const
function getZone(index: number) {
return ZONES.find((zone) => index < zone.upTo) ?? ZONES[ZONES.length - 1]
}
export default function DigitalProgressBar({
current,
capacity,
}: DigitalProgressBarProps) {
const filledBars =
capacity > 0
? Math.min(Math.round((current / capacity) * BAR_COUNT), BAR_COUNT)
: 0
return (
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${BAR_COUNT}, 1fr)`,
gap: '10px',
height: '2rem',
}}
>
{Array.from({ length: BAR_COUNT }, (_, i) => {
const filled = i < filledBars
const zone = getZone(i)
return (
<div
key={i}
style={{
borderRadius: '3px',
transition: 'background-color 300ms, box-shadow 300ms',
boxShadow: filled
? `0 0 8px color-mix(in oklch, ${zone.token} 60%, transparent)`
: 'none',
}}
className={filled ? zone.className : 'bg-chart-1/10'}
/>
)
})}
</div>
)
}

View file

@ -0,0 +1,13 @@
import type { HTMLAttributes } from 'react'
import { cn } from '../../lib/cn'
type PanelProps = HTMLAttributes<HTMLDivElement>
export default function Panel({ className, ...props }: PanelProps) {
return (
<div
className={cn('glow-red border-primary border-4 bg-card p-6', className)}
{...props}
/>
)
}

68
frontend/src/lib/api.ts Normal file
View file

@ -0,0 +1,68 @@
/**
* Thin API client for the Symfony + API Platform backend.
*
* Auth is cookie-session (#30): every request sends credentials so the session
* cookie set by POST /api/login flows on subsequent calls. In dev the Vite proxy
* keeps /api same-origin, so no CORS is involved. JSON-LD (Hydra) is the wire
* format; PATCH uses application/merge-patch+json per API Platform.
*/
const BASE = '/api'
/** Thrown for any non-2xx response; carries the status and raw body. */
export class ApiError extends Error {
readonly status: number
readonly body: string
constructor(status: number, body: string) {
super(`API request failed with status ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
...init,
headers: {
Accept: 'application/ld+json',
...init.headers,
},
})
if (!res.ok) {
throw new ApiError(res.status, await res.text())
}
// 204 No Content (e.g. DELETE) has an empty body — don't parse it as JSON.
// This cast assumes 204 only occurs where T is void (delete); the API does
// not 204 on GET/POST/PATCH, so a typed get<T> never reaches this path.
if (res.status === 204) {
return undefined as T
}
return res.json() as Promise<T>
}
export const api = {
get: <T>(path: string): Promise<T> => request<T>(path),
post: <T>(path: string, body: unknown): Promise<T> =>
request<T>(path, {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/ld+json' },
}),
patch: <T>(path: string, body: unknown): Promise<T> =>
request<T>(path, {
method: 'PATCH',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/merge-patch+json' },
}),
delete: (path: string): Promise<void> =>
request<void>(path, { method: 'DELETE' }),
}

13
frontend/src/lib/cn.ts Normal file
View file

@ -0,0 +1,13 @@
/**
* Minimal className joiner filters out falsy values and joins with spaces.
* Avoids pulling clsx / tailwind-merge for the small primitive set here.
*
* Note: this does NOT resolve conflicting Tailwind utilities. If a consumer
* passes a class that conflicts with a default (e.g. bg-blue-500 over bg-black),
* both land in the DOM and CSS source order decides the winner not "last wins".
*/
export function cn(
...classes: Array<string | false | null | undefined>
): string {
return classes.filter(Boolean).join(' ')
}

10
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './theme.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

100
frontend/src/theme.css Normal file
View file

@ -0,0 +1,100 @@
@import 'tailwindcss';
/* 7-segment display font for numeric amounts (recovered from the old app). */
@font-face {
font-family: '7Segment';
src: url('/fonts/7segment.woff') format('woff');
font-weight: normal;
font-style: normal;
}
.font-digital {
font-family: '7Segment', monospace;
}
/* CRT-style ambient glow for cards/buttons. */
.glow-red {
box-shadow: 0 0 20px rgba(239, 68, 68, 0.4);
transition: box-shadow 300ms ease;
}
.glow-red:hover {
box-shadow: 0 0 25px rgba(239, 68, 68, 0.6);
}
/* Reserved: the app is dark-only (palette lives unconditionally in :root), so
* no `dark:` utilities are used yet. Kept for forward-compatibility if a light
* mode is ever added. */
@custom-variant dark (&:is(.dark *));
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius: 0;
}
/*
* 80s digital theme dark only. Monochrome CRT look: foreground/card/accent
* foregrounds are deliberately bound to the same red as --primary (everything
* glows one red), so text-foreground and text-primary are intentionally
* indistinguishable not a duplicate-token bug.
*/
:root {
--background: oklch(0.1 0 0);
--foreground: oklch(0.637 0.237 25.331);
--card: oklch(0.12 0 0);
--card-foreground: oklch(0.637 0.237 25.331);
--primary: oklch(0.637 0.237 25.331);
--primary-foreground: oklch(0.1 0 0);
--secondary: oklch(0.2 0 0);
--secondary-foreground: oklch(0.637 0.237 25.331);
--muted: oklch(0.2 0 0);
--muted-foreground: oklch(0.5 0.1 25);
--accent: oklch(0.2 0 0);
--accent-foreground: oklch(0.637 0.237 25.331);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.637 0.237 25.331);
--input: oklch(0.2 0 0);
--ring: oklch(0.637 0.237 25.331);
--chart-1: oklch(0.637 0.237 25.331);
--chart-2: oklch(0.75 0.18 70);
--chart-3: oklch(0.7 0.15 145);
--chart-4: oklch(0.75 0.15 55);
--chart-5: oklch(0.5 0.1 25);
}
@layer base {
body {
@apply bg-background text-foreground;
}
}

19
frontend/src/types/api.ts Normal file
View file

@ -0,0 +1,19 @@
/**
* JSON-LD / Hydra envelope shapes shared by every API Platform resource.
* Domain models (Scenario/Bucket/Stream) are added by the feature tickets that
* consume them this file only types the transport envelope.
*/
/** Fields every API Platform item carries in a JSON-LD response. */
export interface HydraResource {
'@id': string
'@type': string
}
/** A Hydra collection response: items live under `member`. */
export interface HydraCollection<T> {
'@id': string
'@type': string
member: T[]
totalItems: number
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

28
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,28 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
// Polling + explicit HMR client port make file-watch and the HMR
// websocket work reliably when Vite runs inside a (podman) container.
watch: { usePolling: true },
hmr: { clientPort: 5173 },
allowedHosts: true,
proxy: {
// Browser hits same-origin /api; Vite forwards to the backend service
// on the compose network (service name `php`, internal port 80) so the
// session cookie flows. NOT localhost:8100 — that is the host, not this
// container.
'/api': {
target: 'http://php:80',
changeOrigin: true,
},
},
},
})

View file

@ -0,0 +1,53 @@
<?php
namespace App\Controller;
use App\Dto\RegisterRequest;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class RegisterController extends AbstractController
{
public function __construct(
private UserPasswordHasherInterface $passwordHasher,
private EntityManagerInterface $entityManager,
private ValidatorInterface $validator,
) {
}
#[Route('/api/register', name: 'api_register', methods: ['POST'])]
public function register(Request $request): JsonResponse
{
$content = $request->toArray();
$requestDto = new RegisterRequest($content['email'] ?? '', $content['password'] ?? '');
$violations = $this->validator->validate($requestDto);
if (\count($violations) > 0) {
$errors = [];
foreach ($violations as $violation) {
$errors[$violation->getPropertyPath()][] = $violation->getMessage();
}
return $this->json(['errors' => $errors], 422);
}
$user = new User();
$user->setEmail($requestDto->email);
$user->setPassword($this->passwordHasher->hashPassword($user, $requestDto->password));
$this->entityManager->persist($user);
$this->entityManager->flush();
return $this->json([
'id' => (string) $user->getId(),
'email' => $user->getEmail(),
], 201);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Dto;
use App\Entity\User;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity(entityClass: User::class, fields: ['email'], message: 'This email is already registered.')]
class RegisterRequest
{
#[Assert\NotBlank]
#[Assert\Email]
public string $email;
#[Assert\Length(min: 8)]
#[Assert\NotBlank]
public string $password;
public function __construct(
string $email,
string $password,
) {
$this->email = $email;
$this->password = $password;
}
}

96
tests/Entity/UserTest.php Normal file
View file

@ -0,0 +1,96 @@
<?php
namespace App\Tests\Entity;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Uid\UuidV7;
final class UserTest extends TestCase
{
public function testGetIdReturnsAUuidV7AssignedAtConstruction(): void
{
$user = new User();
$this->assertInstanceOf(UuidV7::class, $user->getId());
}
public function testGetRolesAlwaysIncludesRoleUserEvenWhenNoRolesAreSet(): void
{
$user = new User();
$this->assertContains('ROLE_USER', $user->getRoles());
}
public function testGetRolesMergesExplicitlySetRolesWithRoleUser(): void
{
$user = new User();
$user->setRoles(['ROLE_ADMIN']);
$roles = $user->getRoles();
$this->assertContains('ROLE_ADMIN', $roles);
$this->assertContains('ROLE_USER', $roles);
}
public function testGetRolesDeduplicatesRoleUserWhenExplicitlySet(): void
{
$user = new User();
$user->setRoles(['ROLE_USER']);
$roles = $user->getRoles();
$this->assertSame(['ROLE_USER'], array_values(array_unique($roles)));
$this->assertCount(1, array_keys($roles, 'ROLE_USER'));
}
public function testGetUserIdentifierReturnsTheEmail(): void
{
$user = new User();
$user->setEmail('alice@example.com');
$this->assertSame('alice@example.com', $user->getUserIdentifier());
}
public function testEmailGetterAndSetterRoundTrip(): void
{
$user = new User();
$returned = $user->setEmail('bob@example.com');
$this->assertSame('bob@example.com', $user->getEmail());
$this->assertSame($user, $returned, 'setEmail should return $this for fluent chaining.');
}
public function testRolesGetterAndSetterRoundTrip(): void
{
$user = new User();
$returned = $user->setRoles(['ROLE_ADMIN', 'ROLE_USER']);
$this->assertContains('ROLE_ADMIN', $user->getRoles());
$this->assertSame($user, $returned, 'setRoles should return $this for fluent chaining.');
}
public function testPasswordGetterAndSetterRoundTrip(): void
{
$user = new User();
$returned = $user->setPassword('hashed-password');
$this->assertSame('hashed-password', $user->getPassword());
$this->assertSame($user, $returned, 'setPassword should return $this for fluent chaining.');
}
public function testSerializeReplacesThePasswordWithItsCrc32cHashAndDropsTheRawValue(): void
{
$user = new User();
$user->setEmail('alice@example.com');
$user->setPassword('raw-hashed-password');
$serialized = $user->__serialize();
$key = "\0".User::class."\0password";
$this->assertArrayHasKey($key, $serialized);
$this->assertSame(hash('crc32c', 'raw-hashed-password'), $serialized[$key]);
$this->assertNotSame('raw-hashed-password', $serialized[$key]);
}
}

View file

@ -155,19 +155,16 @@ final class BucketOwnerApiTest extends WebTestCase
}
/**
* PINS CURRENT (PRE-#50-STORY-3) BEHAVIOUR — flagged for review, not a locked contract.
* Pins the LOCKED 400-not-404 decision for a bucket POSTed under a scenario IRI
* the caller does not own (ratified project-wide see PLATFORM.md "Per-user ownership").
*
* The ticket asks for 404 when POSTing a bucket under a scenario IRI the caller
* does not own. Empirically, today this is 400: API Platform's IRI denormalizer
* (`AbstractItemNormalizer::getResourceFromIri`) raises "Item not found" as soon as
* the `scenario` relation can't be resolved — Scenario's own ownership extension
* (#50 Story 2) already makes a foreign scenario IRI unresolvable to a non-owner,
* so this 400 already happens BEFORE any Bucket-specific extension code runs.
*
* This test asserts the OBSERVED 400, not the ticket's desired 404. If a 404 is
* required, it needs a normalizing guard (e.g. catching the IRI-resolution failure
* and re-throwing as NotFoundHttpException) that's an implementation decision,
* flagged to the user rather than assumed.
* API Platform's IRI denormalizer (`AbstractItemNormalizer::getResourceFromIri`)
* raises "Item not found" as soon as the `scenario` relation can't be resolved
* Scenario's own ownership extension (#50 Story 2) already makes a foreign scenario
* IRI unresolvable to a non-owner, so this 400 happens BEFORE any Bucket-specific
* extension code runs. Forcing 404 would require a normalizing guard that risks
* masking legitimate 400s; the security properties are already met (no write, no
* leak, no oracle), so 400 stays.
*/
public function testPostUnderAForeignScenarioCurrentlyReturnsBadRequestNotFound(): void
{
@ -351,15 +348,14 @@ final class BucketOwnerApiTest extends WebTestCase
}
/**
* Mirrors the class-level docblock on {@see testPostUnderAForeignScenarioCurrentlyReturnsBadRequestNotFound()}:
* this pins OBSERVED behaviour for an attempted cross-tenant re-parent via PATCH, not an assumed contract.
* Mirrors {@see testPostUnderAForeignScenarioCurrentlyReturnsBadRequestNotFound()}:
* the LOCKED 400-not-404 decision applies to an attempted cross-tenant re-parent via PATCH too.
*
* The caller PATCHes their OWN bucket but supplies a `scenario` IRI pointing at a scenario owned by
* `otherUser`. `ScenarioOwnerExtension` (#50 Story 2) already makes that IRI unresolvable to a non-owner,
* so API Platform's IRI denormalizer fails to resolve `scenario` while building the merge-patched object
* the same `AbstractItemNormalizer::getResourceFromIri` path that produces 400 on the analogous foreign-scenario
* POST. Empirically this also resolves to 400 here. Pinning it as a coherent rejection: the bucket is
* confirmed NOT re-parented below.
* POST. 400 is the ratified outcome (no write, no leak, no oracle); the bucket is confirmed NOT re-parented below.
*/
public function testPatchReparentingIntoForeignScenarioIsRejected(): void
{

View file

@ -0,0 +1,312 @@
<?php
namespace App\Tests\Functional;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
final class RegisterApiTest extends WebTestCase
{
private const PASSWORD = 'correct-horse-battery-staple';
public function testItCreatesAUserAndReturns201WithExactlyIdAndEmail(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => 'new-user@example.com',
'password' => self::PASSWORD,
]);
$response = $client->getResponse();
self::assertSame(
201,
$response->getStatusCode(),
'A successful registration must return 201 Created.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The register response must be a JSON object.');
self::assertArrayHasKey('email', $body, 'The register response must include the email field.');
self::assertSame('new-user@example.com', $body['email'], 'The register response must return the registered email.');
self::assertArrayHasKey('id', $body, 'The register response must include the id (uuid) field.');
self::assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
(string) $body['id'],
'The register response id must be a valid UUIDv7.',
);
$keys = array_keys($body);
sort($keys);
self::assertSame(
['email', 'id'],
$keys,
'The register response body must contain EXACTLY the keys [email, id] — no more, no less.',
);
$responseText = (string) $response->getContent();
self::assertStringNotContainsString(
'$2',
$responseText,
'The register response must never expose a bcrypt hash (starts with $2).',
);
self::assertStringNotContainsString(
'$argon',
$responseText,
'The register response must never expose an Argon2 hash (starts with $argon).',
);
}
public function testItPersistsAHashedPasswordThatAuthenticatesViaLogin(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => 'login-after-register@example.com',
'password' => self::PASSWORD,
]);
self::assertSame(
201,
$client->getResponse()->getStatusCode(),
'Registration must succeed before testing login.',
);
$client->jsonRequest('POST', '/api/login', [
'username' => 'login-after-register@example.com',
'password' => self::PASSWORD,
]);
$loginResponse = $client->getResponse();
self::assertLessThan(
300,
$loginResponse->getStatusCode(),
'Logging in with the just-registered credentials must succeed — proving the password was hashed, not stored plaintext.',
);
self::assertNotNull(
$loginResponse->headers->get('Set-Cookie'),
'A successful login after registration must set a session cookie.',
);
}
public function testItRejectsADuplicateEmailWith422(): void
{
$client = static::createClient();
$factory = new PasswordHasherFactory([
User::class => new NativePasswordHasher(cost: 4),
]);
$hasher = new UserPasswordHasher($factory);
$existing = new User();
$existing->setEmail('already-registered@example.com');
$existing->setPassword($hasher->hashPassword($existing, 'some-other-password'));
/** @var EntityManagerInterface $em */
$em = static::getContainer()->get(EntityManagerInterface::class);
$em->persist($existing);
$em->flush();
$client->jsonRequest('POST', '/api/register', [
'email' => 'already-registered@example.com',
'password' => self::PASSWORD,
]);
$response = $client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'Registering an already-used email must return 422 Unprocessable Entity, not 500.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The error response must be a JSON object.');
self::assertArrayHasKey('errors', $body, 'The error response must have an "errors" key.');
self::assertArrayHasKey(
'email',
$body['errors'],
'The error response must name the "email" field as the source of the violation.',
);
}
public function testItRejectsMissingOrBlankFieldsWith422(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => '',
'password' => '',
]);
$response = $client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'Blank email and password must return 422 Unprocessable Entity.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The error response must be a JSON object.');
self::assertArrayHasKey('errors', $body, 'The error response must have an "errors" key.');
self::assertArrayHasKey('email', $body['errors'], 'Blank email must produce a violation named "email".');
self::assertArrayHasKey('password', $body['errors'], 'Blank password must produce a violation named "password".');
}
public function testItRejectsAnInvalidEmailFormatWith422(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => 'not-an-email',
'password' => self::PASSWORD,
]);
$response = $client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'An invalid email format must return 422 Unprocessable Entity.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The error response must be a JSON object.');
self::assertArrayHasKey('errors', $body, 'The error response must have an "errors" key.');
self::assertArrayHasKey('email', $body['errors'], 'An invalid email format must produce a violation named "email".');
}
public function testItRejectsATooShortPasswordWith422(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => 'short-password@example.com',
'password' => 'short',
]);
$response = $client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'A too-short password must return 422 Unprocessable Entity.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The error response must be a JSON object.');
self::assertArrayHasKey('errors', $body, 'The error response must have an "errors" key.');
self::assertArrayHasKey('password', $body['errors'], 'A too-short password must produce a violation named "password".');
}
public function testItRejectsAnAbsentEmailFieldWith422(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'password' => self::PASSWORD,
]);
$response = $client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'A request body that omits the "email" key entirely must return 422 Unprocessable Entity, not 500.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The error response must be a JSON object.');
self::assertArrayHasKey('errors', $body, 'The error response must have an "errors" key.');
self::assertArrayHasKey('email', $body['errors'], 'An absent email key must produce a violation named "email".');
}
public function testItRejectsAnAbsentPasswordFieldWith422(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => 'absent-password@example.com',
]);
$response = $client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'A request body that omits the "password" key entirely must return 422 Unprocessable Entity, not 500.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The error response must be a JSON object.');
self::assertArrayHasKey('errors', $body, 'The error response must have an "errors" key.');
self::assertArrayHasKey('password', $body['errors'], 'An absent password key must produce a violation named "password".');
}
public function testItReturnsAllViolationMessagesForAFieldAsAnArray(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => 'multi-violation@example.com',
'password' => '',
]);
$response = $client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'A blank password must return 422 Unprocessable Entity.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The error response must be a JSON object.');
self::assertArrayHasKey('errors', $body, 'The error response must have an "errors" key.');
self::assertArrayHasKey('password', $body['errors'], 'A blank password must produce a violation named "password".');
self::assertIsArray(
$body['errors']['password'],
'Each field in "errors" must map to an ARRAY of messages, not a single string — a blank password triggers both NotBlank and Length violations.',
);
self::assertGreaterThan(
1,
\count($body['errors']['password']),
'A blank password violates both NotBlank and Length(min: 8) — both messages must survive, not just the last one written.',
);
}
public function testItIsReachableWithoutAuthentication(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => 'anon-reachable@example.com',
'password' => self::PASSWORD,
]);
self::assertNotSame(
401,
$client->getResponse()->getStatusCode(),
'/api/register must be publicly reachable — an unauthenticated request must not be blocked with 401 by access_control.',
);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Tests\Security;
use App\Security\LogoutSubscriber;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Http\Event\LogoutEvent;
final class LogoutSubscriberTest extends TestCase
{
public function testGetSubscribedEventsMapsLogoutEventToOnLogout(): void
{
$this->assertSame(
[LogoutEvent::class => 'onLogout'],
LogoutSubscriber::getSubscribedEvents(),
);
}
public function testOnLogoutSetsAnEmptyNoContentResponseOnTheEvent(): void
{
$event = new LogoutEvent(new Request(), null);
$subscriber = new LogoutSubscriber();
$subscriber->onLogout($event);
$response = $event->getResponse();
$this->assertInstanceOf(Response::class, $response);
$this->assertSame(Response::HTTP_NO_CONTENT, $response->getStatusCode());
$this->assertSame('', $response->getContent());
}
}