fxi first test round

This commit is contained in:
root
2026-05-30 01:08:24 -04:00
parent f55213a3df
commit 6a40de4937
11 changed files with 1303 additions and 5 deletions
@@ -0,0 +1,333 @@
<?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.'
);
}
}
@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Integration;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Auth\GenericUser;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
final class SchoolWorkflowIntegrationTest extends TestCase
{
use RefreshDatabase;
public function test_school_can_be_created_and_read_through_http(): void
{
$response = $this->postJson('/api/schools', [
'name' => 'North Ridge School',
'code' => 'NRS',
'timezone' => 'America/New_York',
]);
$response->assertCreated()
->assertJsonPath('data.name', 'North Ridge School')
->assertJsonPath('data.code', 'NRS');
$schoolId = (int) $response->json('data.id');
$this->actingAsSchoolUser($schoolId);
$this->getJson("/api/schools/{$schoolId}")
->assertOk()
->assertJsonPath('data.timezone', 'America/New_York');
$this->assertDatabaseHas('schools', [
'id' => $schoolId,
'code' => 'NRS',
]);
}
public function test_student_can_be_created_for_school_and_isolated_by_school(): void
{
$schoolA = $this->createSchool('School A', 'S-A');
$schoolB = $this->createSchool('School B', 'S-B');
$response = $this->postJson("/api/schools/{$schoolA}/students", [
'first_name' => 'Maya',
'last_name' => 'Patel',
'student_number' => 'STU-1001',
'grade_level' => '7',
]);
$response->assertCreated()
->assertJsonPath('data.first_name', 'Maya')
->assertJsonPath('data.student_number', 'STU-1001');
$studentId = $response->json('data.id');
$this->getJson("/api/schools/{$schoolA}/students/{$studentId}")
->assertOk()
->assertJsonPath('data.grade_level', '7');
$this->getJson("/api/schools/{$schoolB}/students/{$studentId}")
->assertNotFound();
}
public function test_attendance_scan_records_once_and_duplicate_scan_is_idempotent(): void
{
$school = $this->createSchool();
$this->actingAsSchoolUser($school);
$student = $this->createStudent($school, 'STU-2001');
$payload = [
'student_number' => 'STU-2001',
'scanned_at' => '2026-05-29T08:15:00Z',
'source' => 'kiosk',
];
$this->postJson("/api/schools/{$school}/attendance/scans", $payload)
->assertCreated()
->assertJsonPath('data.status', 'recorded');
$this->postJson("/api/schools/{$school}/attendance/scans", $payload)
->assertOk()
->assertJsonPath('data.status', 'duplicate');
$this->assertDatabaseHas('attendance_records', [
'school_id' => $school,
'subject_type' => 'student',
'subject_id' => $student,
'status' => 'present',
]);
$this->assertSame(1, DB::table('attendance_records')->where('school_id', $school)->where('subject_id', $student)->count());
$this->assertSame(2, DB::table('attendance_scan_logs')->where('school_id', $school)->where('subject_id', $student)->count());
}
public function test_communication_preview_has_no_side_effect_and_send_queues_recipient(): void
{
$school = $this->createSchool();
$this->actingAsSchoolUser($school);
$student = $this->createStudent($school, 'STU-3001', 'Maya');
$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' => $student,
'channel' => 'email',
])->assertOk()
->assertJsonPath('data.body', 'Hello, Maya was marked absent.');
$this->assertDatabaseCount('communication_messages', 0);
$this->assertDatabaseCount('communication_message_recipients', 0);
$this->postJson("/api/schools/{$school}/communications/send", [
'template_key' => 'attendance_absence',
'student_id' => $student,
'channel' => 'email',
])->assertAccepted()
->assertJsonPath('data.status', 'queued');
$this->assertDatabaseHas('communication_messages', [
'school_id' => $school,
'channel' => 'email',
'status' => 'queued',
]);
$this->assertDatabaseHas('communication_message_recipients', [
'school_id' => $school,
'recipient_type' => 'student',
'recipient_id' => $student,
'status' => 'queued',
]);
}
public function test_payment_file_imports_valid_rows_and_rejects_invalid_rows_without_partial_import(): void
{
$school = $this->createSchool();
$this->actingAsSchoolUser($school);
$student = $this->createStudent($school, 'STU-4001');
$validFile = UploadedFile::fake()->createWithContent(
'payments.csv',
"student_number,amount,paid_at,reference\nSTU-4001,125.00,2026-05-29,PAY-001\n"
);
$this->postJson("/api/schools/{$school}/finance/payment-files", [
'file' => $validFile,
])->assertCreated()
->assertJsonPath('data.imported_count', 1);
$this->assertDatabaseHas('payments', [
'school_id' => $school,
'student_id' => $student,
'amount_minor' => 12500,
'reference_number' => 'PAY-001',
]);
$invalidFile = UploadedFile::fake()->createWithContent(
'bad-payments.csv',
"student_number,amount,paid_at,reference\nSTU-4001,80.00,2026-05-29,PAY-002\nMISSING,90.00,2026-05-29,PAY-003\n"
);
$this->postJson("/api/schools/{$school}/finance/payment-files", [
'file' => $invalidFile,
])->assertUnprocessable();
$this->assertDatabaseMissing('payments', [
'school_id' => $school,
'reference_number' => 'PAY-002',
]);
$this->assertDatabaseMissing('payments', [
'school_id' => $school,
'reference_number' => 'PAY-003',
]);
}
public function test_daily_report_aggregates_attendance_and_finance(): void
{
$school = $this->createSchool();
$this->actingAsSchoolUser($school);
$student = $this->createStudent($school, 'STU-5001');
$this->postJson("/api/schools/{$school}/attendance/scans", [
'student_number' => 'STU-5001',
'scanned_at' => '2026-05-29T08:15:00Z',
'source' => 'kiosk',
])->assertCreated();
DB::table('payments')->insert([
'school_id' => $school,
'student_id' => $student,
'amount_minor' => 12500,
'currency' => 'USD',
'payment_method' => 'manual',
'payment_date' => '2026-05-29',
'reference_number' => 'PAY-RPT-001',
'status' => 'recorded',
'created_at' => now(),
'updated_at' => now(),
]);
$this->getJson("/api/schools/{$school}/reports/daily?date=2026-05-29")
->assertOk()
->assertJsonPath('data.attendance.present_count', 1)
->assertJsonPath('data.finance.total_payments', 12500);
}
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 actingAsSchoolUser(int $school): Authenticatable
{
$this->ensureAuthTestTablesExist();
$userId = (int) DB::table('users')->insertGetId([
'name' => 'Workflow User '.$school,
'email' => 'workflow'.$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(),
]);
$user = new GenericUser([
'id' => $userId,
'name' => 'Workflow User '.$school,
'email' => 'workflow'.$school.'@example.test',
]);
$this->actingAs($user);
return $user;
}
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']);
});
}
}
}