add school year and security fix
API CI/CD / Validate (composer + pint) (push) Successful in 3m14s
API CI/CD / Test (PHPUnit) (push) Failing after 3m28s
API CI/CD / Build frontend assets (push) Successful in 1m2s
API CI/CD / Security audit (push) Failing after 56s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped

This commit is contained in:
root
2026-07-06 00:55:01 -04:00
parent 99782c2a3c
commit 39228168c8
40 changed files with 20370 additions and 70 deletions
BIN
View File
Binary file not shown.
+78
View File
@@ -6,6 +6,8 @@ use App\Models\Configuration;
use App\Models\Role;
use App\Models\SchoolYear;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Laravel\Sanctum\Sanctum;
trait CreatesApiTestUsers
@@ -64,6 +66,7 @@ trait CreatesApiTestUsers
{
$roles = $this->seedApiRoles();
$this->seedApiTestConfig();
$this->seedSchoolYearClosurePermissionsForTests($roles);
$user = User::factory()->create(array_merge([
'status' => 'Active',
@@ -91,6 +94,81 @@ trait CreatesApiTestUsers
return $user;
}
/**
* Keep test fixtures aligned with explicit permission middleware.
* Production receives these through the closure permission migration.
*
* @param array<string, Role> $roles
*/
protected function seedSchoolYearClosurePermissionsForTests(array $roles): void
{
if (! Schema::hasTable('permissions') || ! Schema::hasTable('role_permissions')) {
return;
}
$permissions = [
'school_year.view',
'school_year.view_closed',
'school_year.create',
'school_year.close',
'school_year.reopen',
'school_year.archive',
'school_year.manage',
'school_year.select',
'student_enrollment.view',
'student_enrollment.promote',
'finance.view',
'finance.balance_transfer.view',
'reports.view',
];
foreach ($permissions as $permission) {
DB::table('permissions')->updateOrInsert(
['name' => $permission],
['description' => 'Test permission '.$permission, 'created_at' => now(), 'updated_at' => now()]
);
}
$adminRoleIds = collect([$roles['administrator'] ?? null, $roles['admin'] ?? null])->filter()->map(fn (Role $role) => (int) $role->id)->all();
$teacherRoleId = isset($roles['teacher']) ? (int) $roles['teacher']->id : null;
$parentRoleId = isset($roles['parent']) ? (int) $roles['parent']->id : null;
$this->grantTestPermissions($adminRoleIds, $permissions);
if ($teacherRoleId) {
$this->grantTestPermissions([$teacherRoleId], ['school_year.view', 'school_year.view_closed', 'school_year.select', 'student_enrollment.view']);
}
if ($parentRoleId) {
$this->grantTestPermissions([$parentRoleId], ['school_year.view', 'school_year.select', 'finance.view']);
}
}
/** @param list<int> $roleIds */
private function grantTestPermissions(array $roleIds, array $permissions): void
{
foreach ($roleIds as $roleId) {
foreach ($permissions as $permission) {
$permissionId = (int) DB::table('permissions')->where('name', $permission)->value('id');
if ($permissionId <= 0) {
continue;
}
DB::table('role_permissions')->updateOrInsert(
['role_id' => $roleId, 'permission_id' => $permissionId],
[
'can_create' => true,
'can_read' => true,
'can_update' => true,
'can_delete' => true,
'can_manage' => true,
'created_at' => now(),
'updated_at' => now(),
]
);
}
}
}
/**
* @return array<string, mixed>
*/
BIN
View File
Binary file not shown.
@@ -5,6 +5,7 @@ namespace Tests\Feature\Api\V1\SchoolYears;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class SchoolYearControllerTest extends TestCase
@@ -31,10 +32,122 @@ class SchoolYearControllerTest extends TestCase
->assertJsonPath('data.promotion_preview.summary.promote', 1)
->assertJsonPath('data.parent_balances.summary.total_old_unpaid_balance', 200);
$this->assertSame(200.0, (float) $response->json('data.parent_balances.rows.0.amount_to_transfer'));
$balanceRows = collect($response->json('data.parent_balances.rows'));
$parentRow = $balanceRows->first(fn (array $row): bool => (int) $row['parent_id'] === 10);
$this->assertCount(1, $balanceRows);
$this->assertNotNull($parentRow);
$this->assertSame(200.0, (float) $parentRow['amount_to_transfer']);
$this->assertSame('promote', $response->json('data.promotion_preview.rows.0.promotion_action'));
}
public function test_preview_close_excludes_zero_invoice_balance_parent_even_when_parent_account_is_stale(): void
{
$this->seedClosureData();
$this->seedZeroBalanceParentWithStaleAccount();
$this->actingAs(User::query()->findOrFail(1), 'api');
$response = $this->postJson('/api/v1/school-years/1/preview-close', [
'new_school_year' => [
'name' => '2026-2027',
'start_date' => '2026-09-01',
'end_date' => '2027-06-30',
],
'transfer_unpaid_balances' => true,
]);
$response->assertOk()
->assertJsonPath('data.validation.can_close', true)
->assertJsonPath('data.parent_balances.summary.total_old_unpaid_balance', 200)
->assertJsonPath('data.parent_balances.summary.net_balance_to_transfer', 200);
$balanceRows = collect($response->json('data.parent_balances.rows'));
$this->assertTrue(
$balanceRows->contains(fn (array $row): bool => (int) $row['parent_id'] === 10 && (float) $row['amount_to_transfer'] === 200.0)
);
$this->assertFalse(
$balanceRows->contains(fn (array $row): bool => (int) $row['parent_id'] === 11),
'Parent with invoice balance 0 must not appear in closure balance preview, even when parent_accounts.current_balance is stale.'
);
}
public function test_close_school_year_does_not_transfer_zero_invoice_balance_parent_even_when_parent_account_is_stale(): void
{
$this->seedClosureData();
$this->seedZeroBalanceParentWithStaleAccount();
$this->actingAs(User::query()->findOrFail(1), 'api');
$this->postJson('/api/v1/school-years/1/close', [
'new_school_year' => [
'name' => '2026-2027',
'start_date' => '2026-09-01',
'end_date' => '2027-06-30',
],
'transfer_unpaid_balances' => true,
'confirmation' => 'CLOSE 2025-2026',
])->assertOk();
$this->assertDatabaseHas('parent_balance_transfers', [
'parent_id' => 10,
'from_school_year' => '2025-2026',
'to_school_year' => '2026-2027',
'amount' => 200.00,
'status' => 'transferred',
]);
$this->assertDatabaseMissing('parent_balance_transfers', [
'parent_id' => 11,
'from_school_year' => '2025-2026',
'to_school_year' => '2026-2027',
]);
$this->assertDatabaseMissing('invoices', [
'parent_id' => 11,
'school_year' => '2026-2027',
'description' => 'Opening balance transferred from 2025-2026',
]);
}
public function test_parent_cannot_close_school_year(): void
{
$this->seedClosureData();
$this->actingAs(User::query()->findOrFail(10), 'api');
$this->postJson('/api/v1/school-years/1/close', [
'new_school_year' => [
'name' => '2026-2027',
'start_date' => '2026-09-01',
'end_date' => '2027-06-30',
],
'transfer_unpaid_balances' => true,
'confirmation' => 'CLOSE 2025-2026',
])->assertForbidden();
}
public function test_school_year_closure_routes_have_explicit_permission_middleware(): void
{
$routes = collect(app('router')->getRoutes()->getRoutes())
->filter(fn ($route) => str_starts_with($route->uri(), 'api/v1/school-years'));
$missing = [];
foreach ($routes as $route) {
$middleware = $route->gatherMiddleware();
$hasPermission = collect($middleware)->contains(fn (string $middleware): bool => str_starts_with($middleware, 'perm:'));
$hasAuth = collect($middleware)->contains(fn (string $middleware): bool => str_contains($middleware, 'multi.auth') || str_contains($middleware, 'auth'));
$hasAccountGuard = in_array('account.active', $middleware, true);
if (! $hasPermission || ! $hasAuth || ! $hasAccountGuard) {
$missing[] = implode('|', $route->methods()).' '.$route->uri();
}
}
$this->assertSame([], $missing, 'School-year routes missing auth/account/permission middleware: '.implode(', ', $missing));
}
public function test_admin_can_create_draft_school_year_via_api(): void
{
$this->seedClosureData();
@@ -178,6 +291,68 @@ class SchoolYearControllerTest extends TestCase
->assertJsonPath('message', 'School year 2025-2026 is closed and read-only.');
}
private function seedZeroBalanceParentWithStaleAccount(): void
{
DB::table('users')->insert([
'id' => 11,
'school_id' => 1,
'firstname' => 'Zero',
'lastname' => 'Balance',
'cellphone' => '5555555556',
'email' => 'zero@example.com',
'address_street' => '123 Main',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'is_verified' => 1,
'status' => 'Active',
'is_suspended' => 0,
'failed_attempts' => 0,
'password' => bcrypt('secret'),
'semester' => 'Fall',
'school_year' => '2025-2026',
]);
DB::table('user_roles')->insert([
'user_id' => 11,
'role_id' => 2,
]);
$parentAccount = [
'parent_id' => 11,
'school_year' => '2025-2026',
'opening_balance' => 999,
'current_balance' => 999,
];
if (Schema::hasColumn('parent_accounts', 'created_at')) {
$parentAccount['created_at'] = now();
}
if (Schema::hasColumn('parent_accounts', 'updated_at')) {
$parentAccount['updated_at'] = now();
}
DB::table('parent_accounts')->insert($parentAccount);
DB::table('invoices')->insert([
'id' => 501,
'parent_id' => 11,
'invoice_number' => 'INV-501',
'total_amount' => 500,
'balance' => 0,
'paid_amount' => 500,
'has_discount' => 0,
'issue_date' => '2026-05-10',
'due_date' => '2026-06-10',
'status' => 'overdue',
'description' => 'Paid invoice with stale overdue status',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
'updated_by' => 1,
'semester' => 'Fall',
]);
}
private function seedClosureData(): void
{
DB::table('configuration')->insert([
@@ -238,6 +413,38 @@ class SchoolYearControllerTest extends TestCase
['user_id' => 10, 'role_id' => 2],
]);
$permissions = [
'school_year.view',
'school_year.create',
'school_year.manage',
'school_year.close',
'school_year.reopen',
'school_year.archive',
'school_year.select',
'student_enrollment.promote',
'finance.balance_transfer.view',
];
foreach ($permissions as $permission) {
$permissionId = DB::table('permissions')->insertGetId([
'name' => $permission,
'description' => 'Test permission '.$permission,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('role_permissions')->insert([
'role_id' => 1,
'permission_id' => $permissionId,
'can_create' => 1,
'can_read' => 1,
'can_update' => 1,
'can_delete' => 1,
'can_manage' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
}
DB::table('school_years')->insert([
'id' => 1,
'name' => '2025-2026',