createSchool('Own School', 'OWN'); $user = $this->createActorForSchool($school); $this->actingAs($user); $this->getJson("/api/schools/{$school}") ->assertOk(); $studentResponse = $this->postJson("/api/schools/{$school}/students", [ 'first_name' => 'Maya', 'last_name' => 'Patel', 'student_number' => 'OWN-1001', 'grade_level' => '7', ]); $studentResponse->assertCreated(); $studentId = $studentResponse->json('data.id'); $this->getJson("/api/schools/{$school}/students/{$studentId}") ->assertOk() ->assertJsonPath('data.student_number', 'OWN-1001'); $this->postJson("/api/schools/{$school}/attendance/scans", [ 'student_number' => 'OWN-1001', 'scanned_at' => '2026-05-29T08:15:00Z', 'source' => 'kiosk', ])->assertCreated(); $this->createTemplate( $school, 'attendance_absence', 'email', 'Hello, {{ student.first_name }} was marked absent.' ); $this->postJson("/api/schools/{$school}/communications/preview", [ 'template_key' => 'attendance_absence', 'student_id' => $studentId, 'channel' => 'email', ])->assertOk(); $this->postJson("/api/schools/{$school}/communications/send", [ 'template_key' => 'attendance_absence', 'student_id' => $studentId, 'channel' => 'email', ])->assertAccepted(); $file = UploadedFile::fake()->createWithContent( 'payments.csv', "student_number,amount,paid_at,reference\nOWN-1001,125.00,2026-05-29,PAY-OWN-001\n" ); $this->postJson("/api/schools/{$school}/finance/payment-files", [ 'file' => $file, ])->assertCreated(); $this->getJson("/api/schools/{$school}/reports/daily?date=2026-05-29") ->assertOk(); } public function test_user_cannot_access_another_schools_data(): void { $schoolA = $this->createSchool('School A', 'S-A'); $schoolB = $this->createSchool('School B', 'S-B'); $userA = $this->createActorForSchool($schoolA); $studentB = $this->createStudent($schoolB, 'OTHER-2001', 'Other'); $this->createTemplate( $schoolB, 'attendance_absence', 'email', 'Hello, {{ student.first_name }} was marked absent.' ); $this->actingAs($userA); $this->assertTenantBlocked( $this->getJson("/api/schools/{$schoolB}") ); $this->assertTenantBlocked( $this->postJson("/api/schools/{$schoolB}/students", [ 'first_name' => 'Intruder', 'last_name' => 'Student', 'student_number' => 'BAD-1001', 'grade_level' => '8', ]) ); $this->assertTenantBlocked( $this->getJson("/api/schools/{$schoolB}/students/{$studentB}") ); $this->assertTenantBlocked( $this->postJson("/api/schools/{$schoolB}/attendance/scans", [ 'student_number' => 'OTHER-2001', 'scanned_at' => '2026-05-29T08:15:00Z', 'source' => 'kiosk', ]) ); $this->assertTenantBlocked( $this->postJson("/api/schools/{$schoolB}/communications/preview", [ 'template_key' => 'attendance_absence', 'student_id' => $studentB, 'channel' => 'email', ]) ); $this->assertTenantBlocked( $this->postJson("/api/schools/{$schoolB}/communications/send", [ 'template_key' => 'attendance_absence', 'student_id' => $studentB, 'channel' => 'email', ]) ); $file = UploadedFile::fake()->createWithContent( 'payments.csv', "student_number,amount,paid_at,reference\nOTHER-2001,125.00,2026-05-29,PAY-BAD-001\n" ); $this->assertTenantBlocked( $this->postJson("/api/schools/{$schoolB}/finance/payment-files", [ 'file' => $file, ]) ); $this->assertTenantBlocked( $this->getJson("/api/schools/{$schoolB}/reports/daily?date=2026-05-29") ); $this->assertDatabaseMissing('students', [ 'school_id' => $schoolB, 'student_identifier' => 'BAD-1001', ]); $this->assertDatabaseMissing('payments', [ 'school_id' => $schoolB, 'reference_number' => 'PAY-BAD-001', ]); } public function test_guest_cannot_access_school_workflows(): void { $school = $this->createSchool('Guest Block School', 'GST'); $student = $this->createStudent($school, 'GUEST-1001', 'Guest'); $this->createTemplate( $school, 'attendance_absence', 'email', 'Hello, {{ student.first_name }} was marked absent.' ); $this->getJson("/api/schools/{$school}") ->assertUnauthorized(); $this->postJson("/api/schools/{$school}/students", [ 'first_name' => 'Guest', 'last_name' => 'Student', 'student_number' => 'GUEST-2001', 'grade_level' => '7', ])->assertUnauthorized(); $this->getJson("/api/schools/{$school}/students/{$student}") ->assertUnauthorized(); $this->postJson("/api/schools/{$school}/attendance/scans", [ 'student_number' => 'GUEST-1001', 'scanned_at' => '2026-05-29T08:15:00Z', 'source' => 'kiosk', ])->assertUnauthorized(); $this->postJson("/api/schools/{$school}/communications/preview", [ 'template_key' => 'attendance_absence', 'student_id' => $student, 'channel' => 'email', ])->assertUnauthorized(); $this->postJson("/api/schools/{$school}/communications/send", [ 'template_key' => 'attendance_absence', 'student_id' => $student, 'channel' => 'email', ])->assertUnauthorized(); $file = UploadedFile::fake()->createWithContent( 'payments.csv', "student_number,amount,paid_at,reference\nGUEST-1001,125.00,2026-05-29,PAY-GUEST-001\n" ); $this->postJson("/api/schools/{$school}/finance/payment-files", [ 'file' => $file, ])->assertUnauthorized(); $this->getJson("/api/schools/{$school}/reports/daily?date=2026-05-29") ->assertUnauthorized(); } private function createSchool(string $name = 'Test School', ?string $code = null): int { return (int) DB::table('schools')->insertGetId([ 'name' => $name, 'code' => $code ?? 'SCH-'.uniqid(), 'domain_profile' => 'standard_school', 'timezone' => 'UTC', 'locale' => 'en', 'currency' => 'USD', 'active' => true, 'created_at' => now(), 'updated_at' => now(), ]); } private function createStudent(int $school, string $studentNumber, string $firstName = 'Maya'): int { return (int) DB::table('students')->insertGetId([ 'school_id' => $school, 'school_id_number' => $studentNumber, 'student_identifier' => $studentNumber, 'first_name' => $firstName, 'last_name' => 'Patel', 'status' => 'active', 'profile_metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR), 'metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR), 'created_at' => now(), 'updated_at' => now(), ]); } private function createTemplate(int $school, string $key, string $channel, string $body): int { return (int) DB::table('communication_templates')->insertGetId([ 'school_id' => $school, 'template_key' => $key, 'version' => '1', 'status' => 'active', 'channel' => $channel, 'message_category' => 'general', 'language' => 'en', 'subject' => 'Notice for {{ student.first_name }}', 'body' => $body, 'created_at' => now(), 'updated_at' => now(), ]); } private function createActorForSchool(int $school): Authenticatable { $this->ensureAuthTestTablesExist(); $userId = (int) DB::table('users')->insertGetId([ 'name' => 'Test User '.$school, 'email' => 'user'.$school.'@example.test', 'password' => bcrypt('password'), 'created_at' => now(), 'updated_at' => now(), ]); DB::table('school_user')->insert([ 'school_id' => $school, 'user_id' => $userId, 'role' => 'admin', 'created_at' => now(), 'updated_at' => now(), ]); return new GenericUser([ 'id' => $userId, 'name' => 'Test User '.$school, 'email' => 'user'.$school.'@example.test', ]); } private function ensureAuthTestTablesExist(): void { if (! Schema::hasTable('users')) { Schema::create('users', function ($table): void { $table->id(); $table->string('name'); $table->string('email')->unique(); $table->string('password'); $table->rememberToken(); $table->timestamps(); }); } if (! Schema::hasTable('school_user')) { Schema::create('school_user', function ($table): void { $table->id(); $table->unsignedBigInteger('school_id')->index(); $table->unsignedBigInteger('user_id')->index(); $table->string('role')->default('admin'); $table->timestamps(); $table->unique(['school_id', 'user_id']); }); } } private function assertTenantBlocked($response): void { $this->assertContains( $response->getStatusCode(), [403, 404], 'Cross-tenant access must return either 403 or 404.' ); } }