Compare commits
8 commits
cb4af693b1
...
5777946faf
| Author | SHA1 | Date | |
|---|---|---|---|
| 5777946faf | |||
| f76c7b9ecd | |||
| 79367b3de0 | |||
| 28757d1e6b | |||
| d72b672b23 | |||
| 4ac02d1ee9 | |||
| 41bcc1b905 | |||
| 1e9abf884b |
32 changed files with 2882 additions and 16 deletions
|
|
@ -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
2
frontend/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*.woff binary
|
||||
*.woff2 binary
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
8
frontend/.oxlintrc.json
Normal 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
4
frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
package-lock.json
|
||||
*.tsbuildinfo
|
||||
7
frontend/.prettierrc.json
Normal file
7
frontend/.prettierrc.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2
|
||||
}
|
||||
38
frontend/README.md
Normal file
38
frontend/README.md
Normal 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
13
frontend/index.html
Normal 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
1768
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal 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 |
BIN
frontend/public/fonts/7segment.woff
Normal file
BIN
frontend/public/fonts/7segment.woff
Normal file
Binary file not shown.
23
frontend/src/App.tsx
Normal file
23
frontend/src/App.tsx
Normal 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
|
||||
18
frontend/src/components/layout/AppLayout.tsx
Normal file
18
frontend/src/components/layout/AppLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
frontend/src/components/ui/Button.tsx
Normal file
19
frontend/src/components/ui/Button.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
62
frontend/src/components/ui/DigitalProgressBar.tsx
Normal file
62
frontend/src/components/ui/DigitalProgressBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
frontend/src/components/ui/Panel.tsx
Normal file
13
frontend/src/components/ui/Panel.tsx
Normal 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
68
frontend/src/lib/api.ts
Normal 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
13
frontend/src/lib/cn.ts
Normal 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
10
frontend/src/main.tsx
Normal 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
100
frontend/src/theme.css
Normal 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
19
frontend/src/types/api.ts
Normal 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
|
||||
}
|
||||
25
frontend/tsconfig.app.json
Normal file
25
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
frontend/tsconfig.node.json
Normal file
23
frontend/tsconfig.node.json
Normal 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
28
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
53
src/Controller/RegisterController.php
Normal file
53
src/Controller/RegisterController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
27
src/Dto/RegisterRequest.php
Normal file
27
src/Dto/RegisterRequest.php
Normal 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
96
tests/Entity/UserTest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
312
tests/Functional/RegisterApiTest.php
Normal file
312
tests/Functional/RegisterApiTest.php
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
34
tests/Security/LogoutSubscriberTest.php
Normal file
34
tests/Security/LogoutSubscriberTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue