333 lines
11 KiB
PHP
333 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Integration;
|
|
|
|
use Illuminate\Auth\GenericUser;
|
|
use Illuminate\Contracts\Auth\Authenticatable;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Tests\TestCase;
|
|
|
|
final class AuthTenantIsolationIntegrationTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_user_can_access_own_school_workflows(): void
|
|
{
|
|
$school = $this->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.'
|
|
);
|
|
}
|
|
} |