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
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:
Vendored
BIN
Binary file not shown.
@@ -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>
|
||||
*/
|
||||
|
||||
Vendored
BIN
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',
|
||||
|
||||
Reference in New Issue
Block a user