From 83881bfee501228ab9bf854f3a5d8ade654d93dd Mon Sep 17 00:00:00 2001 From: KeyVan Date: Fri, 9 Oct 2026 10:07:21 -0400 Subject: [PATCH] Add dashboard login and per-user data scoping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web dashboard was public: anyone who could reach the server could browse every project and session. - Session login (/login, rate-limited to 5 attempts/min) and logout; every dashboard route now requires auth. No public registration — accounts are created with `php artisan movez:user ` - Dashboard counts, projects, sessions and the migration wizard only show the signed-in user's data; other users' records return 404 - Seeder adds a local-only demo login (demo@movez.test / password) used by the Playwright suite, which now signs in once via a setup project - CI runs the Playwright end-to-end suite - Docs: account creation and the removed "no login" warning Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 17 ++++ README.md | 6 +- docs/sync-server-setup.md | 6 +- web/.gitignore | 1 + web/README.md | 5 +- web/app/Http/Controllers/AuthController.php | 47 +++++++++++ .../Http/Controllers/DashboardController.php | 12 +-- .../Http/Controllers/MigrationController.php | 10 ++- .../Http/Controllers/ProjectController.php | 11 ++- .../Http/Controllers/SessionController.php | 6 +- .../Http/Middleware/HandleInertiaRequests.php | 4 +- web/app/Models/AiSession.php | 7 ++ web/database/seeders/DatabaseSeeder.php | 10 +++ web/e2e/auth.spec.ts | 20 +++++ web/e2e/global.setup.ts | 15 ++++ web/playwright.config.ts | 4 +- web/resources/js/Pages/Auth/Login.vue | 78 +++++++++++++++++++ web/resources/js/Pages/Dashboard.vue | 15 +++- web/resources/js/types/index.d.ts | 12 +++ web/routes/console.php | 32 ++++++++ web/routes/web.php | 23 ++++-- web/test-results/.last-run.json | 4 + web/tests/Feature/AuthTest.php | 76 ++++++++++++++++++ web/tests/Feature/DashboardAndSessionTest.php | 45 ++++++++--- web/tests/Feature/MigrationControllerTest.php | 26 +++++-- web/tests/Feature/ProjectControllerTest.php | 31 ++++++-- 26 files changed, 473 insertions(+), 50 deletions(-) create mode 100644 web/app/Http/Controllers/AuthController.php create mode 100644 web/e2e/auth.spec.ts create mode 100644 web/e2e/global.setup.ts create mode 100644 web/resources/js/Pages/Auth/Login.vue create mode 100644 web/test-results/.last-run.json create mode 100644 web/tests/Feature/AuthTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a3c788..93c4ac0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,23 @@ jobs: working-directory: web run: php artisan test + - name: Seed demo data for end-to-end tests + working-directory: web + run: php artisan db:seed --force + + - name: Install Playwright browser + working-directory: web + run: npx playwright install --with-deps chromium + + - name: Run end-to-end tests + working-directory: web + run: | + php artisan serve --port=8000 > /tmp/serve.log 2>&1 & + for i in $(seq 1 30); do curl -sf http://127.0.0.1:8000/login > /dev/null && break; sleep 1; done + npx playwright test + env: + PLAYWRIGHT_BASE_URL: http://127.0.0.1:8000 + extension-build: name: VS Code Extension Build runs-on: ubuntu-latest diff --git a/README.md b/README.md index 19b0352..d9c2b30 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,9 @@ Sessions are written under `~/.claude/projects//`. On Windows they ## Sync between machines -1. **Run a sync server.** See [docs/sync-server-setup.md](docs/sync-server-setup.md). Then issue yourself a token: +1. **Run a sync server.** See [docs/sync-server-setup.md](docs/sync-server-setup.md). Then create your account and issue yourself a token: ```bash + php artisan movez:user you@example.com php artisan movez:token you@example.com ``` 2. **Configure each machine.** Prefer environment variables, because command-line arguments are visible to other processes: @@ -175,7 +176,7 @@ Install `movez-vscode.vsix` with **Extensions: Install from VSIX…**. It adds a ## Web dashboard -The sync server also serves a dashboard: projects, sessions, and a migration wizard that builds the exact `movez transfer` command for you. It is meant for a private, single-user deployment; the dashboard has no login yet (see Roadmap). +The sync server also serves a dashboard: projects, sessions, and a migration wizard that builds the exact `movez transfer` command for you. Sign in with an account created on the server (`php artisan movez:user you@example.com`); there is no public registration, and each user only sees their own projects and sessions. --- @@ -252,7 +253,6 @@ cd electron-app && npm run typecheck # desktop app type check ## Roadmap - Windsurf support (blocked on its encrypted Cascade storage) -- Login and per-user scoping for the web dashboard - Server-side migration jobs (Horizon) - macOS and Linux desktop builds diff --git a/docs/sync-server-setup.md b/docs/sync-server-setup.md index 72999e8..ce9bc6d 100644 --- a/docs/sync-server-setup.md +++ b/docs/sync-server-setup.md @@ -67,10 +67,12 @@ php artisan migrate --force ### 4. Create a user and an API token ```bash -php artisan tinker --execute="App\Models\User::create(['name' => 'Me', 'email' => 'me@example.com', 'password' => 'change-me']);" +php artisan movez:user me@example.com --name="Me" # prompts for a password (min 12 characters) php artisan movez:token me@example.com ``` +There is no public registration. Every dashboard user is created this way, and each user only sees their own projects, sessions and sync data. + `movez:token` prints the token **once**. Only its SHA-256 hash is stored. Running it again replaces the old token. ### 5. Web server (Nginx) @@ -98,7 +100,7 @@ server { } ``` -> The web dashboard has **no login yet**. Restrict it (VPN, IP allow-list or HTTP basic auth on everything except `/api/*`) until authentication lands. The `/api/sync/*` endpoints are protected by API tokens. +> The dashboard requires signing in (`/login`, rate-limited to 5 attempts per minute). The `/api/sync/*` endpoints use API tokens instead of the login session. --- diff --git a/web/.gitignore b/web/.gitignore index b71b1ea..88b253d 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -22,3 +22,4 @@ Homestead.json Homestead.yaml Thumbs.db +/e2e/.auth/ diff --git a/web/README.md b/web/README.md index c4f3a9e..b273643 100644 --- a/web/README.md +++ b/web/README.md @@ -13,7 +13,7 @@ Deployment: see [docs/sync-server-setup.md](../docs/sync-server-setup.md). Proje composer install npm ci cp .env.example .env && php artisan key:generate -touch database/database.sqlite && php artisan migrate --seed # prints dev API tokens +touch database/database.sqlite && php artisan migrate --seed # demo login demo@movez.test / password, prints dev API tokens npm run dev # Vite php artisan serve # http://localhost:8000 ``` @@ -21,6 +21,7 @@ php artisan serve # http://localhost:8000 ```bash php artisan test # Pest feature + unit tests npm run type-check # vue-tsc -npm run test:e2e # Playwright (starts php artisan serve locally) +npm run test:e2e # Playwright (starts php artisan serve locally; needs the seeded demo user) +php artisan movez:user you@example.com # create a dashboard user (no public registration) php artisan movez:token you@example.com # issue a sync token ``` diff --git a/web/app/Http/Controllers/AuthController.php b/web/app/Http/Controllers/AuthController.php new file mode 100644 index 0000000..5217c45 --- /dev/null +++ b/web/app/Http/Controllers/AuthController.php @@ -0,0 +1,47 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + + if (!Auth::attempt($credentials, $request->boolean('remember'))) { + throw ValidationException::withMessages([ + 'email' => 'These credentials do not match our records.', + ]); + } + + $request->session()->regenerate(); + + return redirect()->intended(route('dashboard')); + } + + public function destroy(Request $request): RedirectResponse + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('login'); + } +} diff --git a/web/app/Http/Controllers/DashboardController.php b/web/app/Http/Controllers/DashboardController.php index 10446a5..8cc2ad1 100644 --- a/web/app/Http/Controllers/DashboardController.php +++ b/web/app/Http/Controllers/DashboardController.php @@ -4,20 +4,22 @@ namespace App\Http\Controllers; use App\Models\AiSession; -use App\Models\Project; use App\Models\SyncEvent; +use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; class DashboardController extends Controller { - public function index(): Response + public function index(Request $request): Response { + $user = $request->user(); + return Inertia::render('Dashboard', [ 'stats' => [ - 'total_sessions' => AiSession::count(), - 'total_projects' => Project::count(), - 'last_sync' => SyncEvent::latest('created_at')->value('created_at'), + 'total_sessions' => AiSession::ownedBy($user)->count(), + 'total_projects' => $user->projects()->count(), + 'last_sync' => SyncEvent::where('user_id', $user->id)->latest('created_at')->value('created_at'), ], ]); } diff --git a/web/app/Http/Controllers/MigrationController.php b/web/app/Http/Controllers/MigrationController.php index a03119d..e9ddcf0 100644 --- a/web/app/Http/Controllers/MigrationController.php +++ b/web/app/Http/Controllers/MigrationController.php @@ -6,6 +6,7 @@ use App\Models\Project; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; use Inertia\Inertia; use Inertia\Response; @@ -17,12 +18,12 @@ class MigrationController extends Controller /** Tools the CLI can write to. */ private const TARGET_TOOLS = ['cursor', 'claude-code', 'codex', 'copilot-cli']; - public function wizard(): Response + public function wizard(Request $request): Response { return Inertia::render('Migration/Wizard', [ 'supportedTools' => self::SOURCE_TOOLS, 'writableTools' => self::TARGET_TOOLS, - 'projects' => Project::latest()->get(['id', 'name', 'path']), + 'projects' => $request->user()->projects()->latest()->get(['id', 'name', 'path']), ]); } @@ -35,7 +36,10 @@ public function start(Request $request): JsonResponse $validated = $request->validate([ 'from_tool' => ['required', 'string', 'in:' . implode(',', self::SOURCE_TOOLS)], 'to_tool' => ['required', 'string', 'in:' . implode(',', self::TARGET_TOOLS), 'different:from_tool'], - 'project_id' => ['nullable', 'integer', 'exists:projects,id'], + 'project_id' => [ + 'nullable', 'integer', + Rule::exists('projects', 'id')->where('user_id', $request->user()->id), + ], 'from_path' => ['nullable', 'string', 'max:1024'], 'to_path' => ['nullable', 'string', 'max:1024'], ]); diff --git a/web/app/Http/Controllers/ProjectController.php b/web/app/Http/Controllers/ProjectController.php index 3f7da84..cf48da7 100644 --- a/web/app/Http/Controllers/ProjectController.php +++ b/web/app/Http/Controllers/ProjectController.php @@ -4,14 +4,16 @@ namespace App\Http\Controllers; use App\Models\Project; +use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; class ProjectController extends Controller { - public function index(): Response + public function index(Request $request): Response { - $projects = Project::withCount('aiSessions') + $projects = $request->user()->projects() + ->withCount('aiSessions') ->latest() ->paginate(20); @@ -20,8 +22,11 @@ public function index(): Response ]); } - public function show(Project $project): Response + public function show(Request $request, Project $project): Response { + // 404 rather than 403 so other users' project ids are not revealed + abort_unless($project->user_id === $request->user()->id, 404); + return Inertia::render('Projects/Show', [ 'project' => $project, 'sessions' => $project->aiSessions()->latest()->get(), diff --git a/web/app/Http/Controllers/SessionController.php b/web/app/Http/Controllers/SessionController.php index ed8abe3..037b095 100644 --- a/web/app/Http/Controllers/SessionController.php +++ b/web/app/Http/Controllers/SessionController.php @@ -4,15 +4,19 @@ namespace App\Http\Controllers; use App\Models\AiSession; +use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; class SessionController extends Controller { - public function show(AiSession $session): Response + public function show(Request $request, AiSession $session): Response { $session->load('project'); + // Sessions are owned through their project; orphaned sessions belong to nobody + abort_unless($session->project?->user_id === $request->user()->id, 404); + return Inertia::render('Sessions/Show', [ 'session' => $session, 'sessionData' => $session->getDecodedSessionData(), diff --git a/web/app/Http/Middleware/HandleInertiaRequests.php b/web/app/Http/Middleware/HandleInertiaRequests.php index b970198..348b8f2 100644 --- a/web/app/Http/Middleware/HandleInertiaRequests.php +++ b/web/app/Http/Middleware/HandleInertiaRequests.php @@ -39,7 +39,9 @@ public function share(Request $request): array { return [ ...parent::share($request), - // + 'auth' => [ + 'user' => fn () => $request->user()?->only(['id', 'name', 'email']), + ], ]; } } diff --git a/web/app/Models/AiSession.php b/web/app/Models/AiSession.php index 86a9405..8a2f445 100644 --- a/web/app/Models/AiSession.php +++ b/web/app/Models/AiSession.php @@ -3,6 +3,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -29,6 +30,12 @@ public function project(): BelongsTo return $this->belongsTo(Project::class); } + /** Sessions belonging to the user's projects. */ + public function scopeOwnedBy(Builder $query, User $user): Builder + { + return $query->whereHas('project', fn (Builder $q) => $q->where('user_id', $user->id)); + } + /** * Decode session_data, falling back to an empty array for anything * that is not a JSON object/array. diff --git a/web/database/seeders/DatabaseSeeder.php b/web/database/seeders/DatabaseSeeder.php index 1b30eaa..952dc9b 100644 --- a/web/database/seeders/DatabaseSeeder.php +++ b/web/database/seeders/DatabaseSeeder.php @@ -14,6 +14,16 @@ public function run(): void { $users = User::factory(2)->create(); + // Known login for local development and the Playwright suite — never in production + if (app()->environment(['local', 'testing'])) { + $users->prepend(User::factory()->create([ + 'name' => 'Demo', + 'email' => 'demo@movez.test', + 'password' => 'password', + ])); + $this->command?->info('Dashboard login: demo@movez.test / password'); + } + foreach ($users as $user) { // Only the hash is stored, so print the plaintext once for local testing $token = $user->issueApiToken(); diff --git a/web/e2e/auth.spec.ts b/web/e2e/auth.spec.ts new file mode 100644 index 0000000..6340958 --- /dev/null +++ b/web/e2e/auth.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Authentication', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('guests are sent to the login page', async ({ page }) => { + await page.goto('/projects'); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); + }); + + test('wrong password shows an error', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel('Email').fill('demo@movez.test'); + await page.getByLabel('Password').fill('not-the-password'); + await page.getByRole('button', { name: 'Sign in' }).click(); + + await expect(page.getByText('These credentials do not match our records.')).toBeVisible(); + }); +}); diff --git a/web/e2e/global.setup.ts b/web/e2e/global.setup.ts new file mode 100644 index 0000000..dfca647 --- /dev/null +++ b/web/e2e/global.setup.ts @@ -0,0 +1,15 @@ +import { test as setup, expect } from '@playwright/test'; + +// Signs in once and saves the session for all other specs. +// Uses the demo user created by `php artisan db:seed` in local environments. +const authFile = 'e2e/.auth/user.json'; + +setup('sign in', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel('Email').fill(process.env.E2E_EMAIL ?? 'demo@movez.test'); + await page.getByLabel('Password').fill(process.env.E2E_PASSWORD ?? 'password'); + await page.getByRole('button', { name: 'Sign in' }).click(); + + await expect(page).toHaveURL(/\/$/); + await page.context().storageState({ path: authFile }); +}); diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 0f61de5..ce28ca0 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -12,9 +12,11 @@ export default defineConfig({ screenshot: 'only-on-failure', }, projects: [ + { name: 'setup', testMatch: /global\.setup\.ts/ }, { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' }, + dependencies: ['setup'], }, ], webServer: process.env.CI diff --git a/web/resources/js/Pages/Auth/Login.vue b/web/resources/js/Pages/Auth/Login.vue new file mode 100644 index 0000000..c158e99 --- /dev/null +++ b/web/resources/js/Pages/Auth/Login.vue @@ -0,0 +1,78 @@ + + + diff --git a/web/resources/js/Pages/Dashboard.vue b/web/resources/js/Pages/Dashboard.vue index f48ee2b..f43828e 100644 --- a/web/resources/js/Pages/Dashboard.vue +++ b/web/resources/js/Pages/Dashboard.vue @@ -11,6 +11,15 @@ Migration Wizard + {{ user.name }} + + Sign out + @@ -62,9 +71,13 @@ diff --git a/web/resources/js/types/index.d.ts b/web/resources/js/types/index.d.ts index 932fdca..54440ae 100644 --- a/web/resources/js/types/index.d.ts +++ b/web/resources/js/types/index.d.ts @@ -54,3 +54,15 @@ export interface DashboardStats { } export type SyncStatus = 'synced' | 'pending' | 'error'; + +export interface AuthUser { + id: number; + name: string; + email: string; +} + +/** Props shared with every page by HandleInertiaRequests. */ +export interface SharedProps { + auth: { user: AuthUser | null }; + [key: string]: unknown; +} diff --git a/web/routes/console.php b/web/routes/console.php index 787758c..1cfb1d8 100644 --- a/web/routes/console.php +++ b/web/routes/console.php @@ -4,6 +4,38 @@ use App\Models\User; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Validator; + +Artisan::command('movez:user {email : Login email} {--name= : Display name}', function (string $email): int { + if (User::where('email', $email)->exists()) { + $this->error("A user with email {$email} already exists"); + return 1; + } + + $password = $this->secret('Password (min 12 characters)'); + $check = Validator::make(['email' => $email, 'password' => $password], [ + 'email' => ['required', 'email'], + 'password' => ['required', 'string', 'min:12'], + ]); + + if ($check->fails()) { + foreach ($check->errors()->all() as $message) { + $this->error($message); + } + return 1; + } + + User::create([ + 'name' => $this->option('name') ?: strstr($email, '@', true), + 'email' => $email, + 'password' => $password, // hashed by the model's cast + ]); + + $this->info("Created {$email}. Sign in at " . config('app.url') . '/login'); + $this->line("Issue a sync token with: php artisan movez:token {$email}"); + + return 0; +})->purpose('Create a dashboard user (there is no public registration)'); Artisan::command('movez:token {email : Email of an existing user}', function (string $email): int { $user = User::where('email', $email)->first(); diff --git a/web/routes/web.php b/web/routes/web.php index 907f5a6..e2ae233 100644 --- a/web/routes/web.php +++ b/web/routes/web.php @@ -2,18 +2,29 @@ declare(strict_types=1); +use App\Http\Controllers\AuthController; use App\Http\Controllers\DashboardController; use App\Http\Controllers\MigrationController; use App\Http\Controllers\ProjectController; use App\Http\Controllers\SessionController; use Illuminate\Support\Facades\Route; -Route::get('/', [DashboardController::class, 'index'])->name('dashboard'); +// No public registration: users are created on the server with `php artisan movez:user` +Route::middleware('guest')->group(function (): void { + Route::get('/login', [AuthController::class, 'create'])->name('login'); + Route::post('/login', [AuthController::class, 'store'])->middleware('throttle:5,1')->name('login.store'); +}); -Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index'); -Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show'); +Route::middleware('auth')->group(function (): void { + Route::post('/logout', [AuthController::class, 'destroy'])->name('logout'); -Route::get('/sessions/{session}', [SessionController::class, 'show'])->name('sessions.show'); + Route::get('/', [DashboardController::class, 'index'])->name('dashboard'); -Route::get('/migration/wizard', [MigrationController::class, 'wizard'])->name('migration.wizard'); -Route::post('/migration/start', [MigrationController::class, 'start'])->name('migration.start'); + Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index'); + Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show'); + + Route::get('/sessions/{session}', [SessionController::class, 'show'])->name('sessions.show'); + + Route::get('/migration/wizard', [MigrationController::class, 'wizard'])->name('migration.wizard'); + Route::post('/migration/start', [MigrationController::class, 'start'])->name('migration.start'); +}); diff --git a/web/test-results/.last-run.json b/web/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/web/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/web/tests/Feature/AuthTest.php b/web/tests/Feature/AuthTest.php new file mode 100644 index 0000000..da28af1 --- /dev/null +++ b/web/tests/Feature/AuthTest.php @@ -0,0 +1,76 @@ +get($url)->assertRedirect('/login'); +})->with(['/', '/projects', '/projects/1', '/sessions/1', '/migration/wizard']); + +it('rejects guest migration requests', function (): void { + $this->postJson('/migration/start', ['from_tool' => 'cursor', 'to_tool' => 'codex'])->assertUnauthorized(); +}); + +it('shows the login page', function (): void { + $this->withoutVite()->get('/login') + ->assertStatus(200) + ->assertInertia(fn (Assert $page) => $page->component('Auth/Login')); +}); + +it('logs in with valid credentials', function (): void { + $user = User::factory()->create(['password' => 'correct-horse-battery']); + + $this->post('/login', ['email' => $user->email, 'password' => 'correct-horse-battery']) + ->assertRedirect('/'); + + $this->assertAuthenticatedAs($user); +}); + +it('rejects invalid credentials', function (): void { + $user = User::factory()->create(['password' => 'correct-horse-battery']); + + $this->post('/login', ['email' => $user->email, 'password' => 'wrong']) + ->assertSessionHasErrors('email'); + + $this->assertGuest(); +}); + +it('throttles repeated login attempts', function (): void { + $user = User::factory()->create(); + + foreach (range(1, 5) as $_) { + $this->post('/login', ['email' => $user->email, 'password' => 'wrong']); + } + + $this->post('/login', ['email' => $user->email, 'password' => 'wrong'])->assertStatus(429); +}); + +it('logs out', function (): void { + $this->actingAs(User::factory()->create()) + ->post('/logout') + ->assertRedirect('/login'); + + $this->assertGuest(); +}); + +it('redirects logged-in users away from the login page', function (): void { + $this->actingAs(User::factory()->create())->get('/login')->assertRedirect('/'); +}); + +it('movez:user creates a user that can log in', function (): void { + $this->artisan('movez:user', ['email' => 'owner@example.com', '--name' => 'Owner']) + ->expectsQuestion('Password (min 12 characters)', 'long-enough-password') + ->assertExitCode(0); + + $this->post('/login', ['email' => 'owner@example.com', 'password' => 'long-enough-password']) + ->assertRedirect('/'); +}); + +it('movez:user rejects short passwords', function (): void { + $this->artisan('movez:user', ['email' => 'owner@example.com']) + ->expectsQuestion('Password (min 12 characters)', 'short') + ->assertExitCode(1); + + expect(User::where('email', 'owner@example.com')->exists())->toBeFalse(); +}); diff --git a/web/tests/Feature/DashboardAndSessionTest.php b/web/tests/Feature/DashboardAndSessionTest.php index a3b1b8e..d608a1c 100644 --- a/web/tests/Feature/DashboardAndSessionTest.php +++ b/web/tests/Feature/DashboardAndSessionTest.php @@ -4,26 +4,36 @@ use App\Models\AiSession; use App\Models\Project; use App\Models\SyncEvent; +use App\Models\User; use Inertia\Testing\AssertableInertia as Assert; -it('dashboard shows counts and last sync', function (): void { - AiSession::factory(3)->create(); - SyncEvent::factory()->create(); +it('dashboard shows the user\'s own counts and last sync', function (): void { + $user = User::factory()->create(); + $project = Project::factory()->create(['user_id' => $user->id]); + AiSession::factory(3)->create(['project_id' => $project->id]); + SyncEvent::factory()->create(['user_id' => $user->id]); - $this->withoutVite()->get('/') + // Someone else's data must not be counted + AiSession::factory(5)->create(); + + $this->actingAs($user)->withoutVite()->get('/') ->assertStatus(200) ->assertInertia(fn (Assert $page) => $page ->component('Dashboard') ->where('stats.total_sessions', 3) - ->where('stats.total_projects', 3) + ->where('stats.total_projects', 1) ->whereNot('stats.last_sync', null) + ->where('auth.user.email', $user->email) ); }); it('session page decodes session data without leaking the raw payload', function (): void { - $session = AiSession::factory()->create(); + $user = User::factory()->create(); + $session = AiSession::factory()->create([ + 'project_id' => Project::factory()->create(['user_id' => $user->id])->id, + ]); - $this->withoutVite()->get("/sessions/{$session->id}") + $this->actingAs($user)->withoutVite()->get("/sessions/{$session->id}") ->assertStatus(200) ->assertInertia(fn (Assert $page) => $page ->component('Sessions/Show') @@ -34,18 +44,31 @@ }); it('session page tolerates malformed session data', function (): void { - $session = AiSession::factory()->create(['session_data' => '"just a string"']); + $user = User::factory()->create(); + $session = AiSession::factory()->create([ + 'project_id' => Project::factory()->create(['user_id' => $user->id])->id, + 'session_data' => '"just a string"', + ]); - $this->withoutVite()->get("/sessions/{$session->id}") + $this->actingAs($user)->withoutVite()->get("/sessions/{$session->id}") ->assertStatus(200) ->assertInertia(fn (Assert $page) => $page->where('sessionData', [])); }); +it('another user\'s session is not found', function (): void { + $session = AiSession::factory()->create(); + + $this->actingAs(User::factory()->create())->withoutVite() + ->get("/sessions/{$session->id}") + ->assertNotFound(); +}); + it('projects index lists session counts without loading sessions', function (): void { - $project = Project::factory()->create(); + $user = User::factory()->create(); + $project = Project::factory()->create(['user_id' => $user->id]); AiSession::factory(2)->create(['project_id' => $project->id]); - $this->withoutVite()->get('/projects') + $this->actingAs($user)->withoutVite()->get('/projects') ->assertInertia(fn (Assert $page) => $page ->component('Projects/Index') ->where('projects.data.0.ai_sessions_count', 2) diff --git a/web/tests/Feature/MigrationControllerTest.php b/web/tests/Feature/MigrationControllerTest.php index 4a69393..c87c3f6 100644 --- a/web/tests/Feature/MigrationControllerTest.php +++ b/web/tests/Feature/MigrationControllerTest.php @@ -2,17 +2,25 @@ declare(strict_types=1); use App\Models\Project; +use App\Models\User; use Inertia\Testing\AssertableInertia as Assert; -it('migration wizard returns Inertia page', function (): void { - $response = $this->withoutVite()->get('/migration/wizard'); +beforeEach(function (): void { + $this->user = User::factory()->create(); + $this->actingAs($this->user); +}); + +it('migration wizard returns Inertia page with the user\'s projects', function (): void { + Project::factory()->create(['user_id' => $this->user->id]); + Project::factory()->create(); // someone else's - $response->assertStatus(200) + $this->withoutVite()->get('/migration/wizard') + ->assertStatus(200) ->assertInertia(fn (Assert $page) => $page ->component('Migration/Wizard') ->has('supportedTools') ->has('writableTools') - ->has('projects') + ->has('projects', 1) ); }); @@ -26,8 +34,16 @@ ->assertJsonValidationErrors('to_tool'); }); +it('migration start rejects another user\'s project', function (): void { + $project = Project::factory()->create(); + + $this->postJson('/migration/start', ['from_tool' => 'cursor', 'to_tool' => 'codex', 'project_id' => $project->id]) + ->assertStatus(422) + ->assertJsonValidationErrors('project_id'); +}); + it('migration start returns the CLI command to run', function (): void { - $project = Project::factory()->create(['path' => '/home/dev/my app']); + $project = Project::factory()->create(['user_id' => $this->user->id, 'path' => '/home/dev/my app']); $this->postJson('/migration/start', [ 'from_tool' => 'cursor', diff --git a/web/tests/Feature/ProjectControllerTest.php b/web/tests/Feature/ProjectControllerTest.php index b9fc00a..dbd1b60 100644 --- a/web/tests/Feature/ProjectControllerTest.php +++ b/web/tests/Feature/ProjectControllerTest.php @@ -6,22 +6,41 @@ use Inertia\Testing\AssertableInertia as Assert; it('projects index returns Inertia page', function (): void { - $response = $this->withoutVite()->get('/projects'); - - $response->assertStatus(200) + $this->actingAs(User::factory()->create()) + ->withoutVite()->get('/projects') + ->assertStatus(200) ->assertInertia(fn (Assert $page) => $page->component('Projects/Index')); }); +it('projects index only lists the user\'s own projects', function (): void { + $user = User::factory()->create(); + Project::factory()->create(['user_id' => $user->id, 'name' => 'Mine']); + Project::factory()->create(['name' => 'Theirs']); + + $this->actingAs($user)->withoutVite()->get('/projects') + ->assertInertia(fn (Assert $page) => $page + ->has('projects.data', 1) + ->where('projects.data.0.name', 'Mine') + ); +}); + it('projects show returns project data', function (): void { $user = User::factory()->create(); $project = Project::factory()->create(['user_id' => $user->id]); - $response = $this->withoutVite()->get("/projects/{$project->id}"); - - $response->assertStatus(200) + $this->actingAs($user)->withoutVite()->get("/projects/{$project->id}") + ->assertStatus(200) ->assertInertia(fn (Assert $page) => $page ->component('Projects/Show') ->has('project') ->has('sessions') ); }); + +it('another user\'s project is not found', function (): void { + $project = Project::factory()->create(); + + $this->actingAs(User::factory()->create())->withoutVite() + ->get("/projects/{$project->id}") + ->assertNotFound(); +});